-
-
Notifications
You must be signed in to change notification settings - Fork 52
Self served invoices / billing history #481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| @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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import { | ||
| Controller, | ||
| Req, | ||
| Res, | ||
| Body, | ||
| Param, | ||
| Get, | ||
|
|
@@ -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' | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 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 |
||
|
|
||
| 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() | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) {} | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 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:
💡 Result: In TypeORM v0.3.28, Method signature (v0.3.28)repository.upsert(
entityOrEntities: QueryDeepPartialEntity<Entity> | QueryDeepPartialEntity<Entity>[],
conflictPathsOrOptions: string[] | UpsertOptions,
): Promise<InsertResult>What it does
How to call itUsing conflict column(s): await repo.upsert(
[{ externalId: "abc123", firstName: "Rizzrak" }],
["externalId"],
)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 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 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't stamp today's plan onto historical invoices. Lines 806-807 copy the current 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
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| invoices.push(invoice) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return invoices | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| '[syncSubscriptionPayments] Failed:', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| error?.response?.data || error?.message, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 }, | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't persist a
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 🤖 Prompt for AI Agents |
||
| } catch (reason) { | ||
| this.logger.error( | ||
| '[subscription_payment_succeeded] Failed to persist invoice:', | ||
| reason, | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| break | ||
| } | ||
|
|
||
|
|
||
| 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; |
| 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 | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 1737
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 102
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 143
🏁 Script executed:
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:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 292
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 1017
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 418
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 121
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 552
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 105
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 4207
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 106
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 796
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 1765
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 308
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 686
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 41
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 281
🏁 Script executed:
Repository: Swetrix/swetrix
Length of output: 1821
🏁 Script executed:
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
DECIMALcolumn is hydrated (stringvsnumber) is primarily determined by the underlying MySQL driver (typically mysql2), not by TypeORM itself. TypeORM exposes driver options viaDataSourceOptions.extra. [1]Why you often get
stringDECIMALvalues are commonly returned as strings by mysql2 to avoid silent precision loss in JavaScriptnumber(IEEE-754). This “DECIMAL-as-string” behavior is widely reported/expected in the mysql2 ecosystem. [2]How to get
numberhydration (two common approaches)A) Convert DECIMAL to JS numbers via mysql2
decimalNumbersIf you’re OK with potential rounding for large/high-precision decimals, enable mysql2’s
decimalNumbersoption (it makesDECIMAL/NEWDECIMALcome back as numbers). [3]B) Keep precision-safe strings unless the value is “safe”, using
supportBigNumbers/bigNumberStringsmysql2 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]If you need exact decimals in JS
Prefer hydrating as string and using a decimal library (or a TypeORM
ValueTransformer) to map to something likedecimal.js/big.jsfor calculations, then serialize as string/number as needed (TypeORM won’t do exact fixed-point math for you automatically). [2]Sources: TypeORM
extraoption passthrough [1]; mysql2 DECIMAL-as-string behavior discussion [2]; mysql2 options (decimalNumbers,supportBigNumbers,bigNumberStrings) [3].References
Citations:
amountdeclaration doesn't match TypeORM's runtime hydration.With TypeORM 0.3.28 and MySQL,
DECIMALcolumns are hydrated as strings by the mysql2 driver (unlessdecimalNumbers: trueis configured in the DataSource). The entity incorrectly declaresamount: number, forcing every consumer to convert it—see the controller'sNumber(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: truein the DataSource and update the entity type.Suggested fix (transformer approach)
📝 Committable suggestion
🤖 Prompt for AI Agents