Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions backend/apps/cloud/src/user/entities/billing-invoice.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
JoinColumn,
} from 'typeorm'
import { User } from './user.entity'

export enum InvoiceStatus {
PAID = 'paid',
REFUNDED = 'refunded',
PENDING = 'pending',
}

@Entity('billing_invoice')
export class BillingInvoice {
@PrimaryGeneratedColumn('uuid')
id: string

@Index()
@Column('varchar', { length: 36 })
userId: string

@ManyToOne(() => User, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'userId' })
user: User

@Column('varchar', { length: 20, default: 'paddle' })
provider: string

@Index({ unique: true })
@Column('varchar', { length: 100 })
providerPaymentId: string

@Column('varchar', { length: 20, nullable: true })
providerSubscriptionId: string | null

@Column('decimal', { precision: 10, scale: 2 })
amount: number
Comment on lines +41 to +42
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.


@Column('varchar', { length: 3 })
currency: string

@Column({
type: 'enum',
enum: InvoiceStatus,
default: InvoiceStatus.PAID,
})
status: InvoiceStatus

@Column('varchar', { length: 50, nullable: true })
planCode: string | null

@Column('varchar', { length: 10, nullable: true })
billingFrequency: string | null

@Column('varchar', { length: 500, nullable: true })
receiptUrl: string | null

@Column({ type: 'timestamp' })
billedAt: Date

@CreateDateColumn()
createdAt: Date
}
4 changes: 4 additions & 0 deletions backend/apps/cloud/src/user/entities/user.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { ActionToken } from '../../action-tokens/action-token.entity'
import { Project } from '../../project/entity/project.entity'
import { ProjectShare } from '../../project/entity/project-share.entity'
import { RefreshToken } from './refresh-token.entity'
import { BillingInvoice } from './billing-invoice.entity'
import { OrganisationMember } from '../../organisation/entity/organisation-member.entity'

export enum PlanCode {
Expand Down Expand Up @@ -439,4 +440,7 @@ export class User {

@OneToMany(() => OrganisationMember, (membership) => membership.user)
organisationMemberships: OrganisationMember[]

@OneToMany(() => BillingInvoice, (invoice) => invoice.user)
billingInvoices: BillingInvoice[]
}
60 changes: 59 additions & 1 deletion backend/apps/cloud/src/user/user.controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Controller,
Req,
Res,
Body,
Param,
Get,
Expand All @@ -15,7 +16,7 @@ import {
Ip,
NotFoundException,
} from '@nestjs/common'
import { Request } from 'express'
import { Request, Response } from 'express'
import { ApiTags, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
Expand Down Expand Up @@ -798,6 +799,63 @@ export class UserController {
})
}

@ApiBearerAuth()
@Get('billing/invoices')
async getInvoices(@CurrentUserId() userId: string) {
this.logger.log({ userId }, 'GET /user/billing/invoices')

const user = await this.userService.findOne({ where: { id: userId } })

if (!user) {
throw new BadRequestException('User not found')
}

let invoices = await this.userService.getInvoicesForUser(userId)

if (invoices.length === 0 && user.subID) {
invoices = await this.userService.syncSubscriptionPayments(
userId,
user.subID,
)
}
Comment on lines +813 to +820
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.


return invoices.map((inv) => ({
id: inv.id,
amount: Number(inv.amount),
currency: inv.currency,
status: inv.status,
planCode: inv.planCode,
billingFrequency: inv.billingFrequency,
receiptUrl: inv.receiptUrl,
billedAt: inv.billedAt,
}))
}

@ApiBearerAuth()
@Get('billing/invoices/:id/download')
async downloadInvoice(
@CurrentUserId() userId: string,
@Param('id') invoiceId: string,
@Res() res: Response,
) {
this.logger.log(
{ userId, invoiceId },
'GET /user/billing/invoices/:id/download',
)

const invoice = await this.userService.getInvoiceById(invoiceId, userId)

if (!invoice) {
throw new NotFoundException('Invoice not found')
}

if (!invoice.receiptUrl) {
throw new BadRequestException('No receipt available for this invoice')
}

return res.redirect(invoice.receiptUrl)
}

// Used to unsubscribe from email reports
@Get('/unsubscribe/:token')
@Public()
Expand Down
2 changes: 2 additions & 0 deletions backend/apps/cloud/src/user/user.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ProjectModule } from '../project/project.module'
import { RefreshToken } from './entities/refresh-token.entity'
import { DeleteFeedback } from './entities/delete-feedback.entity'
import { CancellationFeedback } from './entities/cancellation-feedback.entity'
import { BillingInvoice } from './entities/billing-invoice.entity'
import { Message } from '../integrations/telegram/entities/message.entity'
import { OrganisationModule } from '../organisation/organisation.module'

Expand All @@ -24,6 +25,7 @@ import { OrganisationModule } from '../organisation/organisation.module'
RefreshToken,
DeleteFeedback,
CancellationFeedback,
BillingInvoice,
Message,
]),
ActionTokensModule,
Expand Down
98 changes: 98 additions & 0 deletions backend/apps/cloud/src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ import { UserProfileDTO } from './dto/user.dto'
import { RefreshToken } from './entities/refresh-token.entity'
import { DeleteFeedback } from './entities/delete-feedback.entity'
import { CancellationFeedback } from './entities/cancellation-feedback.entity'
import {
BillingInvoice,
InvoiceStatus,
} from './entities/billing-invoice.entity'
import { UserGoogleDTO } from './dto/user-google.dto'
import { UserGithubDTO } from './dto/user-github.dto'
import { EMAIL_ACTION_ENCRYPTION_KEY } from '../common/constants'
Expand Down Expand Up @@ -111,6 +115,8 @@ export class UserService {
private readonly deleteFeedbackRepository: Repository<DeleteFeedback>,
@InjectRepository(CancellationFeedback)
private readonly cancellationFeedbackRepository: Repository<CancellationFeedback>,
@InjectRepository(BillingInvoice)
private readonly billingInvoiceRepository: Repository<BillingInvoice>,
private readonly organisationService: OrganisationService,
) {}

Expand Down Expand Up @@ -723,4 +729,96 @@ export class UserService {
})
.getMany()
}

async getInvoicesForUser(userId: string): Promise<BillingInvoice[]> {
return this.billingInvoiceRepository.find({
where: { userId },
order: { billedAt: 'DESC' },
})
}

async getInvoiceById(
id: string,
userId: string,
): Promise<BillingInvoice | null> {
return this.billingInvoiceRepository.findOne({
where: { id, userId },
})
}

async createInvoice(data: Partial<BillingInvoice>): Promise<BillingInvoice> {
return this.billingInvoiceRepository.save(data)
}

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)
Comment on lines +753 to +765
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.

}

async syncSubscriptionPayments(userId: string, subID: string) {
if (!PADDLE_VENDOR_ID || !PADDLE_API_KEY) {
return []
}

const url = 'https://vendors.paddle.com/api/2.0/subscription/payments'

try {
const result = await axios.post(url, {
vendor_id: Number(PADDLE_VENDOR_ID),
vendor_auth_code: PADDLE_API_KEY,
subscription_id: Number(subID),
})

if (!result.data?.success) {
console.error(
'[syncSubscriptionPayments] Paddle API returned success=false:',
result.data,
)
return []
}

const payments = result.data.response || []
const invoices: BillingInvoice[] = []

for (const payment of payments) {
if (!payment.is_paid) continue

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),
Comment on lines +796 to +809
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.

})

invoices.push(invoice)
}

return invoices
} catch (error) {
console.error(
'[syncSubscriptionPayments] Failed:',
error?.response?.data || error?.message,
)
return []
}
}
}
34 changes: 33 additions & 1 deletion backend/apps/cloud/src/webhook/webhook.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,16 @@ export class WebhookController {
}

case 'subscription_payment_succeeded': {
const { subscription_id: subID, next_bill_date: nextBillDate } = body
const {
subscription_id: subID,
next_bill_date: nextBillDate,
order_id: orderId,
sale_gross: saleGross,
currency,
receipt_url: receiptUrl,
subscription_plan_id: planId,
event_time: eventTime,
} = body

const subscriber = await this.userService.findOne({
where: { subID },
Expand Down Expand Up @@ -266,6 +275,29 @@ export class WebhookController {
await this.projectService.clearProjectsRedisCacheBySubId(subID)
}

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(),
})
Comment on lines +278 to +292
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.

} catch (reason) {
this.logger.error(
'[subscription_payment_succeeded] Failed to persist invoice:',
reason,
)
}
}

break
}

Expand Down
19 changes: 19 additions & 0 deletions backend/migrations/mysql/2026_03_07_billing_invoices.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
CREATE TABLE `billing_invoice` (
`id` varchar(36) NOT NULL,
`userId` varchar(36) NOT NULL,
`provider` varchar(20) NOT NULL DEFAULT 'paddle',
`providerPaymentId` varchar(100) NOT NULL,
`providerSubscriptionId` varchar(20) DEFAULT NULL,
`amount` decimal(10,2) NOT NULL,
`currency` varchar(3) NOT NULL,
`status` enum('paid','refunded','pending') NOT NULL DEFAULT 'paid',
`planCode` varchar(50) DEFAULT NULL,
`billingFrequency` varchar(10) DEFAULT NULL,
`receiptUrl` varchar(500) DEFAULT NULL,
`billedAt` timestamp NOT NULL,
`createdAt` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (`id`),
UNIQUE KEY `IDX_billing_invoice_providerPaymentId` (`providerPaymentId`),
KEY `IDX_billing_invoice_userId` (`userId`),
CONSTRAINT `FK_billing_invoice_user` FOREIGN KEY (`userId`) REFERENCES `user` (`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
10 changes: 10 additions & 0 deletions web/app/lib/models/BillingInvoice.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export interface BillingInvoice {
id: string
amount: number
currency: string
status: 'paid' | 'refunded' | 'pending'
planCode: string | null
billingFrequency: string | null
receiptUrl: string | null
billedAt: string
}
Loading