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.
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -49,7 +49,7 @@ export class Discount {
|
||||
|
||||
@Column({
|
||||
type: 'decimal',
|
||||
precision: 5,
|
||||
precision: 15,
|
||||
scale: 2,
|
||||
nullable: false,
|
||||
transformer: {
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for creating an internal Lightning invoice.
|
||||
*/
|
||||
export interface BTCPayCreateLnInvoiceRequest {
|
||||
amount: string;
|
||||
description?: string;
|
||||
descriptionHashOnly?: boolean;
|
||||
expiry?: number;
|
||||
privateRouteHints?: boolean;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<Invoice> {
|
||||
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<InvoiceQuote> {
|
||||
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<BTCPayPaymentMethod[]>(
|
||||
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<InvoiceQuote> {
|
||||
const { data: paymentMethods } = await axios.get<BTCPayPaymentMethod[]>(
|
||||
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<BTCPayInternalLnInvoice> {
|
||||
const body: BTCPayCreateLnInvoiceRequest = {
|
||||
amount: amountMsat.toString(),
|
||||
description,
|
||||
expiry,
|
||||
privateRouteHints: true,
|
||||
};
|
||||
|
||||
const { data } = await axios.post<BTCPayInternalLnInvoice>(
|
||||
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<BTCPayInternalLnInvoice> {
|
||||
const { data } = await axios.get<BTCPayInternalLnInvoice>(
|
||||
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<void> {
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
await axios.put(
|
||||
this.storeUrl(`/invoices/${invoiceId}`),
|
||||
{ metadata },
|
||||
{ headers: this.headers },
|
||||
);
|
||||
}
|
||||
|
||||
// ── Private Helpers ─────────────────────────────────────────────────────
|
||||
|
||||
private async getRawInvoice(invoiceId: string): Promise<BTCPayInvoiceResponse> {
|
||||
|
||||
@@ -63,7 +63,7 @@ export class UpdateProjectDTO extends PartialType(CreateProjectDTO) {
|
||||
@IsString()
|
||||
@IsEnum(statuses)
|
||||
@IsOptional()
|
||||
status: Status = 'draft';
|
||||
status?: Status;
|
||||
|
||||
@IsString()
|
||||
@IsEnum(formats)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<Project> = {
|
||||
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<Project> = {};
|
||||
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 {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export class Rent {
|
||||
userId: string;
|
||||
|
||||
@Column('decimal', {
|
||||
precision: 5,
|
||||
precision: 15,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer(),
|
||||
})
|
||||
|
||||
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ export class SeasonRent {
|
||||
userId: string;
|
||||
|
||||
@Column('decimal', {
|
||||
precision: 5,
|
||||
precision: 15,
|
||||
scale: 2,
|
||||
transformer: new ColumnNumericTransformer(),
|
||||
})
|
||||
|
||||
@@ -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<number> {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<number> {
|
||||
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: {
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user