feat: integrate ZapsModule and enhance zap payment handling

- Added ZapsModule to the application, integrating it into the main app and webhooks modules.
- Introduced a new method in BTCPayService for processing payments to Lightning addresses, improving zap payout functionality.
- Updated WebhooksService to handle zap payment events, allowing for seamless integration of zap transactions.
- Enhanced UI components to display zap-related information, including zaps count and avatar stacks, improving user engagement.

These changes enhance the overall zap payment experience and ensure better integration of zap functionalities across the application.
This commit is contained in:
Dorian
2026-02-14 13:21:27 +00:00
parent 1a5cbbfbf1
commit edf8be014e
12 changed files with 337 additions and 88 deletions

View File

@@ -39,6 +39,7 @@ import { DiscountsModule } from './discounts/discounts.module';
import { DiscountRedemptionModule } from './discount-redemption/discount-redemption.module';
import { NostrAuthModule } from './nostr-auth/nostr-auth.module';
import { LibraryModule } from './library/library.module';
import { ZapsModule } from './zaps/zaps.module';
@Module({
imports: [
@@ -94,6 +95,7 @@ import { LibraryModule } from './library/library.module';
DiscountRedemptionModule,
NostrAuthModule,
LibraryModule,
ZapsModule,
],
controllers: [AppController],
providers: [

View File

@@ -402,6 +402,33 @@ export class BTCPayService implements LightningService {
}
}
/**
* Pay sats to a Lightning address (e.g. for zap payouts).
* Resolves LNURL-pay to get BOLT11, then pays via our node.
*/
async payToLightningAddress(
address: string,
sats: number,
comment?: string,
): Promise<LightningPaymentDTO> {
const bolt11 = await this.resolveLightningAddress(address, sats, comment);
const payUrl = this.storeUrl('/lightning/BTC/invoices/pay');
const { data } = await axios.post<BTCPayLightningPayResponse>(
payUrl,
{ BOLT11: bolt11 },
{ headers: this.headers },
);
const result = data?.result;
const paymentHash =
data?.paymentHash ?? (data as any)?.payment_hash ?? 'btcpay-payment';
if (result && result !== 'Ok') {
throw new Error(
`Lightning payment failed: ${result}${data?.errorDetail || 'unknown error'}`,
);
}
return { id: paymentHash, status: 'COMPLETED' };
}
/**
* Validate a Lightning address by attempting to resolve it.
*/

View File

@@ -4,10 +4,16 @@ import { WebhooksService } from './webhooks.service';
import { RentsModule } from 'src/rents/rents.module';
import { SeasonModule } from 'src/season/season.module';
import { SubscriptionsModule } from 'src/subscriptions/subscriptions.module';
import { ZapsModule } from 'src/zaps/zaps.module';
@Module({
controllers: [WebhooksController],
imports: [SubscriptionsModule, RentsModule, forwardRef(() => SeasonModule)],
imports: [
SubscriptionsModule,
RentsModule,
forwardRef(() => SeasonModule),
ZapsModule,
],
providers: [WebhooksService],
})
export class WebhooksModule {}

View File

@@ -3,6 +3,7 @@ import { RentsService } from 'src/rents/rents.service';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { SeasonRentsService } from 'src/season/season-rents.service';
import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
import { ZapsService } from 'src/zaps/zaps.service';
import type { BTCPayWebhookEvent } from 'src/payment/providers/dto/btcpay/btcpay-invoice';
@Injectable()
@@ -14,6 +15,8 @@ export class WebhooksService implements OnModuleInit {
private readonly seasonRentService: SeasonRentsService,
@Inject(SubscriptionsService)
private readonly subscriptionsService: SubscriptionsService,
@Inject(ZapsService)
private readonly zapsService: ZapsService,
@Inject(BTCPayService)
private readonly btcpayService: BTCPayService,
) {}
@@ -89,7 +92,12 @@ export class WebhooksService implements OnModuleInit {
// Not a subscription — continue to season check
}
// Otherwise check if it's a season rental
// Check if it's a zap (user paid us → we pay out to creator)
if (invoice.correlationId?.startsWith('zap:')) {
return await this.zapsService.handleZapPaid(invoiceId, invoice);
}
// Otherwise treat as season rental
return await this.seasonRentService.lightningPaid(invoiceId, invoice);
} catch (error) {
Logger.error(

View File

@@ -0,0 +1,13 @@
import { IsNumber, IsString, Min } from 'class-validator';
export class CreateZapInvoiceDto {
@IsString()
projectId: string;
@IsString()
filmmakerId: string;
@IsNumber()
@Min(1, { message: 'Amount must be at least 1 satoshi' })
amountSats: number;
}

View File

@@ -0,0 +1,22 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ZapsService } from './zaps.service';
import { CreateZapInvoiceDto } from './dto/create-zap-invoice.dto';
@Controller('zaps')
export class ZapsController {
constructor(private readonly zapsService: ZapsService) {}
@Post('invoice')
async createInvoice(@Body() dto: CreateZapInvoiceDto) {
return this.zapsService.createInvoice(
dto.projectId,
dto.filmmakerId,
dto.amountSats,
);
}
@Get('invoice/:invoiceId/quote')
async getQuote(@Param('invoiceId') invoiceId: string) {
return this.zapsService.getQuote(invoiceId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ZapsController } from './zaps.controller';
import { ZapsService } from './zaps.service';
import { PaymentModule } from 'src/payment/payment.module';
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
@Module({
imports: [PaymentModule, FilmmakersModule],
controllers: [ZapsController],
providers: [ZapsService],
exports: [ZapsService],
})
export class ZapsModule {}

View File

@@ -0,0 +1,91 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
import Invoice from 'src/payment/providers/dto/strike/invoice';
import InvoiceQuote from 'src/payment/providers/dto/strike/invoice-quote';
const ZAP_ORDER_PREFIX = 'zap:';
@Injectable()
export class ZapsService {
private readonly logger = new Logger(ZapsService.name);
constructor(
private readonly btcpayService: BTCPayService,
private readonly filmmakersService: FilmmakersService,
) {}
/**
* Create a BTCPay invoice for a zap. User pays this invoice; on settlement
* we pay out to the creator's Lightning address. Correlation is stored so
* the webhook can route the payout.
*/
async createInvoice(projectId: string, filmmakerId: string, amountSats: number) {
await this.filmmakersService.getFilmmakerLightningAddress(filmmakerId);
const correlationId = `${ZAP_ORDER_PREFIX}${projectId}:${filmmakerId}`;
const invoice = await this.btcpayService.issueInvoice(
amountSats,
`Zap — project ${projectId}`,
correlationId,
);
return { invoiceId: invoice.invoiceId };
}
/**
* Get quote (BOLT11 + settled) for a zap invoice. Only allowed for invoices
* whose metadata marks them as zap (so we don't expose rent/subscription).
*/
async getQuote(invoiceId: string): Promise<InvoiceQuote> {
const invoice = await this.btcpayService.getInvoice(invoiceId);
if (!invoice.correlationId?.startsWith(ZAP_ORDER_PREFIX)) {
throw new NotFoundException('Zap invoice not found');
}
return this.btcpayService.issueQuote(invoiceId);
}
/**
* Called from webhook when a zap invoice is settled. Pays the creator's
* Lightning address via our BTCPay node.
*/
async handleZapPaid(invoiceId: string, invoice?: Invoice): Promise<void> {
if (!invoice) {
invoice = await this.btcpayService.getInvoice(invoiceId);
}
if (!invoice.correlationId?.startsWith(ZAP_ORDER_PREFIX)) {
return;
}
const parts = invoice.correlationId.slice(ZAP_ORDER_PREFIX.length).split(':');
if (parts.length < 2) {
this.logger.warn(`Invalid zap correlation: ${invoice.correlationId}`);
return;
}
const [_projectId, filmmakerId] = parts;
const amountBtc = parseFloat(invoice.amount?.amount ?? '0');
const sats = Math.floor(amountBtc * 100_000_000);
if (sats < 1) {
this.logger.warn(`Zap invoice ${invoiceId} amount too small: ${amountBtc} BTC`);
return;
}
let address: string;
try {
const res = await this.filmmakersService.getFilmmakerLightningAddress(filmmakerId);
address = res.lightningAddress;
} catch (e) {
this.logger.error(`Zap payout: no Lightning address for filmmaker ${filmmakerId}: ${e.message}`);
throw new BadRequestException('Creator has no Lightning address configured');
}
this.logger.log(`Zap payout: ${sats} sats to ${address} (invoice ${invoiceId})`);
await this.btcpayService.payToLightningAddress(
address,
sats,
`IndeeHub zap — project ${parts[0]}`,
);
this.logger.log(`Zap payout completed: ${invoiceId}`);
}
}