From 55a6ba83041aa088e7ca0410772e7739a594e691 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Sat, 7 Mar 2026 00:49:25 +0000 Subject: [PATCH] Self served invoices / billing history --- .../user/entities/billing-invoice.entity.ts | 68 +++++++++ .../cloud/src/user/entities/user.entity.ts | 4 + .../apps/cloud/src/user/user.controller.ts | 60 +++++++- backend/apps/cloud/src/user/user.module.ts | 2 + backend/apps/cloud/src/user/user.service.ts | 98 +++++++++++++ .../cloud/src/webhook/webhook.controller.ts | 34 ++++- .../mysql/2026_03_07_billing_invoices.sql | 19 +++ web/app/lib/models/BillingInvoice.ts | 10 ++ web/app/pages/UserSettings/UserSettings.tsx | 138 ++++++++++++++++++ web/app/routes/user-settings.tsx | 13 +- web/public/locales/en.json | 15 +- 11 files changed, 456 insertions(+), 5 deletions(-) create mode 100644 backend/apps/cloud/src/user/entities/billing-invoice.entity.ts create mode 100644 backend/migrations/mysql/2026_03_07_billing_invoices.sql create mode 100644 web/app/lib/models/BillingInvoice.ts diff --git a/backend/apps/cloud/src/user/entities/billing-invoice.entity.ts b/backend/apps/cloud/src/user/entities/billing-invoice.entity.ts new file mode 100644 index 000000000..bad68b0d0 --- /dev/null +++ b/backend/apps/cloud/src/user/entities/billing-invoice.entity.ts @@ -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 +} diff --git a/backend/apps/cloud/src/user/entities/user.entity.ts b/backend/apps/cloud/src/user/entities/user.entity.ts index 22d7d0185..bb784133d 100644 --- a/backend/apps/cloud/src/user/entities/user.entity.ts +++ b/backend/apps/cloud/src/user/entities/user.entity.ts @@ -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 { @@ -439,4 +440,7 @@ export class User { @OneToMany(() => OrganisationMember, (membership) => membership.user) organisationMemberships: OrganisationMember[] + + @OneToMany(() => BillingInvoice, (invoice) => invoice.user) + billingInvoices: BillingInvoice[] } diff --git a/backend/apps/cloud/src/user/user.controller.ts b/backend/apps/cloud/src/user/user.controller.ts index e37e9463d..dec47d550 100644 --- a/backend/apps/cloud/src/user/user.controller.ts +++ b/backend/apps/cloud/src/user/user.controller.ts @@ -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, + ) + } + + 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() diff --git a/backend/apps/cloud/src/user/user.module.ts b/backend/apps/cloud/src/user/user.module.ts index 06363ee0b..ffe900483 100644 --- a/backend/apps/cloud/src/user/user.module.ts +++ b/backend/apps/cloud/src/user/user.module.ts @@ -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' @@ -24,6 +25,7 @@ import { OrganisationModule } from '../organisation/organisation.module' RefreshToken, DeleteFeedback, CancellationFeedback, + BillingInvoice, Message, ]), ActionTokensModule, diff --git a/backend/apps/cloud/src/user/user.service.ts b/backend/apps/cloud/src/user/user.service.ts index 8cab7f517..bf6b4c216 100644 --- a/backend/apps/cloud/src/user/user.service.ts +++ b/backend/apps/cloud/src/user/user.service.ts @@ -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, @InjectRepository(CancellationFeedback) private readonly cancellationFeedbackRepository: Repository, + @InjectRepository(BillingInvoice) + private readonly billingInvoiceRepository: Repository, private readonly organisationService: OrganisationService, ) {} @@ -723,4 +729,96 @@ export class UserService { }) .getMany() } + + async getInvoicesForUser(userId: string): Promise { + return this.billingInvoiceRepository.find({ + where: { userId }, + order: { billedAt: 'DESC' }, + }) + } + + async getInvoiceById( + id: string, + userId: string, + ): Promise { + return this.billingInvoiceRepository.findOne({ + where: { id, userId }, + }) + } + + async createInvoice(data: Partial): Promise { + return this.billingInvoiceRepository.save(data) + } + + async upsertInvoice( + data: Partial & { providerPaymentId: string }, + ): Promise { + 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 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), + }) + + invoices.push(invoice) + } + + return invoices + } catch (error) { + console.error( + '[syncSubscriptionPayments] Failed:', + error?.response?.data || error?.message, + ) + return [] + } + } } diff --git a/backend/apps/cloud/src/webhook/webhook.controller.ts b/backend/apps/cloud/src/webhook/webhook.controller.ts index 27928352d..0e363f002 100644 --- a/backend/apps/cloud/src/webhook/webhook.controller.ts +++ b/backend/apps/cloud/src/webhook/webhook.controller.ts @@ -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(), + }) + } catch (reason) { + this.logger.error( + '[subscription_payment_succeeded] Failed to persist invoice:', + reason, + ) + } + } + break } diff --git a/backend/migrations/mysql/2026_03_07_billing_invoices.sql b/backend/migrations/mysql/2026_03_07_billing_invoices.sql new file mode 100644 index 000000000..2d9c3e022 --- /dev/null +++ b/backend/migrations/mysql/2026_03_07_billing_invoices.sql @@ -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; diff --git a/web/app/lib/models/BillingInvoice.ts b/web/app/lib/models/BillingInvoice.ts new file mode 100644 index 000000000..5c904ca55 --- /dev/null +++ b/web/app/lib/models/BillingInvoice.ts @@ -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 +} diff --git a/web/app/pages/UserSettings/UserSettings.tsx b/web/app/pages/UserSettings/UserSettings.tsx index 7f5193b3f..d4bf803ab 100644 --- a/web/app/pages/UserSettings/UserSettings.tsx +++ b/web/app/pages/UserSettings/UserSettings.tsx @@ -18,6 +18,8 @@ import { LockIcon, CaretDownIcon, CreditCardIcon, + ReceiptIcon, + DownloadSimpleIcon, } from '@phosphor-icons/react' import _round from 'lodash/round' import React, { useState, useEffect, memo, useMemo, useRef } from 'react' @@ -45,6 +47,7 @@ import { import BillingPricing from '~/components/pricing/BillingPricing' import { usePaddle } from '~/hooks/usePaddle' import { changeLanguage } from '~/i18n' +import type { BillingInvoice } from '~/lib/models/BillingInvoice' import { DEFAULT_METAINFO } from '~/lib/models/Metainfo' import { UsageInfo } from '~/lib/models/Usageinfo' import { User } from '~/lib/models/User' @@ -282,6 +285,11 @@ const UserSettings = () => { [loaderData?.usageInfo], ) + const invoices = useMemo( + () => loaderData?.invoices ?? [], + [loaderData?.invoices], + ) + const isBillingLoading = !loaderData const { @@ -1535,6 +1543,136 @@ const UserSettings = () => { ) : null} + + {/* Invoice History Section */} + + {invoices.length > 0 ? ( +
+ + + + + + + + + + + + {invoices.map((invoice) => { + const currencySymbol = + invoice.currency === 'EUR' + ? '€' + : invoice.currency === 'GBP' + ? '£' + : '$' + + return ( + + + + + + + + ) + })} + +
+ {t('billing.invoiceDate')} + + {t('billing.invoiceAmount')} + + {t('billing.invoiceStatus')} + + {t('billing.invoicePlan')} + + + {t('billing.invoiceDownload')} + +
+ {language === 'en' + ? dayjs(invoice.billedAt) + .locale(language) + .format('MMM D, YYYY') + : dayjs(invoice.billedAt) + .locale(language) + .format('D MMM YYYY')} + + {currencySymbol} + {Number(invoice.amount).toFixed(2)} + + + {t(`billing.status.${invoice.status}`)} + + + {invoice.planCode || '—'} + {invoice.billingFrequency + ? ` (${t(`pricing.${invoice.billingFrequency === 'monthly' ? 'billedMonthly' : 'billedYearly'}`)})` + : ''} + + {invoice.receiptUrl ? ( + + + {t('billing.invoiceDownload')} + + ) : ( + + — + + )} +
+
+ ) : ( +
+ + + {t('billing.noInvoices')} + +
+ )} +
)} diff --git a/web/app/routes/user-settings.tsx b/web/app/routes/user-settings.tsx index c30640cc3..7f8aed11e 100644 --- a/web/app/routes/user-settings.tsx +++ b/web/app/routes/user-settings.tsx @@ -9,6 +9,7 @@ import type { SitemapFunction } from 'remix-sitemap' import { serverFetch } from '~/api/api.server' import { getOgImageUrl, isSelfhosted } from '~/lib/constants' +import { BillingInvoice } from '~/lib/models/BillingInvoice' import { Metainfo } from '~/lib/models/Metainfo' import { UsageInfo } from '~/lib/models/Usageinfo' import { User } from '~/lib/models/User' @@ -46,6 +47,7 @@ export const sitemap: SitemapFunction = () => ({ export interface UserSettingsLoaderData { metainfo: Metainfo | null usageInfo: UsageInfo | null + invoices: BillingInvoice[] | null } export async function loader({ request }: LoaderFunctionArgs) { @@ -55,20 +57,27 @@ export async function loader({ request }: LoaderFunctionArgs) { return data({ metainfo: null, usageInfo: null, + invoices: null, }) } - const [metainfoResult, usageInfoResult] = await Promise.all([ + const [metainfoResult, usageInfoResult, invoicesResult] = await Promise.all([ serverFetch(request, 'user/metainfo'), serverFetch(request, 'user/usageinfo'), + serverFetch(request, 'user/billing/invoices'), ]) - const cookies = [...metainfoResult.cookies, ...usageInfoResult.cookies] + const cookies = [ + ...metainfoResult.cookies, + ...usageInfoResult.cookies, + ...invoicesResult.cookies, + ] return data( { metainfo: metainfoResult.data, usageInfo: usageInfoResult.data, + invoices: invoicesResult.data, }, { headers: createHeadersWithCookies(cookies) }, ) diff --git a/web/public/locales/en.json b/web/public/locales/en.json index 7a31b3687..ef6253fb2 100644 --- a/web/public/locales/en.json +++ b/web/public/locales/en.json @@ -1995,7 +1995,20 @@ "xPercentUsed": "{{percentage}}% used", "xPercentRemaining": "{{percentage}}% remaining", "paddleLoadError": "Failed to load the payment system. Please refresh the page and try again.", - "paddleStillLoading": "Payment system is still loading. Please try again in a moment." + "paddleStillLoading": "Payment system is still loading. Please try again in a moment.", + "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" + } }, "modals": { "paidFeature": {