From edf8be014e8b27e32a53b2eee51413b7c7af7ed3 Mon Sep 17 00:00:00 2001 From: Dorian Date: Sat, 14 Feb 2026 13:21:27 +0000 Subject: [PATCH] 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. --- backend/src/app.module.ts | 2 + .../providers/services/btcpay.service.ts | 27 ++++ backend/src/webhooks/webhook.module.ts | 8 +- backend/src/webhooks/webhooks.service.ts | 10 +- .../src/zaps/dto/create-zap-invoice.dto.ts | 13 ++ backend/src/zaps/zaps.controller.ts | 22 +++ backend/src/zaps/zaps.module.ts | 13 ++ backend/src/zaps/zaps.service.ts | 91 +++++++++++ src/components/ContentDetailModal.vue | 29 ++-- src/components/ContentRow.vue | 47 +++++- src/components/ZapModal.vue | 143 ++++++++++-------- src/composables/useContentDiscovery.ts | 20 +++ 12 files changed, 337 insertions(+), 88 deletions(-) create mode 100644 backend/src/zaps/dto/create-zap-invoice.dto.ts create mode 100644 backend/src/zaps/zaps.controller.ts create mode 100644 backend/src/zaps/zaps.module.ts create mode 100644 backend/src/zaps/zaps.service.ts diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 68ceaf8..9c50eee 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/payment/providers/services/btcpay.service.ts b/backend/src/payment/providers/services/btcpay.service.ts index f187803..ac8ae04 100644 --- a/backend/src/payment/providers/services/btcpay.service.ts +++ b/backend/src/payment/providers/services/btcpay.service.ts @@ -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 { + const bolt11 = await this.resolveLightningAddress(address, sats, comment); + const payUrl = this.storeUrl('/lightning/BTC/invoices/pay'); + const { data } = await axios.post( + 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. */ diff --git a/backend/src/webhooks/webhook.module.ts b/backend/src/webhooks/webhook.module.ts index d2bf9ed..f5da6e5 100644 --- a/backend/src/webhooks/webhook.module.ts +++ b/backend/src/webhooks/webhook.module.ts @@ -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 {} diff --git a/backend/src/webhooks/webhooks.service.ts b/backend/src/webhooks/webhooks.service.ts index 2c693c4..554e348 100644 --- a/backend/src/webhooks/webhooks.service.ts +++ b/backend/src/webhooks/webhooks.service.ts @@ -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( diff --git a/backend/src/zaps/dto/create-zap-invoice.dto.ts b/backend/src/zaps/dto/create-zap-invoice.dto.ts new file mode 100644 index 0000000..15f7682 --- /dev/null +++ b/backend/src/zaps/dto/create-zap-invoice.dto.ts @@ -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; +} diff --git a/backend/src/zaps/zaps.controller.ts b/backend/src/zaps/zaps.controller.ts new file mode 100644 index 0000000..b38194c --- /dev/null +++ b/backend/src/zaps/zaps.controller.ts @@ -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); + } +} diff --git a/backend/src/zaps/zaps.module.ts b/backend/src/zaps/zaps.module.ts new file mode 100644 index 0000000..53001e3 --- /dev/null +++ b/backend/src/zaps/zaps.module.ts @@ -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 {} diff --git a/backend/src/zaps/zaps.service.ts b/backend/src/zaps/zaps.service.ts new file mode 100644 index 0000000..4518c27 --- /dev/null +++ b/backend/src/zaps/zaps.service.ts @@ -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 { + 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 { + 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}`); + } +} diff --git a/src/components/ContentDetailModal.vue b/src/components/ContentDetailModal.vue index ce6d914..79a00f4 100644 --- a/src/components/ContentDetailModal.vue +++ b/src/components/ContentDetailModal.vue @@ -143,30 +143,31 @@ {{ content.creator }} - +
- + Zapped by - ({{ totalZapSats.toLocaleString() }} sats) + {{ totalZapSats.toLocaleString() }} sats
-
+
- {{ formatZapAmount(zap.amount) }} + {{ formatZapAmount(zap.amount) }}
- + +{{ zapsList.length - 8 }} more
@@ -749,21 +750,21 @@ function openSubscriptionFromRental() { color: #F7931A; } -/* Zap avatar pill */ +/* Zap avatar pill (Primal/Yakihonne style: clear profile pic + amount) */ .zap-avatar-pill { display: flex; align-items: center; - gap: 4px; - padding: 3px 10px 3px 3px; - border-radius: 999px; + gap: 6px; + padding: 4px 12px 4px 4px; + border-radius: 9999px; background: rgba(247, 147, 26, 0.08); - border: 1px solid rgba(247, 147, 26, 0.15); + border: 1px solid rgba(247, 147, 26, 0.2); transition: all 0.2s ease; } .zap-avatar-pill:hover { background: rgba(247, 147, 26, 0.15); - border-color: rgba(247, 147, 26, 0.3); + border-color: rgba(247, 147, 26, 0.35); } /* Category Tags */ diff --git a/src/components/ContentRow.vue b/src/components/ContentRow.vue index 5fc2778..cb1f2f4 100644 --- a/src/components/ContentRow.vue +++ b/src/components/ContentRow.vue @@ -36,7 +36,7 @@ loading="lazy" /> -
+
+ +
@@ -71,6 +91,7 @@