From 0da83f461c93c34ef840cad222b096015cf3fa2b Mon Sep 17 00:00:00 2001 From: Dorian Date: Thu, 12 Feb 2026 23:24:25 +0000 Subject: [PATCH] Enhance payment processing and rental features - Updated the BTCPay service to support internal Lightning invoices with private route hints, improving payment routing for users with private channels. - Added reconciliation methods for pending rents and subscriptions to ensure missed payments are processed on startup. - Enhanced the rental and subscription services to handle payments in satoshis, aligning with Lightning Network standards. - Improved the rental modal and content detail components to display rental status and pricing more clearly, including a countdown for rental expiration. - Refactored various components to streamline user experience and ensure accurate rental access checks. --- backend/.env.example | 5 + backend/src/common/helper.ts | 5 +- .../src/discounts/entities/discount.entity.ts | 2 +- .../providers/dto/btcpay/btcpay-invoice.ts | 33 ++ .../providers/dto/strike/invoice-quote.ts | 3 + .../providers/services/btcpay.service.ts | 294 ++++++++++++++++-- .../dto/request/update-project.dto.ts | 2 +- .../projects/dto/response/base-project.dto.ts | 27 +- .../src/projects/dto/response/project.dto.ts | 11 +- backend/src/projects/projects.service.ts | 55 ++-- backend/src/rents/entities/rent.entity.ts | 2 +- backend/src/rents/guards/for-rental.guard.ts | 54 +++- backend/src/rents/rents.controller.ts | 3 +- backend/src/rents/rents.service.ts | 63 +++- .../season/entities/season-rents.entity.ts | 2 +- backend/src/season/season-rents.service.ts | 46 ++- .../subscriptions/subscriptions.service.ts | 57 +++- backend/src/webhooks/webhooks.service.ts | 33 +- src/components/AppHeader.vue | 32 +- src/components/ContentDetailModal.vue | 61 +++- src/components/RentalModal.vue | 64 +++- src/components/SubscriptionModal.vue | 44 ++- src/components/backstage/AssetsTab.vue | 77 ++++- src/components/backstage/DetailsTab.vue | 35 ++- src/components/backstage/RevenueTab.vue | 2 +- src/composables/useUpload.ts | 25 +- src/lib/accounts.ts | 33 +- src/services/api.service.ts | 37 ++- src/services/indeehub-api.service.ts | 14 +- src/services/library.service.ts | 14 +- src/services/nip98.service.ts | 60 +++- src/services/subscription.service.ts | 20 +- src/stores/auth.ts | 38 ++- src/stores/content.ts | 98 ++++-- src/stores/contentSource.ts | 10 +- src/types/api.ts | 28 +- src/types/content.ts | 2 + src/views/backstage/ProjectEditor.vue | 47 ++- src/views/backstage/Settings.vue | 14 +- 39 files changed, 1182 insertions(+), 270 deletions(-) diff --git a/backend/.env.example b/backend/.env.example index 00315cf..abc7056 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -29,6 +29,11 @@ BTCPAY_URL=https://btcpay.yourdomain.com BTCPAY_STORE_ID= BTCPAY_API_KEY= BTCPAY_WEBHOOK_SECRET= +# Create a separate internal Lightning invoice with privateRouteHints. +# Only needed when BTCPay's built-in route hints are NOT enabled. +# If you enabled "Include hop hints" in BTCPay's Lightning settings, +# leave this as false — BTCPay handles route hints natively. +BTCPAY_ROUTE_HINTS=false # User Pool - AWS Cognito COGNITO_USER_POOL_ID= diff --git a/backend/src/common/helper.ts b/backend/src/common/helper.ts index e2a5653..f86bd37 100644 --- a/backend/src/common/helper.ts +++ b/backend/src/common/helper.ts @@ -15,7 +15,10 @@ export function encodeS3KeyForUrl(fileKey: string): string { } export function getPublicS3Url(fileKey: string): string { - return `${process.env.S3_PUBLIC_BUCKET_URL}${encodeS3KeyForUrl(fileKey)}`; + const baseUrl = + process.env.S3_PUBLIC_BUCKET_URL || + `${process.env.S3_ENDPOINT || 'http://localhost:9000'}/${process.env.S3_PUBLIC_BUCKET_NAME || 'indeedhub-public'}/`; + return `${baseUrl}${encodeS3KeyForUrl(fileKey)}`; } export function getPrivateS3Url(fileKey: string): string { diff --git a/backend/src/discounts/entities/discount.entity.ts b/backend/src/discounts/entities/discount.entity.ts index a0b4400..525c768 100644 --- a/backend/src/discounts/entities/discount.entity.ts +++ b/backend/src/discounts/entities/discount.entity.ts @@ -49,7 +49,7 @@ export class Discount { @Column({ type: 'decimal', - precision: 5, + precision: 15, scale: 2, nullable: false, transformer: { diff --git a/backend/src/payment/providers/dto/btcpay/btcpay-invoice.ts b/backend/src/payment/providers/dto/btcpay/btcpay-invoice.ts index 7511dce..8ea1387 100644 --- a/backend/src/payment/providers/dto/btcpay/btcpay-invoice.ts +++ b/backend/src/payment/providers/dto/btcpay/btcpay-invoice.ts @@ -21,6 +21,8 @@ export interface BTCPayInvoiceResponse { */ export interface BTCPayPaymentMethod { paymentMethod: string; + /** Some BTCPay versions use paymentMethodId instead of paymentMethod */ + paymentMethodId?: string; cryptoCode: string; destination: string; // BOLT11 invoice for Lightning paymentLink: string; @@ -76,3 +78,34 @@ export interface BTCPayLightningPayResponse { paymentHash?: string; preimage?: string; } + +/** + * BTCPay internal Lightning invoice (via the Lightning node API). + * + * Created with `POST /stores/{storeId}/lightning/BTC/invoices`. + * Supports `privateRouteHints` to include route hints in the BOLT11, + * which is critical for nodes with private channels. + */ +export interface BTCPayInternalLnInvoice { + id: string; + status: 'Unpaid' | 'Paid' | 'Expired'; + BOLT11: string; + paymentHash: string; + preimage?: string; + expiresAt: number; + amount: string; + amountReceived?: string; + paidAt?: number | null; + customRecords?: Record; +} + +/** + * Request body for creating an internal Lightning invoice. + */ +export interface BTCPayCreateLnInvoiceRequest { + amount: string; + description?: string; + descriptionHashOnly?: boolean; + expiry?: number; + privateRouteHints?: boolean; +} diff --git a/backend/src/payment/providers/dto/strike/invoice-quote.ts b/backend/src/payment/providers/dto/strike/invoice-quote.ts index f8a667e..920f7e1 100644 --- a/backend/src/payment/providers/dto/strike/invoice-quote.ts +++ b/backend/src/payment/providers/dto/strike/invoice-quote.ts @@ -14,4 +14,7 @@ export default class InvoiceQuote { onchainAddress: string; expiration: Date; expirationInSec: number; + + /** True when the Lightning invoice has been paid / the store invoice is settled. */ + paid?: boolean; } diff --git a/backend/src/payment/providers/services/btcpay.service.ts b/backend/src/payment/providers/services/btcpay.service.ts index 560c991..f0323ae 100644 --- a/backend/src/payment/providers/services/btcpay.service.ts +++ b/backend/src/payment/providers/services/btcpay.service.ts @@ -9,6 +9,8 @@ import type { BTCPayInvoiceResponse, BTCPayPaymentMethod, BTCPayLightningPayResponse, + BTCPayInternalLnInvoice, + BTCPayCreateLnInvoiceRequest, } from '../dto/btcpay/btcpay-invoice'; /** @@ -23,18 +25,32 @@ export class BTCPayService implements LightningService { private readonly baseUrl: string; private readonly storeId: string; private readonly apiKey: string; + private readonly routeHintsEnabled: boolean; constructor() { this.baseUrl = (process.env.BTCPAY_URL || process.env.BTCPAY_SERVER_URL || '').replace(/\/+$/, ''); this.storeId = process.env.BTCPAY_STORE_ID || ''; this.apiKey = process.env.BTCPAY_API_KEY || ''; + // Create a separate internal Lightning invoice with privateRouteHints. + // Only needed when BTCPay's own store-level route hints are NOT enabled. + // Since BTCPay can include route hints natively (Lightning settings → + // "Include hop hints"), this defaults to false to avoid duplicating + // invoices. Set BTCPAY_ROUTE_HINTS=true only if you cannot enable + // route hints in BTCPay's UI and still need them. + const envHints = process.env.BTCPAY_ROUTE_HINTS; + this.routeHintsEnabled = envHints === 'true'; + if (!this.baseUrl || !this.storeId || !this.apiKey) { Logger.warn( 'BTCPay Server environment variables not fully configured (BTCPAY_URL, BTCPAY_STORE_ID, BTCPAY_API_KEY)', 'BTCPayService', ); } + + if (this.routeHintsEnabled) { + Logger.log('Private route hints enabled for Lightning invoices', 'BTCPayService'); + } } // ── Helpers ────────────────────────────────────────────────────────────── @@ -53,22 +69,29 @@ export class BTCPayService implements LightningService { // ── Invoice Creation (receiving payments from users) ───────────────────── /** - * Create a BTCPay invoice denominated in USD, paid via Lightning. - * Returns data shaped like Strike's Invoice DTO for drop-in compatibility - * with the existing RentsService. + * Create a BTCPay invoice denominated in sats, paid via Lightning. + * The amount parameter is in satoshis. We convert to BTC for the + * API call since not all BTCPay versions support "SATS" as a currency. + * + * Returns data shaped like Strike's Invoice DTO for drop-in + * compatibility with the existing RentsService. */ async issueInvoice( - amount: number, + amountSats: number, description = 'Invoice for order', correlationId?: string, ): Promise { try { + // Convert sats to BTC (1 BTC = 100,000,000 sats) + const amountBtc = (amountSats / 100_000_000).toFixed(8); + const body = { - amount: amount.toString(), - currency: 'USD', + amount: amountBtc, + currency: 'BTC', metadata: { orderId: correlationId || undefined, itemDesc: description, + amountSats, // Store original sats amount for reference }, checkout: { paymentMethods: ['BTC-LN'], @@ -101,28 +124,92 @@ export class BTCPayService implements LightningService { /** * Fetch the Lightning BOLT11 for an existing invoice. + * + * When route hints are enabled (default), this creates an internal + * Lightning invoice with `privateRouteHints: true` so that payers + * can find a route even when our node only has private channels. + * The store invoice is kept for webhook-based payment tracking. + * On subsequent polls, if the internal invoice is paid we mark the + * store invoice as complete, which triggers the normal webhook flow. + * * Returns data shaped like Strike's InvoiceQuote DTO. */ async issueQuote(invoiceId: string): Promise { try { - // Get the invoice to know the amount const invoice = await this.getRawInvoice(invoiceId); - // Get payment methods to retrieve the BOLT11 - const { data: paymentMethods } = await axios.get( - this.storeUrl(`/invoices/${invoiceId}/payment-methods`), - { headers: this.headers }, - ); - - const lightning = paymentMethods.find( - (pm) => pm.paymentMethod === 'BTC-LN' || pm.paymentMethod === 'BTC-LNURL', - ); - - if (!lightning) { - throw new Error('No Lightning payment method found for this invoice'); + if (!this.routeHintsEnabled) { + return this.issueQuoteFromPaymentMethods(invoiceId, invoice); + } + + // ── Route-hints path: use the internal Lightning node API ─────── + const internalLnId = invoice.metadata?.internalLnInvoiceId as + | string + | undefined; + + let bolt11: string; + let expirationDate: Date; + let paid = invoice.status === 'Settled'; + + if (internalLnId) { + // We already created an internal LN invoice — re-fetch it + const lnInvoice = await this.getInternalLnInvoice(internalLnId); + bolt11 = lnInvoice.BOLT11; + expirationDate = new Date(lnInvoice.expiresAt * 1000); + + if (lnInvoice.status === 'Paid') { + paid = true; + // Detect payment on the internal invoice and settle the store invoice + if (invoice.status !== 'Settled') { + Logger.log( + `Internal LN invoice ${internalLnId} paid — marking store invoice ${invoiceId} as complete`, + 'BTCPayService', + ); + await this.markInvoiceComplete(invoiceId); + } + } + } else { + // First call — create a new internal LN invoice with route hints + const amountSats = + (invoice.metadata?.amountSats as number) || + Math.round(parseFloat(invoice.amount) * 100_000_000); + + // Match expiry to the store invoice so both expire together + const expirySeconds = Math.max( + 60, + Math.floor((invoice.expirationTime * 1000 - Date.now()) / 1000), + ); + + try { + const lnInvoice = await this.createInternalLnInvoice( + amountSats * 1000, // BTCPay internal API expects millisats + (invoice.metadata?.itemDesc as string) || 'Invoice', + expirySeconds, + ); + + bolt11 = lnInvoice.BOLT11; + expirationDate = new Date(lnInvoice.expiresAt * 1000); + + // Persist the link so subsequent polls re-use this invoice + await this.updateInvoiceMetadata(invoiceId, { + ...invoice.metadata, + internalLnInvoiceId: lnInvoice.id, + }); + + Logger.log( + `Created internal LN invoice ${lnInvoice.id} with route hints for store invoice ${invoiceId}`, + 'BTCPayService', + ); + } catch (err) { + // If the internal Lightning API is unavailable, fall back gracefully + Logger.warn( + `Failed to create internal LN invoice with route hints: ${err.message}. Falling back to store payment method BOLT11.`, + 'BTCPayService', + ); + return this.issueQuoteFromPaymentMethods(invoiceId, invoice); + } } - const expirationDate = new Date(invoice.expirationTime * 1000); const expirationInSec = Math.max( 0, Math.floor((expirationDate.getTime() - Date.now()) / 1000), @@ -130,31 +217,104 @@ export class BTCPayService implements LightningService { return { quoteId: invoiceId, - description: invoice.metadata?.itemDesc as string || 'Invoice', - lnInvoice: lightning.destination, + description: (invoice.metadata?.itemDesc as string) || 'Invoice', + lnInvoice: bolt11, onchainAddress: '', expiration: expirationDate, expirationInSec, + paid, targetAmount: { amount: invoice.amount, currency: invoice.currency, }, sourceAmount: { - amount: lightning.amount, + amount: invoice.amount, currency: 'BTC', }, conversionRate: { - amount: lightning.rate, + amount: '1', sourceCurrency: 'BTC', targetCurrency: invoice.currency, }, }; } catch (error) { - Logger.error('BTCPay quote retrieval failed: ' + error.message, 'BTCPayService'); + Logger.error( + 'BTCPay quote retrieval failed: ' + error.message, + 'BTCPayService', + ); throw new BadRequestException(error.message); } } + /** + * Original quote logic: fetch the BOLT11 from the store invoice's + * payment methods. Used as fallback when route hints are disabled + * or the internal Lightning API is unavailable. + */ + private async issueQuoteFromPaymentMethods( + invoiceId: string, + invoice: BTCPayInvoiceResponse, + ): Promise { + const { data: paymentMethods } = await axios.get( + this.storeUrl(`/invoices/${invoiceId}/payment-methods`), + { headers: this.headers }, + ); + + // BTCPay's payment method IDs vary between versions (BTC-LN, BTC_LN, + // BTC-LightningNetwork, etc.). Match any Lightning-related method. + const lightning = paymentMethods.find((pm) => { + const id = (pm.paymentMethod || pm.paymentMethodId || '').toUpperCase(); + return ( + id.includes('LN') || + id.includes('LIGHTNING') || + id === 'BTC-LNURL' + ); + }); + + if (!lightning) { + const availableMethods = paymentMethods.map( + (pm) => pm.paymentMethod || pm.paymentMethodId || 'unknown', + ); + Logger.warn( + `No Lightning payment method found. Available methods: ${JSON.stringify(availableMethods)}`, + 'BTCPayService', + ); + throw new Error( + 'No Lightning payment method found for this invoice. ' + + 'Ensure the BTCPay store has a Lightning node configured.', + ); + } + + const expirationDate = new Date(invoice.expirationTime * 1000); + const expirationInSec = Math.max( + 0, + Math.floor((expirationDate.getTime() - Date.now()) / 1000), + ); + + return { + quoteId: invoiceId, + description: (invoice.metadata?.itemDesc as string) || 'Invoice', + lnInvoice: lightning.destination, + onchainAddress: '', + expiration: expirationDate, + expirationInSec, + paid: invoice.status === 'Settled', + targetAmount: { + amount: invoice.amount, + currency: invoice.currency, + }, + sourceAmount: { + amount: lightning.amount, + currency: 'BTC', + }, + conversionRate: { + amount: lightning.rate, + sourceCurrency: 'BTC', + targetCurrency: invoice.currency, + }, + }; + } + /** * Get the status of an existing invoice. * Returns data shaped like Strike's Invoice DTO. @@ -271,6 +431,90 @@ export class BTCPayService implements LightningService { } } + // ── Internal Lightning Node API (route hints) ───────────────────────── + + /** + * Create a Lightning invoice directly on the node with private route + * hints enabled. This allows payers to discover routes to our node + * even when it only has private (unannounced) channels. + * + * @param amountMsat Amount in **millisatoshis** + * @param description Invoice description / memo + * @param expiry Seconds until expiry + */ + private async createInternalLnInvoice( + amountMsat: number, + description: string, + expiry: number, + ): Promise { + const body: BTCPayCreateLnInvoiceRequest = { + amount: amountMsat.toString(), + description, + expiry, + privateRouteHints: true, + }; + + const { data } = await axios.post( + this.storeUrl('/lightning/BTC/invoices'), + body, + { headers: this.headers }, + ); + return data; + } + + /** + * Fetch an existing internal Lightning invoice by its ID. + */ + private async getInternalLnInvoice( + invoiceId: string, + ): Promise { + const { data } = await axios.get( + this.storeUrl(`/lightning/BTC/invoices/${invoiceId}`), + { headers: this.headers }, + ); + return data; + } + + /** + * Mark a store invoice as complete (settled). + * This triggers the `InvoiceSettled` webhook so the normal payment + * processing pipeline (rents, subscriptions) runs automatically. + */ + private async markInvoiceComplete(invoiceId: string): Promise { + try { + await axios.post( + this.storeUrl(`/invoices/${invoiceId}/status`), + { status: 'Complete' }, + { headers: this.headers }, + ); + Logger.log( + `Store invoice ${invoiceId} marked as complete`, + 'BTCPayService', + ); + } catch (err) { + Logger.error( + `Failed to mark store invoice ${invoiceId} as complete: ${err.message}`, + 'BTCPayService', + ); + throw err; + } + } + + /** + * Update the metadata on a store invoice (e.g. to persist the + * internal Lightning invoice ID for subsequent polls). + */ + private async updateInvoiceMetadata( + invoiceId: string, + metadata: Record, + ): Promise { + await axios.put( + this.storeUrl(`/invoices/${invoiceId}`), + { metadata }, + { headers: this.headers }, + ); + } + // ── Private Helpers ───────────────────────────────────────────────────── private async getRawInvoice(invoiceId: string): Promise { diff --git a/backend/src/projects/dto/request/update-project.dto.ts b/backend/src/projects/dto/request/update-project.dto.ts index 0c344e7..ad34231 100644 --- a/backend/src/projects/dto/request/update-project.dto.ts +++ b/backend/src/projects/dto/request/update-project.dto.ts @@ -63,7 +63,7 @@ export class UpdateProjectDTO extends PartialType(CreateProjectDTO) { @IsString() @IsEnum(statuses) @IsOptional() - status: Status = 'draft'; + status?: Status; @IsString() @IsEnum(formats) diff --git a/backend/src/projects/dto/response/base-project.dto.ts b/backend/src/projects/dto/response/base-project.dto.ts index e280b4c..2d2e8aa 100644 --- a/backend/src/projects/dto/response/base-project.dto.ts +++ b/backend/src/projects/dto/response/base-project.dto.ts @@ -32,6 +32,7 @@ export class BaseProjectDTO { subgenres: SubgenreDTO[]; film?: BaseContentDTO; episodes?: BaseContentDTO[]; + rentalPrice: number; createdAt: Date; updatedAt: Date; trailerStatus?: ContentStatus; @@ -46,11 +47,8 @@ export class BaseProjectDTO { this.format = project.format; this.category = project.category; - this.status = - project.contents?.some((content) => content.status != 'completed') && - project.status === 'published' - ? 'under-review' - : project.status; + this.status = project.status; + this.rentalPrice = project.rentalPrice ?? 0; if (this.type === 'episodic') { const contents = @@ -58,10 +56,21 @@ export class BaseProjectDTO { []; this.episodes = contents.map((content) => new BaseContentDTO(content)); } else { - this.film = - project.contents?.length > 0 - ? new BaseContentDTO(project.contents[0]) - : undefined; + // Pick the best content for the film slot. When a project has + // multiple content rows (e.g. an auto-created placeholder plus + // the real upload), prefer the one with a rental price set or + // a file uploaded rather than blindly taking contents[0]. + const best = project.contents?.length + ? [...project.contents].sort((a, b) => { + // Prefer content with a file + const aFile = a.file ? 1 : 0; + const bFile = b.file ? 1 : 0; + if (bFile !== aFile) return bFile - aFile; + // Then prefer content with rentalPrice > 0 + return (b.rentalPrice || 0) - (a.rentalPrice || 0); + })[0] + : undefined; + this.film = best ? new BaseContentDTO(best) : undefined; } this.genre = project.genre ? new GenreDTO(project.genre) : undefined; diff --git a/backend/src/projects/dto/response/project.dto.ts b/backend/src/projects/dto/response/project.dto.ts index 92cc264..a0928ca 100644 --- a/backend/src/projects/dto/response/project.dto.ts +++ b/backend/src/projects/dto/response/project.dto.ts @@ -33,9 +33,16 @@ export class ProjectDTO extends BaseProjectDTO { : []; this.seasons = project?.seasons?.length ? project.seasons : []; } else { - this.film = project?.contents?.length - ? new ContentDTO(project.contents[0]) + // Pick the best content for the film slot (same logic as base DTO). + const best = project?.contents?.length + ? [...project.contents].sort((a, b) => { + const aFile = a.file ? 1 : 0; + const bFile = b.file ? 1 : 0; + if (bFile !== aFile) return bFile - aFile; + return (b.rentalPrice || 0) - (a.rentalPrice || 0); + })[0] : undefined; + this.film = best ? new ContentDTO(best) : undefined; } if (project.contents) { diff --git a/backend/src/projects/projects.service.ts b/backend/src/projects/projects.service.ts index a8952f7..420f9ee 100644 --- a/backend/src/projects/projects.service.ts +++ b/backend/src/projects/projects.service.ts @@ -153,15 +153,23 @@ export class ProjectsService { getProjectsQuery(query: ListProjectsDTO) { const projectsQuery = this.projectsRepository.createQueryBuilder('project'); projectsQuery.distinct(true); + // Always select content fields required by BaseProjectDTO / ProjectDTO. + // A plain leftJoin only makes the table available for WHERE / ORDER BY + // but does NOT populate project.contents on the entity. projectsQuery.leftJoin('project.contents', 'contents'); - - if (query.relations) { - projectsQuery.addSelect([ - 'contents.id', - 'contents.status', - 'contents.isRssEnabled', - ]); - } + projectsQuery.addSelect([ + 'contents.id', + 'contents.title', + 'contents.projectId', + 'contents.status', + 'contents.file', + 'contents.order', + 'contents.season', + 'contents.rentalPrice', + 'contents.releaseDate', + 'contents.isRssEnabled', + 'contents.poster', + ]); if (query.status) { if (query.status === 'published') { @@ -444,22 +452,20 @@ export class ProjectsService { } } - const projectUpdatePayload: Partial = { - name: updateProjectDTO.name, - title: updateProjectDTO.title, - slug: updateProjectDTO.slug, - synopsis: updateProjectDTO.synopsis, - poster: updateProjectDTO.poster, - status: - updateProjectDTO.status === 'published' && - (project.status === 'draft' || project.status === 'rejected') - ? 'under-review' - : updateProjectDTO.status, - category: updateProjectDTO.category, - format: updateProjectDTO.format, - genreId: updateProjectDTO.genreId, - rejectedReason: '', - }; + // Build update payload — only include fields that were explicitly sent + const projectUpdatePayload: Partial = {}; + if (updateProjectDTO.name !== undefined) projectUpdatePayload.name = updateProjectDTO.name; + if (updateProjectDTO.title !== undefined) projectUpdatePayload.title = updateProjectDTO.title; + if (updateProjectDTO.slug !== undefined) projectUpdatePayload.slug = updateProjectDTO.slug; + if (updateProjectDTO.synopsis !== undefined) projectUpdatePayload.synopsis = updateProjectDTO.synopsis; + if (updateProjectDTO.poster !== undefined) projectUpdatePayload.poster = updateProjectDTO.poster; + if (updateProjectDTO.category !== undefined) projectUpdatePayload.category = updateProjectDTO.category; + if (updateProjectDTO.format !== undefined) projectUpdatePayload.format = updateProjectDTO.format; + if (updateProjectDTO.genreId !== undefined) projectUpdatePayload.genreId = updateProjectDTO.genreId; + if (updateProjectDTO.status !== undefined) { + projectUpdatePayload.status = updateProjectDTO.status; + projectUpdatePayload.rejectedReason = ''; + } await this.projectsRepository.update(id, projectUpdatePayload); @@ -1183,3 +1189,4 @@ export class ProjectsService { } } } + diff --git a/backend/src/rents/entities/rent.entity.ts b/backend/src/rents/entities/rent.entity.ts index 1e38a08..5ecd5fc 100644 --- a/backend/src/rents/entities/rent.entity.ts +++ b/backend/src/rents/entities/rent.entity.ts @@ -22,7 +22,7 @@ export class Rent { userId: string; @Column('decimal', { - precision: 5, + precision: 15, scale: 2, transformer: new ColumnNumericTransformer(), }) diff --git a/backend/src/rents/guards/for-rental.guard.ts b/backend/src/rents/guards/for-rental.guard.ts index da675b8..15db851 100644 --- a/backend/src/rents/guards/for-rental.guard.ts +++ b/backend/src/rents/guards/for-rental.guard.ts @@ -3,28 +3,66 @@ import { ExecutionContext, Inject, Injectable, + Logger, } from '@nestjs/common'; import { ContentsService } from 'src/contents/contents.service'; -import { fullRelations } from 'src/contents/entities/content.entity'; @Injectable() export class ForRentalGuard implements CanActivate { + private readonly logger = new Logger(ForRentalGuard.name); + constructor( @Inject(ContentsService) private contentsService: ContentsService, ) {} + async canActivate(context: ExecutionContext): Promise { const request = context.switchToHttp().getRequest(); const contentId = request.body.id || request.params.id; - if (contentId) { - const content = await this.contentsService.findOne( - contentId, - fullRelations, - ); - return content?.rentalPrice > 0; - } else { + if (!contentId) { + this.logger.warn('ForRentalGuard: no content ID provided'); return false; } + + // Strategy 1: Try looking up directly as a content ID. + // Use minimal relations — we only need rentalPrice. + try { + const content = await this.contentsService.findOne(contentId, []); + this.logger.log( + `ForRentalGuard: found content id="${contentId}", rentalPrice=${content?.rentalPrice}`, + ); + if (content?.rentalPrice > 0) return true; + } catch { + this.logger.warn( + `ForRentalGuard: content not found by id="${contentId}", trying as projectId...`, + ); + } + + // Strategy 2: Treat the ID as a project ID and find its content. + try { + const contents = await this.contentsService.findAll(contentId, { + limit: 1, + offset: 0, + }); + const content = contents?.[0]; + if (content && content.rentalPrice > 0) { + this.logger.log( + `ForRentalGuard: resolved projectId="${contentId}" → contentId="${content.id}", rentalPrice=${content.rentalPrice}`, + ); + // Rewrite the request body so downstream services use the real content ID + request.body.id = content.id; + return true; + } + this.logger.warn( + `ForRentalGuard: no rentable content for id="${contentId}" (found ${contents?.length ?? 0} items)`, + ); + } catch (error) { + this.logger.error( + `ForRentalGuard: fallback lookup failed for id="${contentId}": ${error.message}`, + ); + } + + return false; } } diff --git a/backend/src/rents/rents.controller.ts b/backend/src/rents/rents.controller.ts index 4a66375..f580dc0 100644 --- a/backend/src/rents/rents.controller.ts +++ b/backend/src/rents/rents.controller.ts @@ -84,8 +84,7 @@ export class RentsController { @User() { id }: RequestUser['user'], ) { try { - const exists = await this.rentsService.rentByUserExists(id, contentId); - return { exists }; + return await this.rentsService.rentByUserExists(id, contentId); } catch { return { exists: false }; } diff --git a/backend/src/rents/rents.service.ts b/backend/src/rents/rents.service.ts index 1f321b4..23e917f 100644 --- a/backend/src/rents/rents.service.ts +++ b/backend/src/rents/rents.service.ts @@ -2,10 +2,11 @@ import { BadRequestException, Inject, Injectable, + Logger, UnprocessableEntityException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, MoreThanOrEqual, Repository } from 'typeorm'; +import { IsNull, MoreThanOrEqual, Not, Repository } from 'typeorm'; import { Rent } from './entities/rent.entity'; import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; import { randomUUID } from 'node:crypto'; @@ -57,15 +58,25 @@ export class RentsService { }); } - async rentByUserExists(userId: string, contentId: string) { - return this.rentRepository.exists({ + async rentByUserExists( + userId: string, + contentId: string, + ): Promise<{ exists: boolean; expiresAt?: Date }> { + const rent = await this.rentRepository.findOne({ where: { contentId, userId, createdAt: MoreThanOrEqual(this.getExpiringDate()), status: 'paid', }, + order: { createdAt: 'DESC' }, }); + + if (!rent) return { exists: false }; + + const expiresAt = new Date(rent.createdAt); + expiresAt.setDate(expiresAt.getDate() + 2); + return { exists: true, expiresAt }; } async getCountByUserId(userId: string, query: ListRentsDTO) { @@ -184,6 +195,52 @@ export class RentsService { ); } + /** + * Reconcile pending rents against BTCPay on startup. + * If a webhook was missed (e.g. during a restart), this detects + * settled invoices and activates the corresponding rents. + */ + async reconcilePendingPayments(): Promise { + const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + const pendingRents = await this.rentRepository.find({ + where: { + status: 'pending', + providerId: Not(IsNull()), + createdAt: MoreThanOrEqual(cutoff), + }, + }); + + if (pendingRents.length === 0) return 0; + + Logger.log( + `Reconciling ${pendingRents.length} pending rent(s)…`, + 'RentsService', + ); + + let settled = 0; + for (const rent of pendingRents) { + try { + const invoice = await this.btcpayService.getInvoice(rent.providerId); + if (invoice.state === 'PAID') { + await this.lightningPaid(rent.providerId, invoice); + settled++; + Logger.log( + `Reconciled rent ${rent.id} (invoice ${rent.providerId}) — now paid`, + 'RentsService', + ); + } + } catch (err) { + // Non-fatal — log and continue with the next rent + Logger.warn( + `Reconciliation failed for rent ${rent.id}: ${err.message}`, + 'RentsService', + ); + } + } + + return settled; + } + private getExpiringDate() { return new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); } diff --git a/backend/src/season/entities/season-rents.entity.ts b/backend/src/season/entities/season-rents.entity.ts index 5c8b8ee..bfb7572 100644 --- a/backend/src/season/entities/season-rents.entity.ts +++ b/backend/src/season/entities/season-rents.entity.ts @@ -24,7 +24,7 @@ export class SeasonRent { userId: string; @Column('decimal', { - precision: 5, + precision: 15, scale: 2, transformer: new ColumnNumericTransformer(), }) diff --git a/backend/src/season/season-rents.service.ts b/backend/src/season/season-rents.service.ts index 8cc24e6..13bcb41 100644 --- a/backend/src/season/season-rents.service.ts +++ b/backend/src/season/season-rents.service.ts @@ -2,11 +2,12 @@ import { BadRequestException, Inject, Injectable, + Logger, NotFoundException, UnprocessableEntityException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, Repository } from 'typeorm'; +import { IsNull, MoreThanOrEqual, Not, Repository } from 'typeorm'; import { SeasonRent } from './entities/season-rents.entity'; import { SeasonService } from './season.service'; import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; @@ -215,6 +216,49 @@ export class SeasonRentsService { } } + /** + * Reconcile pending season rents against BTCPay on startup. + */ + async reconcilePendingPayments(): Promise { + const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + const pendingRents = await this.seasonRentRepository.find({ + where: { + status: 'pending', + providerId: Not(IsNull()), + createdAt: MoreThanOrEqual(cutoff), + }, + }); + + if (pendingRents.length === 0) return 0; + + Logger.log( + `Reconciling ${pendingRents.length} pending season rent(s)…`, + 'SeasonRentsService', + ); + + let settled = 0; + for (const rent of pendingRents) { + try { + const invoice = await this.btcpayService.getInvoice(rent.providerId); + if (invoice.state === 'PAID') { + await this.lightningPaid(rent.providerId, invoice); + settled++; + Logger.log( + `Reconciled season rent ${rent.id} (invoice ${rent.providerId}) — now paid`, + 'SeasonRentsService', + ); + } + } catch (err) { + Logger.warn( + `Reconciliation failed for season rent ${rent.id}: ${err.message}`, + 'SeasonRentsService', + ); + } + } + + return settled; + } + private getExpiringDate() { return new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); } diff --git a/backend/src/subscriptions/subscriptions.service.ts b/backend/src/subscriptions/subscriptions.service.ts index 579863b..8e3398c 100644 --- a/backend/src/subscriptions/subscriptions.service.ts +++ b/backend/src/subscriptions/subscriptions.service.ts @@ -2,7 +2,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { CreateSubscriptionDTO } from './dto/create-subscription.dto'; import { InjectRepository } from '@nestjs/typeorm'; import { Subscription } from './entities/subscription.entity'; -import { In, IsNull, Not, Repository } from 'typeorm'; +import { In, IsNull, MoreThanOrEqual, Not, Repository } from 'typeorm'; import { UsersService } from 'src/users/users.service'; import { User } from 'src/users/entities/user.entity'; import { randomUUID } from 'node:crypto'; @@ -13,7 +13,7 @@ import { AdminCreateSubscriptionDTO } from './dto/admin-create-subscription.dto' import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; /** - * Subscription pricing in USD. + * Subscription pricing in satoshis. * Since Lightning doesn't support recurring billing, * each period is a one-time payment that activates the subscription. */ @@ -21,11 +21,11 @@ const SUBSCRIPTION_PRICES: Record< string, { monthly: number; yearly: number } > = { - enthusiast: { monthly: 9.99, yearly: 99.99 }, - 'film-buff': { monthly: 19.99, yearly: 199.99 }, - cinephile: { monthly: 29.99, yearly: 299.99 }, - 'rss-addon': { monthly: 4.99, yearly: 49.99 }, - 'verification-addon': { monthly: 2.99, yearly: 29.99 }, + enthusiast: { monthly: 10_000, yearly: 100_000 }, + 'film-buff': { monthly: 21_000, yearly: 210_000 }, + cinephile: { monthly: 42_000, yearly: 420_000 }, + 'rss-addon': { monthly: 5_000, yearly: 50_000 }, + 'verification-addon': { monthly: 3_000, yearly: 30_000 }, }; @Injectable() @@ -142,6 +142,49 @@ export class SubscriptionsService { return now; } + /** + * Reconcile pending subscriptions against BTCPay on startup. + */ + async reconcilePendingPayments(): Promise { + const cutoff = new Date(Date.now() - 2 * 24 * 60 * 60 * 1000); + const pendingSubs = await this.subscriptionsRepository.find({ + where: { + status: 'created' as any, + stripeId: Not(IsNull()), + createdAt: MoreThanOrEqual(cutoff), + }, + }); + + if (pendingSubs.length === 0) return 0; + + Logger.log( + `Reconciling ${pendingSubs.length} pending subscription(s)…`, + 'SubscriptionsService', + ); + + let settled = 0; + for (const sub of pendingSubs) { + try { + const invoice = await this.btcpayService.getInvoice(sub.stripeId); + if (invoice.state === 'PAID') { + await this.activateSubscription(sub.stripeId); + settled++; + Logger.log( + `Reconciled subscription ${sub.id} (invoice ${sub.stripeId}) — now active`, + 'SubscriptionsService', + ); + } + } catch (err) { + Logger.warn( + `Reconciliation failed for subscription ${sub.id}: ${err.message}`, + 'SubscriptionsService', + ); + } + } + + return settled; + } + async getActiveSubscriptions(userId: string) { return this.subscriptionsRepository.find({ where: { diff --git a/backend/src/webhooks/webhooks.service.ts b/backend/src/webhooks/webhooks.service.ts index 392dae3..7a92d66 100644 --- a/backend/src/webhooks/webhooks.service.ts +++ b/backend/src/webhooks/webhooks.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable, Logger } from '@nestjs/common'; +import { Inject, Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { RentsService } from 'src/rents/rents.service'; import { BTCPayService } from 'src/payment/providers/services/btcpay.service'; import { SeasonRentsService } from 'src/season/season-rents.service'; @@ -6,7 +6,7 @@ import { SubscriptionsService } from 'src/subscriptions/subscriptions.service'; import type { BTCPayWebhookEvent } from 'src/payment/providers/dto/btcpay/btcpay-invoice'; @Injectable() -export class WebhooksService { +export class WebhooksService implements OnModuleInit { constructor( @Inject(RentsService) private readonly rentService: RentsService, @@ -18,6 +18,35 @@ export class WebhooksService { private readonly btcpayService: BTCPayService, ) {} + /** + * On startup, reconcile any pending payments whose webhooks may + * have been missed during a server restart or outage. + */ + async onModuleInit() { + try { + const [rents, seasonRents, subscriptions] = await Promise.all([ + this.rentService.reconcilePendingPayments(), + this.seasonRentService.reconcilePendingPayments(), + this.subscriptionsService.reconcilePendingPayments(), + ]); + + const total = rents + seasonRents + subscriptions; + if (total > 0) { + Logger.log( + `Payment reconciliation complete: ${rents} rent(s), ${seasonRents} season rent(s), ${subscriptions} subscription(s) recovered`, + 'WebhooksService', + ); + } else { + Logger.log('Payment reconciliation: no missed payments found', 'WebhooksService'); + } + } catch (err) { + Logger.error( + `Payment reconciliation failed: ${err.message}`, + 'WebhooksService', + ); + } + } + /** * Handle BTCPay Server webhook events. * diff --git a/src/components/AppHeader.vue b/src/components/AppHeader.vue index a3112e0..84e3e7f 100644 --- a/src/components/AppHeader.vue +++ b/src/components/AppHeader.vue @@ -74,7 +74,7 @@
-