Conversation
📝 WalkthroughWalkthroughThis 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
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
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
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches
🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
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 | 🟠 MajorUnsuspend the account when the payment recovery succeeds.
This path clears
dashboardBlockReason, but it leavesisAccountBillingSuspendedunchanged. Users who were locked forpayment_failedcan 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
📒 Files selected for processing (11)
backend/apps/cloud/src/user/entities/billing-invoice.entity.tsbackend/apps/cloud/src/user/entities/user.entity.tsbackend/apps/cloud/src/user/user.controller.tsbackend/apps/cloud/src/user/user.module.tsbackend/apps/cloud/src/user/user.service.tsbackend/apps/cloud/src/webhook/webhook.controller.tsbackend/migrations/mysql/2026_03_07_billing_invoices.sqlweb/app/lib/models/BillingInvoice.tsweb/app/pages/UserSettings/UserSettings.tsxweb/app/routes/user-settings.tsxweb/public/locales/en.json
| @Column('decimal', { precision: 10, scale: 2 }) | ||
| amount: number |
There was a problem hiding this comment.
🧩 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 -60Repository: 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 -5Repository: 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.tsRepository: 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 -40Repository: 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 -10Repository: 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 1Repository: 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 2Repository: 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 -10Repository: 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 fRepository: 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 -15Repository: 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 tsRepository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
# Check the user controller
cat -n backend/apps/cloud/src/user/user.controller.ts | head -100Repository: 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 10Repository: 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 -10Repository: 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 -10Repository: 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 -5Repository: 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 -5Repository: 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 -20Repository: 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 -20Repository: 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 -50Repository: 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 -5Repository: 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 -30Repository: 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 3Repository: 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 15Repository: 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 1Repository: 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 numberRepository: 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
- (typeorm.io)
- (github.com)
- (jsdocs.io)
Citations:
- 1: https://typeorm.io/docs/data-source/data-source-options
- 2: DECIMAL values are now Strings with mysql2 sequelize/sequelize#7465
- 3: https://www.jsdocs.io/package/mysql2?utm_source=openai
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.
| @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.
| let invoices = await this.userService.getInvoicesForUser(userId) | ||
|
|
||
| if (invoices.length === 0 && user.subID) { | ||
| invoices = await this.userService.syncSubscriptionPayments( | ||
| userId, | ||
| user.subID, | ||
| ) | ||
| } |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
find . -type f -name "user.service.ts" | grep -E "cloud|backend" | head -5Repository: 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 fRepository: 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.tsRepository: 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 migrationRepository: 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 -20Repository: 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>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"],
)Using options object:
conflictPaths: required (string[] or{ [K in keyof Entity]?: true }) (raw.githubusercontent.com)skipUpdateIfNoValuesChanged?: (Postgres) skip update if it wouldn’t change values (raw.githubusercontent.com)indexPredicate?: (Postgres) partial index predicate for the conflict target (raw.githubusercontent.com)upsertType?: select upsert strategy (notably mentioned for CockroachDB) (raw.githubusercontent.com)
Citations:
- 1: https://typeorm.io/docs/working-with-entity-manager/repository-api
- 2: https://raw.githubusercontent.com/typeorm/typeorm/0.3.28/src/repository/Repository.ts
- 3: https://typeorm.io/docs/working-with-entity-manager/repository-api
- 4: https://typeorm.io/docs/working-with-entity-manager/repository-api
- 5: https://raw.githubusercontent.com/typeorm/typeorm/0.3.28/src/repository/UpsertOptions.ts
- 6: https://raw.githubusercontent.com/typeorm/typeorm/0.3.28/src/repository/UpsertOptions.ts
- 7: https://raw.githubusercontent.com/typeorm/typeorm/0.3.28/src/query-builder/InsertOrUpdateOptions.ts
- 8: https://raw.githubusercontent.com/typeorm/typeorm/0.3.28/src/repository/UpsertOptions.ts
🏁 Script executed:
# Check if upsert is used anywhere else in the codebase
rg "\.upsert\(" backend/apps/cloud/src --type ts -A 3Repository: 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.tsRepository: 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.
| 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.
| 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), |
There was a problem hiding this comment.
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.
| 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.
| 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(), | ||
| }) |
There was a problem hiding this comment.
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.
| <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'> |
There was a problem hiding this comment.
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.
| <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.
| 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)} |
There was a problem hiding this comment.
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.
| 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.
| "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" |
There was a problem hiding this comment.
🧩 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}")
PYRepository: 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.
Changes
If applicable, please describe what changes were made in this pull request.
Community Edition support
Database migrations
Documentation
Summary by CodeRabbit
Release Notes