Skip to content

Self served invoices / billing history#481

Open
Blaumaus wants to merge 1 commit intomainfrom
feature/self-served-invoices
Open

Self served invoices / billing history#481
Blaumaus wants to merge 1 commit intomainfrom
feature/self-served-invoices

Conversation

@Blaumaus
Copy link
Member

@Blaumaus Blaumaus commented Mar 7, 2026

Changes

If applicable, please describe what changes were made in this pull request.

Community Edition support

  • Your feature is implemented for the Swetrix Community Edition
  • This PR only updates the Cloud (Enterprise) Edition code (e.g. Paddle webhooks, blog, payouts, etc.)

Database migrations

  • Clickhouse / MySQL migrations added for this PR
  • No table schemas changed in this PR

Documentation

  • You have updated the documentation according to your PR
  • This PR did not change any publicly documented endpoints

Summary by CodeRabbit

Release Notes

  • New Features
    • Added invoice history view in user settings, displaying billing invoices with date, amount, status, and plan information.
    • Users can now download receipt files directly from their invoice records.
    • Automatic invoice synchronization with payment provider for accurate billing records.

@Blaumaus Blaumaus self-assigned this Mar 7, 2026
@coderabbitai
Copy link

coderabbitai bot commented Mar 7, 2026

📝 Walkthrough

Walkthrough

This PR introduces billing invoice functionality across the full stack. It adds a new BillingInvoice entity and database table, extends UserService with invoice CRUD and Paddle synchronization operations, adds two controller endpoints for invoice retrieval and receipt download, integrates invoice persistence into Paddle webhook handling, and provides a new invoice history UI on the UserSettings page with download capabilities.

Changes

Cohort / File(s) Summary
Entity & ORM Configuration
backend/apps/cloud/src/user/entities/billing-invoice.entity.ts, backend/apps/cloud/src/user/entities/user.entity.ts, backend/apps/cloud/src/user/user.module.ts
New BillingInvoice entity with UUID key, userId foreign key with CASCADE delete, unique providerPaymentId, invoice amount/currency/status fields, and timestamps. OneToMany relation added to User entity. Entity registered in UserModule TypeORM configuration.
Database Schema
backend/migrations/mysql/2026_03_07_billing_invoices.sql
MySQL migration creating billing_invoice table with indexes on userId and providerPaymentId, foreign key constraint on userId with CASCADE delete, default values for provider ('paddle') and status ('paid').
Service Layer
backend/apps/cloud/src/user/user.service.ts
Adds BillingInvoice repository injection and five methods: getInvoicesForUser(), getInvoiceById(), createInvoice(), upsertInvoice(), and syncSubscriptionPayments(). The sync method calls Paddle API to fetch subscription payments, maps results to invoice records, and persists via upsert.
Controller Endpoints
backend/apps/cloud/src/user/user.controller.ts
Two new public endpoints: GET /user/billing/invoices (fetches invoices, auto-syncs if empty with valid subID) and GET /user/billing/invoices/:id/download (redirects to receipt URL with validation).
Webhook Integration
backend/apps/cloud/src/webhook/webhook.controller.ts
Extended subscription_payment_succeeded handler to parse additional Paddle fields and conditionally persist invoice via upsertInvoice when orderId is present, with error logging but no flow interruption.
Frontend Type Definition
web/app/lib/models/BillingInvoice.ts
New TypeScript interface for BillingInvoice with id, amount, currency, status (paid/refunded/pending), planCode, billingFrequency, receiptUrl, and billedAt fields.
Frontend Routes & Loader
web/app/routes/user-settings.tsx
Extended UserSettingsLoaderData type with invoices field; adds parallel fetch call to 'user/billing/invoices' endpoint and propagates result into component data; initializes invoices to null for self-hosted scenario.
Frontend UI
web/app/pages/UserSettings/UserSettings.tsx
New "Invoice History" SettingsSection within Billing tab displaying table of invoices with columns for date, amount, status badge, plan/frequency, and download action; includes locale-aware date and currency formatting, status-to-badge mapping, and no-invoices placeholder state.
Localization
web/public/locales/en.json
Added 11 new translation keys for invoice history section: invoiceHistory, invoiceHistoryDesc, invoiceDate, invoiceAmount, invoiceStatus, invoicePlan, invoiceDownload, noInvoices, and status nested object with paid/refunded/pending values.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client/Browser
    participant Controller as UserController
    participant Service as UserService
    participant DB as Database
    participant PaddleAPI as Paddle API
    
    Client->>Controller: GET /user/billing/invoices
    Controller->>Service: getInvoicesForUser(userId)
    Service->>DB: Query invoices for user
    DB-->>Service: Return invoices (empty)
    
    alt No invoices and subID present
        Controller->>Service: syncSubscriptionPayments(userId, subID)
        Service->>PaddleAPI: Query subscription payments
        PaddleAPI-->>Service: Return payment records
        Service->>Service: Map to invoice data
        Service->>DB: Upsert invoices
        DB-->>Service: Persisted invoices
        Service-->>Controller: Invoice list
    else Invoices exist
        Service-->>Controller: Invoice list
    end
    
    Controller-->>Client: Return invoices JSON
    Client->>Controller: GET /user/billing/invoices/:id/download
    Controller->>Service: getInvoiceById(id, userId)
    Service->>DB: Query invoice by id
    DB-->>Service: Return invoice
    Service-->>Controller: Invoice with receiptUrl
    Controller-->>Client: Redirect to receiptUrl
Loading
sequenceDiagram
    participant PaddleService as Paddle Service
    participant WebhookCtrl as WebhookController
    participant UserService as UserService
    participant DB as Database
    
    PaddleService->>WebhookCtrl: subscription_payment_succeeded event
    WebhookCtrl->>UserService: Update subscriber (existing flow)
    WebhookCtrl->>UserService: Invalidate cache (existing flow)
    
    alt orderId present in event
        WebhookCtrl->>WebhookCtrl: Extract payment fields<br/>(amount, currency, plan, receipt_url, etc)
        WebhookCtrl->>UserService: upsertInvoice(invoiceData)
        UserService->>DB: Upsert billing_invoice
        DB-->>UserService: Success/Failure
        UserService-->>WebhookCtrl: Invoice persisted/error logged
    end
    
    WebhookCtrl-->>PaddleService: 200 OK
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • Move Billing to user settings #470: Modifies UserSettings billing UI layout; this PR adds invoice history display to the same billing section, creating overlapping UI changes that may require coordination.

Poem

🐰 Hops of joy through the payment flow,
Invoices sync from Paddle's glow,
Receipts downloaded, statuses displayed,
A billing feature perfectly made!
💳✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Self served invoices / billing history' clearly and concisely describes the main feature addition: implementing self-service invoice and billing history functionality.
Description check ✅ Passed The PR description follows the required template structure with all sections present, though the 'Changes' section lacks detailed explanation of what was implemented.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/self-served-invoices

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 8

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/apps/cloud/src/webhook/webhook.controller.ts (1)

266-275: ⚠️ Potential issue | 🟠 Major

Unsuspend the account when the payment recovery succeeds.

This path clears dashboardBlockReason, but it leaves isAccountBillingSuspended unchanged. Users who were locked for payment_failed can stay billing-suspended even after Paddle sends a successful payment event.

Suggested fix
         if (
           subscriber.dashboardBlockReason ===
           DashboardBlockReason.payment_failed
         ) {
           updateParams.dashboardBlockReason = null
+          updateParams.isAccountBillingSuspended = false
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/webhook/webhook.controller.ts` around lines 266 - 275,
The code clears dashboardBlockReason for payment failures but doesn't un-suspend
billing; when subscriber.dashboardBlockReason ===
DashboardBlockReason.payment_failed set updateParams.isAccountBillingSuspended =
false alongside updateParams.dashboardBlockReason = null so the account is
unsuspended, then proceed to call this.userService.updateBySubID(subID,
updateParams) and this.projectService.clearProjectsRedisCacheBySubId(subID) as
before.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/apps/cloud/src/user/entities/billing-invoice.entity.ts`:
- Around line 41-42: The entity's amount Column is declared as number but
DECIMAL is hydrated as string by mysql2; update the `@Column` on the amount
property in billing-invoice.entity.ts to include a transformer that converts
database strings to numbers on read (e.g. from: v => v === null ? null :
parseFloat(v)) and preserves numbers on write (to: v => v), so consumers
(user.controller.ts Number(inv.amount) and
user.service._toNumber(payment.amount)) no longer need manual conversions;
alternatively, set decimalNumbers: true in the DataSource config and keep the
entity type as number.

In `@backend/apps/cloud/src/user/user.controller.ts`:
- Around line 813-820: The current logic in user.controller.ts only calls
userService.syncSubscriptionPayments when getInvoicesForUser(userId) returns
zero invoices, which leaves gaps if the local table is partially populated;
change the flow so that for subscribed users (when user.subID is present) you
invoke syncSubscriptionPayments proactively (either unconditionally before
reloading from DB or gated by a proper sync cursor/lastSync timestamp) and then
re-query getInvoicesForUser to load the refreshed records; update the call sites
referencing getInvoicesForUser and syncSubscriptionPayments so de-duplication in
user.service.ts (the providerPaymentId dedupe) prevents duplicates while
ensuring a full sync occurs for subscribed users.

In `@backend/apps/cloud/src/user/user.service.ts`:
- Around line 796-809: The code currently copies the live User.planCode and
User.billingFrequency into historical invoices when calling upsertInvoice (see
findOne result assigned to user and the upsertInvoice call), which incorrectly
stamps today's plan onto past payments; change the payload to derive planCode
and billingFrequency from the payment/provider response (e.g.,
payment.plan_code, payment.plan_id, payment.metadata, or providerSubscriptionId)
when available, and otherwise pass null for planCode and billingFrequency
instead of using user.planCode/user.billingFrequency so backfilled invoices
reflect the original billing info or remain unknown.
- Around line 753-765: The current upsertInvoice implementation uses a
read-then-write (findOne + update/save) causing a race that can trigger
duplicate-key errors on providerPaymentId; replace that flow with a single
atomic call using billingInvoiceRepository.upsert() (or Repository.upsert) keyed
on providerPaymentId so concurrent syncs become safe: remove the
findOne/update/save branch and call upsert with the incoming data and
conflictTargets set to ['providerPaymentId'], then return the resulting
BillingInvoice as before.

In `@backend/apps/cloud/src/webhook/webhook.controller.ts`:
- Around line 278-292: Validate saleGross before creating an invoice: instead of
parseFloat(saleGross) || 0, parse saleGross with Number or parseFloat and if it
is NaN or <= 0 do not call this.userService.upsertInvoice (return or log and
skip persisting a $0.00 invoice); replace the hardcoded 'paid' as any with the
proper InvoiceStatus enum (e.g. InvoiceStatus.PAID) and add an import for
InvoiceStatus at the top of the file; keep the rest of the upsertInvoice payload
the same and ensure providerPaymentId uses String(orderId) as before.

In `@web/app/pages/UserSettings/UserSettings.tsx`:
- Around line 1554-1555: The invoice table wrapper currently uses the div with
class 'overflow-hidden rounded-lg border ...' which clips the table on small
screens; change the wrapper to allow horizontal scrolling (e.g., replace or
augment 'overflow-hidden' with 'overflow-x-auto' or wrap the <table
class="min-w-full divide-y divide-gray-200 dark:divide-slate-800"> in a new div
that has 'overflow-x-auto' so the five non-wrapping columns (including the
receipt action) can scroll horizontally on small screens while preserving the
rounded border and other styles.
- Around line 1591-1611: The code in UserSettings.tsx assigns a '$' fallback in
the currencySymbol computation which mislabels non-EUR/GBP currencies; update
the rendering to use a proper currency-aware formatter instead of hardcoded
symbols—use Intl.NumberFormat with invoice.currency (e.g., new
Intl.NumberFormat(locale, { style: 'currency', currency: invoice.currency
})).format(Number(invoice.amount)) or build a currencySymbol map (referencing
currencySymbol and invoice.currency in the file) so invoices show the correct
symbol/format for CAD/AUD/SEK/etc.

In `@web/public/locales/en.json`:
- Around line 1999-2010: Add the missing invoice translation keys to each
non-English locale (de.json, fr.json, pl.json, uk.json) under the same "billing"
object used in en.json: include billing.invoiceHistory,
billing.invoiceHistoryDesc, billing.invoiceDate, billing.invoiceAmount,
billing.invoiceStatus, billing.invoicePlan, billing.invoiceDownload,
billing.noInvoices and the nested billing.status.paid, billing.status.refunded,
billing.status.pending keys; preserve the JSON structure and key names exactly
as in web/public/locales/en.json and provide proper translated strings for each
key in the respective language files.

---

Outside diff comments:
In `@backend/apps/cloud/src/webhook/webhook.controller.ts`:
- Around line 266-275: The code clears dashboardBlockReason for payment failures
but doesn't un-suspend billing; when subscriber.dashboardBlockReason ===
DashboardBlockReason.payment_failed set updateParams.isAccountBillingSuspended =
false alongside updateParams.dashboardBlockReason = null so the account is
unsuspended, then proceed to call this.userService.updateBySubID(subID,
updateParams) and this.projectService.clearProjectsRedisCacheBySubId(subID) as
before.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: fd0aadc1-fc3f-4c95-b4a6-02d50ebc336e

📥 Commits

Reviewing files that changed from the base of the PR and between 682e848 and 55a6ba8.

📒 Files selected for processing (11)
  • backend/apps/cloud/src/user/entities/billing-invoice.entity.ts
  • backend/apps/cloud/src/user/entities/user.entity.ts
  • backend/apps/cloud/src/user/user.controller.ts
  • backend/apps/cloud/src/user/user.module.ts
  • backend/apps/cloud/src/user/user.service.ts
  • backend/apps/cloud/src/webhook/webhook.controller.ts
  • backend/migrations/mysql/2026_03_07_billing_invoices.sql
  • web/app/lib/models/BillingInvoice.ts
  • web/app/pages/UserSettings/UserSettings.tsx
  • web/app/routes/user-settings.tsx
  • web/public/locales/en.json

Comment on lines +41 to +42
@Column('decimal', { precision: 10, scale: 2 })
amount: number
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, verify the file exists and examine the relevant code
cat -n backend/apps/cloud/src/user/entities/billing-invoice.entity.ts | head -60

Repository: Swetrix/swetrix

Length of output: 1737


🏁 Script executed:

# Check TypeORM version
find . -name "package.json" -type f | head -5 | xargs grep -l "typeorm"

Repository: Swetrix/swetrix

Length of output: 102


🏁 Script executed:

# Look for TypeORM configuration
find . -name "*.ts" -o -name "*.js" | xargs grep -l "createConnection\|DataSource\|DatabaseModule" | head -5

Repository: Swetrix/swetrix

Length of output: 143


🏁 Script executed:

# Check TypeORM version in backend
cat backend/package.json | grep -A 5 "typeorm"

Repository: Swetrix/swetrix

Length of output: 404


🏁 Script executed:

# Look at MySQL configuration
cat -n admin/src/db/mysql.ts

Repository: Swetrix/swetrix

Length of output: 2029


🏁 Script executed:

# Check for transformer patterns in codebase
rg "transformer\s*:" backend/apps/cloud/src --type ts -B 2 -A 2 | head -40

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Check frontend model expectations
cat web/app/lib/models/BillingInvoice.ts 2>/dev/null || echo "File not found"

Repository: Swetrix/swetrix

Length of output: 292


🏁 Script executed:

# Search for API endpoints that return BillingInvoice
rg "BillingInvoice" backend/apps/cloud/src --type ts | grep -E "controller|service" | head -10

Repository: Swetrix/swetrix

Length of output: 1017


🏁 Script executed:

# Check for other decimal columns in the codebase
rg "@Column\('decimal'" backend/apps/cloud/src --type ts -B 2 -A 1

Repository: Swetrix/swetrix

Length of output: 418


🏁 Script executed:

# Look for any existing transformers in the codebase
rg "transformer.*from.*value.*string\|from.*value.*Number" backend/apps/cloud/src --type ts -B 2 -A 2

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Check the billing invoice controller/service to see how data is returned
find backend/apps/cloud/src -name "*billing*" -o -name "*invoice*" | head -10

Repository: Swetrix/swetrix

Length of output: 121


🏁 Script executed:

# Look at the user service implementation for BillingInvoice methods
cat -n backend/apps/cloud/src/user/user.service.ts | grep -A 15 "getInvoicesForUser"

Repository: Swetrix/swetrix

Length of output: 552


🏁 Script executed:

# Check if there's a user controller
find backend/apps/cloud/src -name "*user*controller*" -type f

Repository: Swetrix/swetrix

Length of output: 105


🏁 Script executed:

# Search for API endpoints that return invoices
rg "invoices\|BillingInvoice" backend/apps/cloud/src --type ts | grep -E "router|controller|get.*route|@Get" | head -15

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Check TypeORM 0.3.28 documentation or test behavior
# Let's search for any explicit type casting or parsing of amount field
rg "amount.*Number\|Number.*amount\|parseFloat.*amount\|parseDecimal" backend/apps/cloud/src --type ts

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Check the user controller
cat -n backend/apps/cloud/src/user/user.controller.ts | head -100

Repository: Swetrix/swetrix

Length of output: 4207


🏁 Script executed:

# Search for invoice endpoints in the controller
rg "invoices\|getInvoices" backend/apps/cloud/src/user/user.controller.ts -B 2 -A 10

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Let's look for any DTOs that might transform the data
find backend/apps/cloud/src -name "*dto*" -o -name "*types*" | xargs rg "BillingInvoice" 2>/dev/null | head -10

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Search for web response serialization or formatting
rg "ClassSerializerInterceptor\|Transform\|toJSON" backend/apps/cloud/src/user --type ts | head -10

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Let's search for test files that might reveal actual runtime behavior
find backend -name "*.spec.ts" -o -name "*.test.ts" | xargs grep -l "BillingInvoice\|decimal" 2>/dev/null | head -5

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Check if there are any integration tests
find backend -type d -name "test" -o -name "tests" -o -name "__tests__"

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Let's look for package.json in backend to check for test setup
cat backend/package.json | grep -A 5 "jest\|test"

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Search for any migrations that might define the schema
find backend -name "*migration*" -type f | head -5

Repository: Swetrix/swetrix

Length of output: 106


🏁 Script executed:

# Check the actual database schema definition or any comments about decimal handling
rg "DECIMAL\|decimal.*string\|decimal.*number" backend --type ts --type sql | head -20

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Let's check how the amount field is used in the codebase
rg "\.amount" backend/apps/cloud/src --type ts | head -20

Repository: Swetrix/swetrix

Length of output: 796


🏁 Script executed:

# Check for any response serialization in the service or controller
cat -n backend/apps/cloud/src/user/user.service.ts | grep -A 30 "getInvoicesForUser\|createInvoice" | head -50

Repository: Swetrix/swetrix

Length of output: 1765


🏁 Script executed:

# Let's check if there's a payment provider integration that might parse the amount
rg "paddle\|stripe\|payment.*provider" backend/apps/cloud/src --type ts -l | head -5

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Search for how amount values are created/inserted
rg "amount.*:" backend/apps/cloud/src/user --type ts -B 2 -A 2 | grep -E "amount|createInvoice|updateInvoice" | head -30

Repository: Swetrix/swetrix

Length of output: 308


🏁 Script executed:

# This is a critical finding - let's see the full context of the controller where amount is being converted
cat -n backend/apps/cloud/src/user/user.controller.ts | grep -B 10 -A 10 "amount: Number"

Repository: Swetrix/swetrix

Length of output: 686


🏁 Script executed:

# Let's verify what _toNumber does
rg "_toNumber\s*=" backend --type ts -A 3

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Check where invoices are returned in the controller
rg "getInvoices\|getInvoice" backend/apps/cloud/src/user/user.controller.ts -B 3 -A 15

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Let's verify the complete picture by checking all places where amount is used
rg "inv\.amount|invoice\.amount|BillingInvoice.*amount" backend/apps/cloud/src --type ts -B 1 -A 1

Repository: Swetrix/swetrix

Length of output: 281


🏁 Script executed:

# Check the service methods to see if they also convert
cat -n backend/apps/cloud/src/user/user.service.ts | grep -B 5 -A 20 "syncSubscriptionPayments"

Repository: Swetrix/swetrix

Length of output: 1821


🏁 Script executed:

# Let's test what TypeORM 0.3.28 actually returns for decimal columns
web_search
TypeORM 0.3 MySQL DECIMAL column type handling string vs number

Repository: Swetrix/swetrix

Length of output: 155


🌐 Web query:

TypeORM 0.3 MySQL DECIMAL column type handling string vs number hydration

💡 Result:

In TypeORM 0.3.x with MySQL, how a DECIMAL column is hydrated (string vs number) is primarily determined by the underlying MySQL driver (typically mysql2), not by TypeORM itself. TypeORM exposes driver options via DataSourceOptions.extra. [1]

Why you often get string

DECIMAL values are commonly returned as strings by mysql2 to avoid silent precision loss in JavaScript number (IEEE-754). This “DECIMAL-as-string” behavior is widely reported/expected in the mysql2 ecosystem. [2]

How to get number hydration (two common approaches)

A) Convert DECIMAL to JS numbers via mysql2 decimalNumbers

If you’re OK with potential rounding for large/high-precision decimals, enable mysql2’s decimalNumbers option (it makes DECIMAL/NEWDECIMAL come back as numbers). [3]

import { DataSource } from "typeorm";

export const ds = new DataSource({
  type: "mysql",
  // ...
  extra: {
    decimalNumbers: true,
  },
});

B) Keep precision-safe strings unless the value is “safe”, using supportBigNumbers / bigNumberStrings

mysql2 supports returning big numeric types as strings, with a mode where it returns numbers only when they’re safely representable as JS number (otherwise string). [3]

export const ds = new DataSource({
  type: "mysql",
  // ...
  extra: {
    supportBigNumbers: true,
    bigNumberStrings: false, // strings only when outside safe JS integer range
  },
});

If you need exact decimals in JS

Prefer hydrating as string and using a decimal library (or a TypeORM ValueTransformer) to map to something like decimal.js/big.js for calculations, then serialize as string/number as needed (TypeORM won’t do exact fixed-point math for you automatically). [2]

Sources: TypeORM extra option passthrough [1]; mysql2 DECIMAL-as-string behavior discussion [2]; mysql2 options (decimalNumbers, supportBigNumbers, bigNumberStrings) [3].

References

  1. (typeorm.io)
  2. (github.com)
  3. (jsdocs.io)

Citations:


amount declaration doesn't match TypeORM's runtime hydration.

With TypeORM 0.3.28 and MySQL, DECIMAL columns are hydrated as strings by the mysql2 driver (unless decimalNumbers: true is configured in the DataSource). The entity incorrectly declares amount: number, forcing every consumer to convert it—see the controller's Number(inv.amount) on line 824 of user.controller.ts and the service's _toNumber(payment.amount) on line 803 of user.service.ts.

Fix this at the source by adding a transformer to the column definition, or configure decimalNumbers: true in the DataSource and update the entity type.

Suggested fix (transformer approach)
-  `@Column`('decimal', { precision: 10, scale: 2 })
+  `@Column`('decimal', {
+    precision: 10,
+    scale: 2,
+    transformer: {
+      to: (value?: number) => value,
+      from: (value: string) => Number(value),
+    },
+  })
   amount: number
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@Column('decimal', { precision: 10, scale: 2 })
amount: number
`@Column`('decimal', {
precision: 10,
scale: 2,
transformer: {
to: (value?: number) => value,
from: (value: string) => Number(value),
},
})
amount: number
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/user/entities/billing-invoice.entity.ts` around lines
41 - 42, The entity's amount Column is declared as number but DECIMAL is
hydrated as string by mysql2; update the `@Column` on the amount property in
billing-invoice.entity.ts to include a transformer that converts database
strings to numbers on read (e.g. from: v => v === null ? null : parseFloat(v))
and preserves numbers on write (to: v => v), so consumers (user.controller.ts
Number(inv.amount) and user.service._toNumber(payment.amount)) no longer need
manual conversions; alternatively, set decimalNumbers: true in the DataSource
config and keep the entity type as number.

Comment on lines +813 to +820
let invoices = await this.userService.getInvoicesForUser(userId)

if (invoices.length === 0 && user.subID) {
invoices = await this.userService.syncSubscriptionPayments(
userId,
user.subID,
)
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't gate provider sync on an empty local table.

Line 815 only backfills from Paddle when the user has zero local invoices. That leaves permanent gaps whenever the table is only partially populated, even though backend/apps/cloud/src/user/user.service.ts:753-765 already de-duplicates by providerPaymentId. Sync for subscribed users should be based on a real sync cursor, or at least rerun here before reloading from the database.

Possible minimal fix
-    let invoices = await this.userService.getInvoicesForUser(userId)
-
-    if (invoices.length === 0 && user.subID) {
-      invoices = await this.userService.syncSubscriptionPayments(
-        userId,
-        user.subID,
-      )
-    }
+    let invoices = await this.userService.getInvoicesForUser(userId)
+
+    if (user.subID) {
+      await this.userService.syncSubscriptionPayments(userId, user.subID)
+      invoices = await this.userService.getInvoicesForUser(userId)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/user/user.controller.ts` around lines 813 - 820, The
current logic in user.controller.ts only calls
userService.syncSubscriptionPayments when getInvoicesForUser(userId) returns
zero invoices, which leaves gaps if the local table is partially populated;
change the flow so that for subscribed users (when user.subID is present) you
invoke syncSubscriptionPayments proactively (either unconditionally before
reloading from DB or gated by a proper sync cursor/lastSync timestamp) and then
re-query getInvoicesForUser to load the refreshed records; update the call sites
referencing getInvoicesForUser and syncSubscriptionPayments so de-duplication in
user.service.ts (the providerPaymentId dedupe) prevents duplicates while
ensuring a full sync occurs for subscribed users.

Comment on lines +753 to +765
async upsertInvoice(
data: Partial<BillingInvoice> & { providerPaymentId: string },
): Promise<BillingInvoice> {
const existing = await this.billingInvoiceRepository.findOne({
where: { providerPaymentId: data.providerPaymentId },
})

if (existing) {
await this.billingInvoiceRepository.update(existing.id, data)
return { ...existing, ...data } as BillingInvoice
}

return this.billingInvoiceRepository.save(data)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

find . -type f -name "user.service.ts" | grep -E "cloud|backend" | head -5

Repository: Swetrix/swetrix

Length of output: 154


🏁 Script executed:

wc -l backend/apps/cloud/src/user/user.service.ts 2>/dev/null || echo "File not found, searching..."
fd "user.service.ts" -t f

Repository: Swetrix/swetrix

Length of output: 198


🏁 Script executed:

# Check the upsertInvoice method around lines 753-765
sed -n '750,770p' backend/apps/cloud/src/user/user.service.ts

Repository: Swetrix/swetrix

Length of output: 709


🏁 Script executed:

# Check for the migration file and unique constraint
cat backend/migrations/mysql/2026_03_07_billing_invoices.sql 2>/dev/null || find . -name "*billing*" -type f | grep migration

Repository: Swetrix/swetrix

Length of output: 986


🏁 Script executed:

# Check TypeORM version in package.json
grep -A 2 -B 2 "typeorm" package.json backend/package.json 2>/dev/null | head -20

Repository: Swetrix/swetrix

Length of output: 571


🏁 Script executed:

# Check Repository injection and imports in user.service.ts
sed -n '1,130p' backend/apps/cloud/src/user/user.service.ts | grep -E "import|Repository|BillingInvoice|InjectRepository"

Repository: Swetrix/swetrix

Length of output: 1723


🌐 Web query:

TypeORM 0.3.28 Repository upsert method documentation

💡 Result:

In TypeORM v0.3.28, Repository#upsert is documented under Repository APIs → upsert. (typeorm.io)

Method signature (v0.3.28)

repository.upsert(
  entityOrEntities: QueryDeepPartialEntity<Entity> | QueryDeepPartialEntity<Entity>[],
  conflictPathsOrOptions: string[] | UpsertOptions,
): Promise<InsertResult>

(raw.githubusercontent.com)

What it does

  • Performs an INSERT … ON CONFLICT DO UPDATE / ON DUPLICATE KEY UPDATE style operation (driver-dependent).
  • Documented as supported by: AuroraDataApi, CockroachDB, MySQL, Postgres, SQLite. (typeorm.io)

How to call it

Using conflict column(s):

await repo.upsert(
  [{ externalId: "abc123", firstName: "Rizzrak" }],
  ["externalId"],
)

(typeorm.io)

Using options object:

Citations:


🏁 Script executed:

# Check if upsert is used anywhere else in the codebase
rg "\.upsert\(" backend/apps/cloud/src --type ts -A 3

Repository: Swetrix/swetrix

Length of output: 41


🏁 Script executed:

# Check TypeORM version and look for any upsert-related code
rg "upsert" backend/apps/cloud/src/user/user.service.ts

Repository: Swetrix/swetrix

Length of output: 132


Make the invoice upsert atomic to prevent duplicate-key failures on concurrent syncs.

The findOne() + update()/save() flow creates a race condition. With the unique constraint on providerPaymentId, two concurrent syncs can both miss the read phase, causing the second insert to fail with a duplicate-key error. Use TypeORM's atomic Repository.upsert() instead:

  async upsertInvoice(
    data: Partial<BillingInvoice> & { providerPaymentId: string },
  ): Promise<BillingInvoice> {
-    const existing = await this.billingInvoiceRepository.findOne({
-      where: { providerPaymentId: data.providerPaymentId },
-    })
-
-    if (existing) {
-      await this.billingInvoiceRepository.update(existing.id, data)
-      return { ...existing, ...data } as BillingInvoice
-    }
-
-    return this.billingInvoiceRepository.save(data)
+    await this.billingInvoiceRepository.upsert(data, ['providerPaymentId'])
+
+    return this.billingInvoiceRepository.findOneOrFail({
+      where: { providerPaymentId: data.providerPaymentId },
+    })
  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async upsertInvoice(
data: Partial<BillingInvoice> & { providerPaymentId: string },
): Promise<BillingInvoice> {
const existing = await this.billingInvoiceRepository.findOne({
where: { providerPaymentId: data.providerPaymentId },
})
if (existing) {
await this.billingInvoiceRepository.update(existing.id, data)
return { ...existing, ...data } as BillingInvoice
}
return this.billingInvoiceRepository.save(data)
async upsertInvoice(
data: Partial<BillingInvoice> & { providerPaymentId: string },
): Promise<BillingInvoice> {
await this.billingInvoiceRepository.upsert(data, ['providerPaymentId'])
return this.billingInvoiceRepository.findOneOrFail({
where: { providerPaymentId: data.providerPaymentId },
})
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/user/user.service.ts` around lines 753 - 765, The
current upsertInvoice implementation uses a read-then-write (findOne +
update/save) causing a race that can trigger duplicate-key errors on
providerPaymentId; replace that flow with a single atomic call using
billingInvoiceRepository.upsert() (or Repository.upsert) keyed on
providerPaymentId so concurrent syncs become safe: remove the
findOne/update/save branch and call upsert with the incoming data and
conflictTargets set to ['providerPaymentId'], then return the resulting
BillingInvoice as before.

Comment on lines +796 to +809
const user = await this.findOne({ where: { id: userId } })

const invoice = await this.upsertInvoice({
userId,
provider: 'paddle',
providerPaymentId: String(payment.id),
providerSubscriptionId: subID,
amount: _toNumber(payment.amount),
currency: payment.currency,
status: InvoiceStatus.PAID,
planCode: user?.planCode || null,
billingFrequency: user?.billingFrequency || null,
receiptUrl: payment.receipt_url || null,
billedAt: new Date(payment.payout_date),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't stamp today's plan onto historical invoices.

Lines 806-807 copy the current User.planCode and User.billingFrequency into every backfilled row. After an upgrade, downgrade, or cancellation, older invoices will show the wrong plan metadata. For billing history, either derive these fields from the payment itself or leave them null when the provider response cannot identify the original plan.

Safer fallback
-        const user = await this.findOne({ where: { id: userId } })
-
         const invoice = await this.upsertInvoice({
           userId,
           provider: 'paddle',
           providerPaymentId: String(payment.id),
           providerSubscriptionId: subID,
           amount: _toNumber(payment.amount),
           currency: payment.currency,
           status: InvoiceStatus.PAID,
-          planCode: user?.planCode || null,
-          billingFrequency: user?.billingFrequency || null,
+          planCode: null,
+          billingFrequency: null,
           receiptUrl: payment.receipt_url || null,
           billedAt: new Date(payment.payout_date),
         })
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const user = await this.findOne({ where: { id: userId } })
const invoice = await this.upsertInvoice({
userId,
provider: 'paddle',
providerPaymentId: String(payment.id),
providerSubscriptionId: subID,
amount: _toNumber(payment.amount),
currency: payment.currency,
status: InvoiceStatus.PAID,
planCode: user?.planCode || null,
billingFrequency: user?.billingFrequency || null,
receiptUrl: payment.receipt_url || null,
billedAt: new Date(payment.payout_date),
const invoice = await this.upsertInvoice({
userId,
provider: 'paddle',
providerPaymentId: String(payment.id),
providerSubscriptionId: subID,
amount: _toNumber(payment.amount),
currency: payment.currency,
status: InvoiceStatus.PAID,
planCode: null,
billingFrequency: null,
receiptUrl: payment.receipt_url || null,
billedAt: new Date(payment.payout_date),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/user/user.service.ts` around lines 796 - 809, The code
currently copies the live User.planCode and User.billingFrequency into
historical invoices when calling upsertInvoice (see findOne result assigned to
user and the upsertInvoice call), which incorrectly stamps today's plan onto
past payments; change the payload to derive planCode and billingFrequency from
the payment/provider response (e.g., payment.plan_code, payment.plan_id,
payment.metadata, or providerSubscriptionId) when available, and otherwise pass
null for planCode and billingFrequency instead of using
user.planCode/user.billingFrequency so backfilled invoices reflect the original
billing info or remain unknown.

Comment on lines +278 to +292
if (orderId) {
try {
await this.userService.upsertInvoice({
userId: subscriber.id,
provider: 'paddle',
providerPaymentId: String(orderId),
providerSubscriptionId: subID,
amount: parseFloat(saleGross) || 0,
currency: currency || subscriber.tierCurrency || 'USD',
status: 'paid' as any,
planCode: subscriber.planCode || null,
billingFrequency: subscriber.billingFrequency || null,
receiptUrl: receiptUrl || null,
billedAt: eventTime ? new Date(eventTime) : new Date(),
})
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't persist a $0.00 invoice when sale_gross is invalid.

parseFloat(saleGross) || 0 silently turns bad webhook payloads into zero-amount invoices, which corrupts billing history. This is also a good place to stop bypassing the enum with as any.

Suggested fix
         if (orderId) {
           try {
+            const amount = Number(saleGross)
+            if (!Number.isFinite(amount)) {
+              this.logger.error(
+                `[subscription_payment_succeeded] Invalid sale_gross for order ${orderId}: ${saleGross}`,
+              )
+              return
+            }
+
             await this.userService.upsertInvoice({
               userId: subscriber.id,
               provider: 'paddle',
               providerPaymentId: String(orderId),
               providerSubscriptionId: subID,
-              amount: parseFloat(saleGross) || 0,
+              amount,
               currency: currency || subscriber.tierCurrency || 'USD',
-              status: 'paid' as any,
+              status: InvoiceStatus.PAID,
               planCode: subscriber.planCode || null,
               billingFrequency: subscriber.billingFrequency || null,
               receiptUrl: receiptUrl || null,
               billedAt: eventTime ? new Date(eventTime) : new Date(),
             })

Also add the InvoiceStatus import at the top of the file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/apps/cloud/src/webhook/webhook.controller.ts` around lines 278 - 292,
Validate saleGross before creating an invoice: instead of parseFloat(saleGross)
|| 0, parse saleGross with Number or parseFloat and if it is NaN or <= 0 do not
call this.userService.upsertInvoice (return or log and skip persisting a $0.00
invoice); replace the hardcoded 'paid' as any with the proper InvoiceStatus enum
(e.g. InvoiceStatus.PAID) and add an import for InvoiceStatus at the top of the
file; keep the rest of the upsertInvoice payload the same and ensure
providerPaymentId uses String(orderId) as before.

Comment on lines +1554 to +1555
<div className='overflow-hidden rounded-lg border border-gray-200 dark:border-slate-800'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-slate-800'>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make the invoice table horizontally scrollable.

This table has five non-wrapping columns, but the wrapper is overflow-hidden. On smaller screens the right-hand columns will be clipped, including the receipt action.

Suggested fix
-                        <div className='overflow-hidden rounded-lg border border-gray-200 dark:border-slate-800'>
+                        <div className='overflow-x-auto rounded-lg border border-gray-200 dark:border-slate-800'>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className='overflow-hidden rounded-lg border border-gray-200 dark:border-slate-800'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-slate-800'>
<div className='overflow-x-auto rounded-lg border border-gray-200 dark:border-slate-800'>
<table className='min-w-full divide-y divide-gray-200 dark:divide-slate-800'>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/UserSettings/UserSettings.tsx` around lines 1554 - 1555, The
invoice table wrapper currently uses the div with class 'overflow-hidden
rounded-lg border ...' which clips the table on small screens; change the
wrapper to allow horizontal scrolling (e.g., replace or augment
'overflow-hidden' with 'overflow-x-auto' or wrap the <table class="min-w-full
divide-y divide-gray-200 dark:divide-slate-800"> in a new div that has
'overflow-x-auto' so the five non-wrapping columns (including the receipt
action) can scroll horizontally on small screens while preserving the rounded
border and other styles.

Comment on lines +1591 to +1611
const currencySymbol =
invoice.currency === 'EUR'
? '€'
: invoice.currency === 'GBP'
? '£'
: '$'

return (
<tr key={invoice.id}>
<td className='px-4 py-3 text-sm whitespace-nowrap text-gray-900 dark:text-gray-100'>
{language === 'en'
? dayjs(invoice.billedAt)
.locale(language)
.format('MMM D, YYYY')
: dayjs(invoice.billedAt)
.locale(language)
.format('D MMM YYYY')}
</td>
<td className='px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-gray-100'>
{currencySymbol}
{Number(invoice.amount).toFixed(2)}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't label every non-EUR/GBP invoice as USD.

The fallback branch renders $ for any other currency, so CAD/AUD/SEK/etc. invoices will display the wrong symbol in billing history.

Suggested fix
-                                const currencySymbol =
-                                  invoice.currency === 'EUR'
-                                    ? '€'
-                                    : invoice.currency === 'GBP'
-                                      ? '£'
-                                      : '$'
+                                const amount = Number(invoice.amount).toFixed(2)
+                                const formattedAmount =
+                                  invoice.currency === 'EUR'
+                                    ? `€${amount}`
+                                    : invoice.currency === 'GBP'
+                                      ? `£${amount}`
+                                      : invoice.currency === 'USD'
+                                        ? `$${amount}`
+                                        : `${invoice.currency} ${amount}`
@@
-                                      {currencySymbol}
-                                      {Number(invoice.amount).toFixed(2)}
+                                      {formattedAmount}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const currencySymbol =
invoice.currency === 'EUR'
? '€'
: invoice.currency === 'GBP'
? '£'
: '$'
return (
<tr key={invoice.id}>
<td className='px-4 py-3 text-sm whitespace-nowrap text-gray-900 dark:text-gray-100'>
{language === 'en'
? dayjs(invoice.billedAt)
.locale(language)
.format('MMM D, YYYY')
: dayjs(invoice.billedAt)
.locale(language)
.format('D MMM YYYY')}
</td>
<td className='px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-gray-100'>
{currencySymbol}
{Number(invoice.amount).toFixed(2)}
const amount = Number(invoice.amount).toFixed(2)
const formattedAmount =
invoice.currency === 'EUR'
? `€${amount}`
: invoice.currency === 'GBP'
? ${amount}`
: invoice.currency === 'USD'
? `$${amount}`
: `${invoice.currency} ${amount}`
return (
<tr key={invoice.id}>
<td className='px-4 py-3 text-sm whitespace-nowrap text-gray-900 dark:text-gray-100'>
{language === 'en'
? dayjs(invoice.billedAt)
.locale(language)
.format('MMM D, YYYY')
: dayjs(invoice.billedAt)
.locale(language)
.format('D MMM YYYY')}
</td>
<td className='px-4 py-3 text-sm font-medium whitespace-nowrap text-gray-900 dark:text-gray-100'>
{formattedAmount}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/app/pages/UserSettings/UserSettings.tsx` around lines 1591 - 1611, The
code in UserSettings.tsx assigns a '$' fallback in the currencySymbol
computation which mislabels non-EUR/GBP currencies; update the rendering to use
a proper currency-aware formatter instead of hardcoded symbols—use
Intl.NumberFormat with invoice.currency (e.g., new Intl.NumberFormat(locale, {
style: 'currency', currency: invoice.currency })).format(Number(invoice.amount))
or build a currencySymbol map (referencing currencySymbol and invoice.currency
in the file) so invoices show the correct symbol/format for CAD/AUD/SEK/etc.

Comment on lines +1999 to +2010
"invoiceHistory": "Invoice history",
"invoiceHistoryDesc": "View and download invoices for all your past subscription payments.",
"invoiceDate": "Date",
"invoiceAmount": "Amount",
"invoiceStatus": "Status",
"invoicePlan": "Plan",
"invoiceDownload": "Receipt",
"noInvoices": "No invoices yet. Invoices will appear here once a payment is made.",
"status": {
"paid": "Paid",
"refunded": "Refunded",
"pending": "Pending"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
python - <<'PY'
import json
from pathlib import Path

keys = [
    ("billing", "invoiceHistory"),
    ("billing", "invoiceHistoryDesc"),
    ("billing", "invoiceDate"),
    ("billing", "invoiceAmount"),
    ("billing", "invoiceStatus"),
    ("billing", "invoicePlan"),
    ("billing", "invoiceDownload"),
    ("billing", "noInvoices"),
    ("billing", "status", "paid"),
    ("billing", "status", "refunded"),
    ("billing", "status", "pending"),
]

for path in sorted(Path("web/public/locales").rglob("*.json")):
    data = json.loads(path.read_text())
    missing = []
    for key in keys:
        cur = data
        for part in key:
            if isinstance(cur, dict) and part in cur:
                cur = cur[part]
            else:
                missing.append(".".join(key))
                break
    if missing:
        print(path.as_posix())
        for item in missing:
            print(f"  - {item}")
PY

Repository: Swetrix/swetrix

Length of output: 1318


Add invoice strings to all locale bundles (de.json, fr.json, pl.json, uk.json).

The invoice feature is missing translations in 4 language bundles. All 11 keys—including billing.invoiceHistory, billing.invoiceHistoryDesc, billing.invoiceDate, billing.invoiceAmount, billing.invoiceStatus, billing.invoicePlan, billing.invoiceDownload, billing.noInvoices, and the nested billing.status.* keys—are absent from the German, French, Polish, and Ukrainian locale files. Non-English users will see untranslated text or missing key fallbacks in the invoice section.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@web/public/locales/en.json` around lines 1999 - 2010, Add the missing invoice
translation keys to each non-English locale (de.json, fr.json, pl.json, uk.json)
under the same "billing" object used in en.json: include billing.invoiceHistory,
billing.invoiceHistoryDesc, billing.invoiceDate, billing.invoiceAmount,
billing.invoiceStatus, billing.invoicePlan, billing.invoiceDownload,
billing.noInvoices and the nested billing.status.paid, billing.status.refunded,
billing.status.pending keys; preserve the JSON structure and key names exactly
as in web/public/locales/en.json and provide proper translated strings for each
key in the respective language files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant