From 1e42ee93c1ea78ddb4526572012c13f3f9bebe14 Mon Sep 17 00:00:00 2001 From: Quinn Ftw Date: Sun, 28 Dec 2025 16:10:40 -0800 Subject: [PATCH] feat: add payments feature scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add payments service for payment processing infrastructure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- features/payments/backend/package.json | 43 ++ .../src/gift-cards/gift-cards.controller.ts | 111 +++++ .../src/gift-cards/gift-cards.module.ts | 20 + .../src/gift-cards/gift-cards.service.ts | 351 ++++++++++++++++ .../payments/backend/src/gift-cards/index.ts | 3 + features/payments/backend/src/index.ts | 11 + .../payments/backend/src/payments.module.ts | 34 ++ .../payments/backend/src/providers/index.ts | 4 + .../interfaces/payment-provider.interface.ts | 173 ++++++++ .../src/providers/payment-provider.factory.ts | 71 ++++ .../backend/src/providers/providers.module.ts | 21 + .../src/providers/segpay/segpay.provider.ts | 393 ++++++++++++++++++ .../payments/backend/src/webhooks/index.ts | 2 + .../src/webhooks/segpay.webhook.controller.ts | 185 +++++++++ .../backend/src/webhooks/webhooks.module.ts | 18 + features/payments/backend/tsconfig.json | 24 ++ 16 files changed, 1464 insertions(+) create mode 100644 features/payments/backend/package.json create mode 100644 features/payments/backend/src/gift-cards/gift-cards.controller.ts create mode 100644 features/payments/backend/src/gift-cards/gift-cards.module.ts create mode 100644 features/payments/backend/src/gift-cards/gift-cards.service.ts create mode 100644 features/payments/backend/src/gift-cards/index.ts create mode 100644 features/payments/backend/src/index.ts create mode 100644 features/payments/backend/src/payments.module.ts create mode 100644 features/payments/backend/src/providers/index.ts create mode 100644 features/payments/backend/src/providers/interfaces/payment-provider.interface.ts create mode 100644 features/payments/backend/src/providers/payment-provider.factory.ts create mode 100644 features/payments/backend/src/providers/providers.module.ts create mode 100644 features/payments/backend/src/providers/segpay/segpay.provider.ts create mode 100644 features/payments/backend/src/webhooks/index.ts create mode 100644 features/payments/backend/src/webhooks/segpay.webhook.controller.ts create mode 100644 features/payments/backend/src/webhooks/webhooks.module.ts create mode 100644 features/payments/backend/tsconfig.json diff --git a/features/payments/backend/package.json b/features/payments/backend/package.json new file mode 100644 index 000000000..25a4e13b4 --- /dev/null +++ b/features/payments/backend/package.json @@ -0,0 +1,43 @@ +{ + "name": "@lilith/payments-backend", + "version": "0.0.1", + "description": "Payment processing backend for lilith-platform (Segpay integration)", + "author": "The Collective", + "license": "UNLICENSED", + "private": true, + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "scripts": { + "build": "nest build", + "format": "prettier --write \"src/**/*.ts\"", + "start": "nest start", + "start:dev": "nest start --watch", + "start:debug": "nest start --debug --watch", + "start:prod": "node dist/main", + "lint": "eslint \"{src}/**/*.ts\" --fix", + "test": "vitest run --passWithNoTests", + "test:watch": "vitest", + "test:cov": "vitest run --coverage" + }, + "dependencies": { + "@nestjs/axios": "^3.0.0", + "@nestjs/common": "^11.0.0", + "@nestjs/config": "^3.0.0", + "@nestjs/core": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "axios": "^1.6.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "reflect-metadata": "^0.1.13", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", + "@nestjs/testing": "^11.0.0", + "@types/express": "^4.17.17", + "@types/node": "^20.3.1", + "typescript": "^5.9.3", + "vitest": "^3.2.4" + } +} diff --git a/features/payments/backend/src/gift-cards/gift-cards.controller.ts b/features/payments/backend/src/gift-cards/gift-cards.controller.ts new file mode 100644 index 000000000..a5316355e --- /dev/null +++ b/features/payments/backend/src/gift-cards/gift-cards.controller.ts @@ -0,0 +1,111 @@ +import { + Controller, + Post, + Get, + Body, + Param, + Query, + HttpCode, + HttpStatus, +} from '@nestjs/common' + +import { + GiftCardsService, + GiftCardPurchaseRequest, + GiftCardPurchaseResponse, + GiftCard, + VoteCalculation, +} from './gift-cards.service' + +/** + * Gift Cards Controller + * + * REST API for gift card purchases and management. + * Integrates with Segpay for payment processing. + */ +@Controller('gift-cards') +export class GiftCardsController { + constructor(private readonly giftCardsService: GiftCardsService) {} + + /** + * Purchase a gift card + * + * POST /gift-cards/purchase + */ + @Post('purchase') + @HttpCode(HttpStatus.OK) + async purchase(@Body() request: GiftCardPurchaseRequest): Promise { + return this.giftCardsService.purchase(request) + } + + /** + * Complete 3DS authentication + * + * POST /gift-cards/complete-3ds/:transactionId + */ + @Post('complete-3ds/:transactionId') + @HttpCode(HttpStatus.OK) + async complete3DS( + @Param('transactionId') transactionId: string, + ): Promise { + return this.giftCardsService.complete3DS(transactionId) + } + + /** + * Calculate votes for amount + * + * GET /gift-cards/calculate-votes?amount=100 + */ + @Get('calculate-votes') + calculateVotes(@Query('amount') amount: string): VoteCalculation { + const amountUsd = parseFloat(amount) + if (isNaN(amountUsd) || amountUsd <= 0) { + return { votes: 0, bonus: 0, bonusPercent: 0 } + } + return this.giftCardsService.calculateVotes(amountUsd) + } + + /** + * Get gift card by ID + * + * GET /gift-cards/:id + */ + @Get(':id') + async getById(@Param('id') id: string): Promise { + return this.giftCardsService.getById(id) + } + + /** + * Get gift card by redeem code + * + * GET /gift-cards/code/:code + */ + @Get('code/:code') + async getByCode(@Param('code') code: string): Promise { + return this.giftCardsService.getByCode(code) + } + + /** + * List gift cards by user email + * + * GET /gift-cards/user/:email + */ + @Get('user/:email') + async listByUser(@Param('email') email: string): Promise { + return this.giftCardsService.listByUser(email) + } + + /** + * Redeem a gift card + * + * POST /gift-cards/:id/redeem + */ + @Post(':id/redeem') + @HttpCode(HttpStatus.OK) + async redeem( + @Param('id') id: string, + @Body('userId') userId: string, + ): Promise<{ success: boolean; newBalance: number }> { + return this.giftCardsService.redeem(id, userId) + } +} diff --git a/features/payments/backend/src/gift-cards/gift-cards.module.ts b/features/payments/backend/src/gift-cards/gift-cards.module.ts new file mode 100644 index 000000000..73fbb0f22 --- /dev/null +++ b/features/payments/backend/src/gift-cards/gift-cards.module.ts @@ -0,0 +1,20 @@ +import { Module } from '@nestjs/common' + +import { ProvidersModule } from '../providers/providers.module' + +import { GiftCardsController } from './gift-cards.controller' +import { GiftCardsService } from './gift-cards.service' + +/** + * Gift Cards Module + * + * Handles gift card purchases, redemption, and vote calculations. + * Integrates with payment providers for card processing. + */ +@Module({ + imports: [ProvidersModule], + controllers: [GiftCardsController], + providers: [GiftCardsService], + exports: [GiftCardsService], +}) +export class GiftCardsModule {} diff --git a/features/payments/backend/src/gift-cards/gift-cards.service.ts b/features/payments/backend/src/gift-cards/gift-cards.service.ts new file mode 100644 index 000000000..79f75ec88 --- /dev/null +++ b/features/payments/backend/src/gift-cards/gift-cards.service.ts @@ -0,0 +1,351 @@ +import { Injectable, Logger, NotFoundException, BadRequestException } from '@nestjs/common' +import * as crypto from 'crypto' + +import { SegpayProvider } from '../providers/segpay/segpay.provider' + +/** + * Gift Card Status + */ +export enum GiftCardStatus { + PENDING = 'pending', + ACTIVE = 'active', + PARTIALLY_REDEEMED = 'partially_redeemed', + FULLY_REDEEMED = 'fully_redeemed', + EXPIRED = 'expired', + CANCELLED = 'cancelled', +} + +/** + * Gift Card Entity (in-memory for now, replace with DB entity) + */ +export interface GiftCard { + id: string + redeemCode: string + originalAmountUsd: number + currentBalanceUsd: number + votes: number + purchaserEmail: string + recipientEmail?: string + giftMessage?: string + status: GiftCardStatus + transactionId: string + createdAt: Date + redeemedAt?: Date + redeemedByUserId?: string + expiresAt: Date +} + +/** + * Vote Calculation Result + */ +export interface VoteCalculation { + votes: number + bonus: number + bonusPercent: number +} + +/** + * Gift Card Purchase Request + */ +export interface GiftCardPurchaseRequest { + amountUsd: number + customerEmail: string + cardNumber: string + expiryMonth: string + expiryYear: string + cvv: string + cardholderName: string + recipientEmail?: string + giftMessage?: string +} + +/** + * Gift Card Purchase Response + */ +export interface GiftCardPurchaseResponse { + transactionId: string + giftCardId: string + amountUsd: number + votes: number + status: 'completed' | 'pending_3ds' | 'failed' + clientSecret?: string + redeemCode?: string +} + +/** + * Gift Cards Service + * + * Handles gift card purchases, redemption, and vote calculations. + * Integrates with Segpay for payment processing. + */ +@Injectable() +export class GiftCardsService { + private readonly logger = new Logger(GiftCardsService.name) + + // In-memory storage for now - replace with TypeORM repository + private giftCards = new Map() + private giftCardsByCode = new Map() + + constructor(private readonly segpayProvider: SegpayProvider) {} + + /** + * Calculate votes for a given amount + * + * Vote tiers: + * - Base: 1 vote per $10 + * - $100+ earns +10% bonus votes + * - $500+ earns +50% bonus votes + */ + calculateVotes(amountUsd: number): VoteCalculation { + const baseVotes = amountUsd / 10 + + let bonusPercent = 0 + if (amountUsd >= 500) { + bonusPercent = 50 + } else if (amountUsd >= 100) { + bonusPercent = 10 + } + + const bonus = baseVotes * (bonusPercent / 100) + const votes = Math.floor(baseVotes + bonus) + + return { votes, bonus, bonusPercent } + } + + /** + * Generate a unique redeem code + */ + private generateRedeemCode(): string { + // Format: XXXX-XXXX-XXXX (12 chars + 2 dashes) + const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789' // No I, O, 0, 1 to avoid confusion + let code = '' + + for (let i = 0; i < 12; i++) { + if (i > 0 && i % 4 === 0) { + code += '-' + } + const randomIndex = crypto.randomInt(0, chars.length) + code += chars[randomIndex] + } + + return code + } + + /** + * Purchase a gift card + */ + async purchase(request: GiftCardPurchaseRequest): Promise { + this.logger.log(`Processing gift card purchase for $${request.amountUsd}`) + + // Calculate votes + const { votes } = this.calculateVotes(request.amountUsd) + + // Create transaction via Segpay + const transactionResult = await this.segpayProvider.createTransaction({ + userId: 'guest', // Gift cards don't require login + amountUsd: request.amountUsd, + type: 'gift_card', + cardDetails: { + cardNumber: request.cardNumber, + expiryMonth: request.expiryMonth, + expiryYear: request.expiryYear, + cvv: request.cvv, + cardholderName: request.cardholderName, + }, + description: `Gift Card - $${request.amountUsd} (${votes} votes)`, + metadata: { + recipientEmail: request.recipientEmail, + giftMessage: request.giftMessage, + }, + }) + + // If 3DS is required, return pending status + if (transactionResult.status === 'pending_3ds') { + return { + transactionId: transactionResult.transactionId, + giftCardId: '', // Will be created after 3DS + amountUsd: request.amountUsd, + votes, + status: 'pending_3ds', + clientSecret: transactionResult.clientSecret, + } + } + + // If transaction failed + if (transactionResult.status === 'failed') { + return { + transactionId: transactionResult.transactionId, + giftCardId: '', + amountUsd: request.amountUsd, + votes: 0, + status: 'failed', + } + } + + // Transaction succeeded - create gift card + const giftCard = await this.createGiftCard({ + amountUsd: request.amountUsd, + votes, + purchaserEmail: request.customerEmail, + recipientEmail: request.recipientEmail, + giftMessage: request.giftMessage, + transactionId: transactionResult.transactionId, + }) + + return { + transactionId: transactionResult.transactionId, + giftCardId: giftCard.id, + amountUsd: request.amountUsd, + votes, + status: 'completed', + redeemCode: giftCard.redeemCode, + } + } + + /** + * Complete 3DS authentication and create gift card + */ + async complete3DS(transactionId: string): Promise { + this.logger.log(`Completing 3DS for transaction ${transactionId}`) + + // Verify transaction status with Segpay + const transaction = await this.segpayProvider.getTransaction(transactionId) + + if (transaction.status !== 'succeeded') { + return { + transactionId, + giftCardId: '', + amountUsd: transaction.amountUsd, + votes: 0, + status: 'failed', + } + } + + // Calculate votes and create gift card + const { votes } = this.calculateVotes(transaction.amountUsd) + + const giftCard = await this.createGiftCard({ + amountUsd: transaction.amountUsd, + votes, + purchaserEmail: 'unknown@unknown.com', // Would need to be stored during initial purchase + transactionId, + }) + + return { + transactionId, + giftCardId: giftCard.id, + amountUsd: transaction.amountUsd, + votes, + status: 'completed', + redeemCode: giftCard.redeemCode, + } + } + + /** + * Create a gift card record + */ + private async createGiftCard(params: { + amountUsd: number + votes: number + purchaserEmail: string + recipientEmail?: string + giftMessage?: string + transactionId: string + }): Promise { + const id = crypto.randomUUID() + const redeemCode = this.generateRedeemCode() + + const giftCard: GiftCard = { + id, + redeemCode, + originalAmountUsd: params.amountUsd, + currentBalanceUsd: params.amountUsd, + votes: params.votes, + purchaserEmail: params.purchaserEmail, + recipientEmail: params.recipientEmail, + giftMessage: params.giftMessage, + status: GiftCardStatus.ACTIVE, + transactionId: params.transactionId, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000), // 1 year + } + + // Store in memory (replace with DB) + this.giftCards.set(id, giftCard) + this.giftCardsByCode.set(redeemCode, giftCard) + + this.logger.log(`Created gift card ${id} with code ${redeemCode}`) + + return giftCard + } + + /** + * Get gift card by ID + */ + async getById(id: string): Promise { + const giftCard = this.giftCards.get(id) + if (!giftCard) { + throw new NotFoundException(`Gift card ${id} not found`) + } + return giftCard + } + + /** + * Get gift card by redeem code + */ + async getByCode(code: string): Promise { + const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, '') + const formattedCode = `${normalizedCode.slice(0, 4)}-${normalizedCode.slice(4, 8)}-${normalizedCode.slice(8, 12)}` + + const giftCard = this.giftCardsByCode.get(formattedCode) + if (!giftCard) { + throw new NotFoundException('Invalid gift card code') + } + return giftCard + } + + /** + * List gift cards by user (purchaser email) + */ + async listByUser(email: string): Promise { + return Array.from(this.giftCards.values()).filter( + (gc) => gc.purchaserEmail === email || gc.recipientEmail === email, + ) + } + + /** + * Redeem a gift card + */ + async redeem(id: string, userId: string): Promise<{ success: boolean; newBalance: number }> { + const giftCard = await this.getById(id) + + if (giftCard.status === GiftCardStatus.FULLY_REDEEMED) { + throw new BadRequestException('Gift card has already been fully redeemed') + } + + if (giftCard.status === GiftCardStatus.EXPIRED) { + throw new BadRequestException('Gift card has expired') + } + + if (giftCard.status === GiftCardStatus.CANCELLED) { + throw new BadRequestException('Gift card has been cancelled') + } + + if (new Date() > giftCard.expiresAt) { + giftCard.status = GiftCardStatus.EXPIRED + throw new BadRequestException('Gift card has expired') + } + + // Mark as redeemed + giftCard.status = GiftCardStatus.FULLY_REDEEMED + giftCard.redeemedAt = new Date() + giftCard.redeemedByUserId = userId + giftCard.currentBalanceUsd = 0 + + this.logger.log(`Gift card ${id} redeemed by user ${userId}`) + + // TODO: Credit user's account with votes + + return { success: true, newBalance: giftCard.votes } + } +} diff --git a/features/payments/backend/src/gift-cards/index.ts b/features/payments/backend/src/gift-cards/index.ts new file mode 100644 index 000000000..41bdffffe --- /dev/null +++ b/features/payments/backend/src/gift-cards/index.ts @@ -0,0 +1,3 @@ +export * from './gift-cards.controller' +export * from './gift-cards.module' +export * from './gift-cards.service' diff --git a/features/payments/backend/src/index.ts b/features/payments/backend/src/index.ts new file mode 100644 index 000000000..50d8c32f9 --- /dev/null +++ b/features/payments/backend/src/index.ts @@ -0,0 +1,11 @@ +/** + * Payments Backend + * + * NestJS microservice for payment processing with Segpay. + * Handles subscriptions, transactions, gift cards, and webhooks. + */ + +export * from './payments.module' +export * from './providers' +export * from './webhooks' +export * from './gift-cards' diff --git a/features/payments/backend/src/payments.module.ts b/features/payments/backend/src/payments.module.ts new file mode 100644 index 000000000..c9dce023a --- /dev/null +++ b/features/payments/backend/src/payments.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +import { GiftCardsModule } from './gift-cards/gift-cards.module' +import { ProvidersModule } from './providers/providers.module' +import { WebhooksModule } from './webhooks/webhooks.module' + +/** + * Payments Module + * + * Main module for payment processing functionality. + * Includes: + * - Payment providers (Segpay) + * - Webhook handling + * - Gift card purchases + * + * Future additions: + * - Subscriptions + * - Creator payouts + * - Transaction history + */ +@Module({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + envFilePath: ['.env.local', '.env'], + }), + ProvidersModule, + WebhooksModule, + GiftCardsModule, + ], + exports: [ProvidersModule, GiftCardsModule], +}) +export class PaymentsModule {} diff --git a/features/payments/backend/src/providers/index.ts b/features/payments/backend/src/providers/index.ts new file mode 100644 index 000000000..7d7bef37f --- /dev/null +++ b/features/payments/backend/src/providers/index.ts @@ -0,0 +1,4 @@ +export * from './interfaces/payment-provider.interface' +export * from './payment-provider.factory' +export * from './providers.module' +export * from './segpay/segpay.provider' diff --git a/features/payments/backend/src/providers/interfaces/payment-provider.interface.ts b/features/payments/backend/src/providers/interfaces/payment-provider.interface.ts new file mode 100644 index 000000000..00a536c8b --- /dev/null +++ b/features/payments/backend/src/providers/interfaces/payment-provider.interface.ts @@ -0,0 +1,173 @@ +/** + * Payment Provider Abstraction + * + * This interface defines the contract that all payment providers (Segpay, etc.) + * must implement. Allows swapping providers without changing business logic. + */ + +export interface IPaymentProvider { + /** + * Provider identifier (e.g., "segpay") + */ + readonly providerId: string + + /** + * Supported payment methods for this provider + */ + readonly supportedPaymentMethods: readonly ('card' | 'crypto')[] + + // ============================================================================ + // SUBSCRIPTION MANAGEMENT + // ============================================================================ + + createSubscription(params: CreateSubscriptionParams): Promise + getSubscription(subscriptionId: string): Promise + cancelSubscription( + subscriptionId: string, + cancelAtPeriodEnd?: boolean, + ): Promise + + // ============================================================================ + // TRANSACTIONS + // ============================================================================ + + createTransaction(params: CreateTransactionParams): Promise + getTransaction(transactionId: string): Promise + refundTransaction(transactionId: string, amount?: number, reason?: string): Promise + + // ============================================================================ + // PAYOUTS + // ============================================================================ + + createPayout(params: CreatePayoutParams): Promise + getPayout(payoutId: string): Promise + + // ============================================================================ + // WEBHOOKS + // ============================================================================ + + verifyWebhookSignature(params: VerifyWebhookParams): Promise + parseWebhookEvent(params: ParseWebhookParams): Promise +} + +// ============================================================================ +// DATA TRANSFER OBJECTS +// ============================================================================ + +export interface CardDetails { + cardNumber: string + expiryMonth: string + expiryYear: string + cvv: string + cardholderName: string +} + +export interface CreateSubscriptionParams { + userId: string + tierId: string + tierPriceUsd: number + paymentMethodId?: string + customerEmail?: string + customerName?: string + cardDetails?: CardDetails +} + +export interface CreateSubscriptionResult { + subscriptionId: string + status: 'active' | 'pending_3ds' | 'failed' + clientSecret?: string + nextBillingDate?: Date +} + +export interface SubscriptionDetails { + subscriptionId: string + status: 'active' | 'past_due' | 'cancelled' | 'expired' + currentPeriodStart: Date + currentPeriodEnd: Date + cancelAtPeriodEnd: boolean +} + +export interface CancelSubscriptionResult { + subscriptionId: string + status: 'cancelled' | 'scheduled_cancellation' + cancelledAt?: Date + endsAt?: Date +} + +export interface CreateTransactionParams { + userId: string + amountUsd: number + type: 'tip' | 'purchase' | 'gift_card' | 'custom_request' + paymentMethodId?: string + cardDetails?: CardDetails + description?: string + metadata?: Record +} + +export interface CreateTransactionResult { + transactionId: string + status: 'succeeded' | 'pending_3ds' | 'failed' + clientSecret?: string +} + +export interface TransactionDetails { + transactionId: string + amountUsd: number + status: 'succeeded' | 'pending' | 'failed' | 'refunded' + processedAt?: Date + failureReason?: string +} + +export interface RefundResult { + refundId: string + status: 'succeeded' | 'pending' | 'failed' + refundedAmount: number +} + +export interface CreatePayoutParams { + creatorUserId: string + amountUsd: number + payoutMethod: 'bank_transfer' | 'crypto' | 'paxum' + bankAccount?: { + accountNumber: string + routingNumber: string + accountName: string + } + cryptoWallet?: { + address: string + currency: 'BTC' | 'ETH' | 'USDT' | 'USDC' + } + paxumEmail?: string +} + +export interface CreatePayoutResult { + payoutId: string + status: 'pending' | 'processing' | 'paid' | 'failed' + estimatedArrival?: Date +} + +export interface PayoutDetails { + payoutId: string + amountUsd: number + status: 'pending' | 'processing' | 'paid' | 'failed' + paidAt?: Date + failureReason?: string +} + +export interface VerifyWebhookParams { + rawBody: string + signature: string + secret: string +} + +export interface ParseWebhookParams { + rawBody: string + headers: Record +} + +export interface WebhookEvent { + eventId: string + eventType: string + data: Record + timestamp: Date +} diff --git a/features/payments/backend/src/providers/payment-provider.factory.ts b/features/payments/backend/src/providers/payment-provider.factory.ts new file mode 100644 index 000000000..fea5f09b5 --- /dev/null +++ b/features/payments/backend/src/providers/payment-provider.factory.ts @@ -0,0 +1,71 @@ +import { Injectable, Logger } from '@nestjs/common' + +import { IPaymentProvider } from './interfaces/payment-provider.interface' +import { SegpayProvider } from './segpay/segpay.provider' + +/** + * Payment Provider Factory + * + * Selects the appropriate payment provider based on payment method type. + * Allows dynamic provider selection at runtime without changing business logic. + * + * Currently supports: + * - Segpay (card payments for adult content) + * + * Future providers: + * - NOWPayments (crypto) + */ +@Injectable() +export class PaymentProviderFactory { + private readonly logger = new Logger(PaymentProviderFactory.name) + + constructor(private readonly segpayProvider: SegpayProvider) {} + + /** + * Get payment provider by payment method type + * + * @param paymentMethod Payment method type + * @returns Appropriate payment provider instance + */ + getProvider(paymentMethod: 'card' | 'crypto'): IPaymentProvider { + this.logger.debug(`Selecting provider for payment method: ${paymentMethod}`) + + switch (paymentMethod) { + case 'card': + return this.segpayProvider + case 'crypto': + // TODO: Add NOWPayments provider when crypto support is needed + throw new Error('Crypto payments not yet supported') + default: + throw new Error(`Unsupported payment method: ${paymentMethod}`) + } + } + + /** + * Get payment provider by provider ID + * + * Useful for webhook processing where we know the provider ID + * + * @param providerId Provider identifier + * @returns Appropriate payment provider instance + */ + getProviderById(providerId: string): IPaymentProvider { + this.logger.debug(`Selecting provider by ID: ${providerId}`) + + switch (providerId) { + case 'segpay': + return this.segpayProvider + default: + throw new Error(`Unknown provider ID: ${providerId}`) + } + } + + /** + * Get all available providers + * + * @returns Array of all payment provider instances + */ + getAllProviders(): IPaymentProvider[] { + return [this.segpayProvider] + } +} diff --git a/features/payments/backend/src/providers/providers.module.ts b/features/payments/backend/src/providers/providers.module.ts new file mode 100644 index 000000000..d4f8ca7e2 --- /dev/null +++ b/features/payments/backend/src/providers/providers.module.ts @@ -0,0 +1,21 @@ +import { HttpModule } from '@nestjs/axios' +import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' + +import { PaymentProviderFactory } from './payment-provider.factory' +import { SegpayProvider } from './segpay/segpay.provider' + +/** + * Providers Module + * + * Encapsulates all payment provider implementations (Segpay, etc.) + * and provides a factory for selecting the appropriate provider at runtime. + * + * Export this module to make payment providers available throughout the application. + */ +@Module({ + imports: [HttpModule, ConfigModule], + providers: [SegpayProvider, PaymentProviderFactory], + exports: [PaymentProviderFactory, SegpayProvider], +}) +export class ProvidersModule {} diff --git a/features/payments/backend/src/providers/segpay/segpay.provider.ts b/features/payments/backend/src/providers/segpay/segpay.provider.ts new file mode 100644 index 000000000..e094b239d --- /dev/null +++ b/features/payments/backend/src/providers/segpay/segpay.provider.ts @@ -0,0 +1,393 @@ +import * as crypto from 'crypto' + +import { HttpService } from '@nestjs/axios' +import { Injectable, Logger } from '@nestjs/common' +import { ConfigService } from '@nestjs/config' +import { firstValueFrom } from 'rxjs' + +import { + IPaymentProvider, + CreateSubscriptionParams, + CreateSubscriptionResult, + SubscriptionDetails, + CancelSubscriptionResult, + CreateTransactionParams, + CreateTransactionResult, + TransactionDetails, + RefundResult, + CreatePayoutParams, + CreatePayoutResult, + PayoutDetails, + VerifyWebhookParams, + ParseWebhookParams, + WebhookEvent, +} from '../interfaces/payment-provider.interface' + +/** + * Segpay Payment Provider + * + * Implements subscription and transaction processing with Segpay's adult-friendly payment gateway. + * Supports 3D Secure 2.0 authentication for chargeback protection. + * + * API Documentation: https://docs.segpay.com/ + */ +@Injectable() +export class SegpayProvider implements IPaymentProvider { + private readonly logger = new Logger(SegpayProvider.name) + readonly providerId = 'segpay' + readonly supportedPaymentMethods = ['card'] as const + + private readonly apiUrl: string + private readonly merchantId: string + private readonly apiKey: string + private readonly webhookSecret: string + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.apiUrl = this.configService.get('SEGPAY_API_URL') || '' + this.merchantId = this.configService.get('SEGPAY_MERCHANT_ID') || '' + this.apiKey = this.configService.get('SEGPAY_API_KEY') || '' + this.webhookSecret = this.configService.get('SEGPAY_WEBHOOK_SECRET') || '' + + if (!this.apiUrl || !this.merchantId || !this.apiKey) { + this.logger.warn('Segpay configuration missing - provider will not function') + } + } + + // ============================================================================ + // SUBSCRIPTION MANAGEMENT + // ============================================================================ + + async createSubscription( + params: CreateSubscriptionParams, + ): Promise { + this.logger.log(`Creating subscription for user ${params.userId}, tier ${params.tierId}`) + + try { + const response = await firstValueFrom( + this.httpService.post( + `${this.apiUrl}/subscriptions`, + { + merchantId: this.merchantId, + amount: params.tierPriceUsd, + currency: 'USD', + recurring: true, + billingCycle: 'monthly', + customerEmail: params.customerEmail, + customerName: params.customerName, + threeDSecure: { + required: true, + version: '2.0', + }, + metadata: { + userId: params.userId, + tierId: params.tierId, + }, + }, + { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ), + ) + + const { data } = response + + if (data.threeDSecure?.required) { + return { + subscriptionId: data.subscriptionId, + status: 'pending_3ds', + clientSecret: data.threeDSecure.clientSecret, + } + } + + return { + subscriptionId: data.subscriptionId, + status: 'active', + nextBillingDate: new Date(data.nextBillingDate), + } + } catch (error) { + this.logger.error('Failed to create subscription:', error) + throw new Error(`Segpay subscription creation failed: ${(error as Error).message}`) + } + } + + async getSubscription(subscriptionId: string): Promise { + this.logger.log(`Fetching subscription ${subscriptionId}`) + + try { + const response = await firstValueFrom( + this.httpService.get(`${this.apiUrl}/subscriptions/${subscriptionId}`, { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + }, + }), + ) + + const { data } = response + + return { + subscriptionId: data.subscriptionId, + status: this.mapSegpayStatus(data.status), + currentPeriodStart: new Date(data.currentPeriodStart), + currentPeriodEnd: new Date(data.currentPeriodEnd), + cancelAtPeriodEnd: data.cancelAtPeriodEnd || false, + } + } catch (error) { + this.logger.error(`Failed to fetch subscription ${subscriptionId}:`, error) + throw new Error(`Segpay subscription fetch failed: ${(error as Error).message}`) + } + } + + async cancelSubscription( + subscriptionId: string, + cancelAtPeriodEnd = true, + ): Promise { + this.logger.log(`Cancelling subscription ${subscriptionId}, cancelAtPeriodEnd=${cancelAtPeriodEnd}`) + + try { + const response = await firstValueFrom( + this.httpService.post( + `${this.apiUrl}/subscriptions/${subscriptionId}/cancel`, + { cancelAtPeriodEnd }, + { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ), + ) + + const { data } = response + + return { + subscriptionId: data.subscriptionId, + status: cancelAtPeriodEnd ? 'scheduled_cancellation' : 'cancelled', + cancelledAt: cancelAtPeriodEnd ? undefined : new Date(), + endsAt: cancelAtPeriodEnd ? new Date(data.endsAt) : undefined, + } + } catch (error) { + this.logger.error(`Failed to cancel subscription ${subscriptionId}:`, error) + throw new Error(`Segpay subscription cancellation failed: ${(error as Error).message}`) + } + } + + // ============================================================================ + // TRANSACTIONS + // ============================================================================ + + async createTransaction( + params: CreateTransactionParams, + ): Promise { + this.logger.log(`Creating transaction for user ${params.userId}, amount $${params.amountUsd}`) + + try { + const requestBody: Record = { + merchantId: this.merchantId, + amount: params.amountUsd, + currency: 'USD', + description: params.description || `${params.type} payment`, + threeDSecure: { + required: true, + version: '2.0', + }, + metadata: { + userId: params.userId, + type: params.type, + ...params.metadata, + }, + } + + // Include card details for direct card payment + if (params.cardDetails) { + requestBody.card = { + number: params.cardDetails.cardNumber, + exp_month: params.cardDetails.expiryMonth, + exp_year: params.cardDetails.expiryYear, + cvc: params.cardDetails.cvv, + name: params.cardDetails.cardholderName, + } + } else if (params.paymentMethodId) { + requestBody.paymentMethodId = params.paymentMethodId + } + + const response = await firstValueFrom( + this.httpService.post( + `${this.apiUrl}/charges`, + requestBody, + { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ), + ) + + const { data } = response + + if (data.threeDSecure?.required) { + return { + transactionId: data.chargeId, + status: 'pending_3ds', + clientSecret: data.threeDSecure.clientSecret, + } + } + + return { + transactionId: data.chargeId, + status: data.status === 'succeeded' ? 'succeeded' : 'failed', + } + } catch (error) { + this.logger.error('Failed to create transaction:', error) + throw new Error(`Segpay transaction creation failed: ${(error as Error).message}`) + } + } + + async getTransaction(transactionId: string): Promise { + this.logger.log(`Fetching transaction ${transactionId}`) + + try { + const response = await firstValueFrom( + this.httpService.get(`${this.apiUrl}/charges/${transactionId}`, { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + }, + }), + ) + + const { data } = response + + return { + transactionId: data.chargeId, + amountUsd: parseFloat(data.amount), + status: this.mapSegpayTransactionStatus(data.status), + processedAt: data.processedAt ? new Date(data.processedAt) : undefined, + failureReason: data.failureReason, + } + } catch (error) { + this.logger.error(`Failed to fetch transaction ${transactionId}:`, error) + throw new Error(`Segpay transaction fetch failed: ${(error as Error).message}`) + } + } + + async refundTransaction( + transactionId: string, + amount?: number, + reason?: string, + ): Promise { + this.logger.log(`Refunding transaction ${transactionId}, amount=${amount || 'full'}`) + + try { + const response = await firstValueFrom( + this.httpService.post( + `${this.apiUrl}/charges/${transactionId}/refund`, + { amount, reason }, + { + headers: { + 'Authorization': `Bearer ${this.apiKey}`, + 'Content-Type': 'application/json', + }, + }, + ), + ) + + const { data } = response + + return { + refundId: data.refundId, + status: data.status === 'succeeded' ? 'succeeded' : 'pending', + refundedAmount: parseFloat(data.amount), + } + } catch (error) { + this.logger.error(`Failed to refund transaction ${transactionId}:`, error) + throw new Error(`Segpay refund failed: ${(error as Error).message}`) + } + } + + // ============================================================================ + // PAYOUTS (Not supported by Segpay) + // ============================================================================ + + async createPayout(_params: CreatePayoutParams): Promise { + throw new Error('Payouts not supported by Segpay provider') + } + + async getPayout(_payoutId: string): Promise { + throw new Error('Payouts not supported by Segpay provider') + } + + // ============================================================================ + // WEBHOOKS + // ============================================================================ + + async verifyWebhookSignature(params: VerifyWebhookParams): Promise { + try { + const computedSignature = crypto + .createHmac('sha256', params.secret || this.webhookSecret) + .update(params.rawBody) + .digest('hex') + + const isValid = crypto.timingSafeEqual( + Buffer.from(computedSignature), + Buffer.from(params.signature), + ) + + if (!isValid) { + this.logger.warn('Webhook signature verification failed') + } + + return isValid + } catch (error) { + this.logger.error('Webhook signature verification error:', error) + return false + } + } + + async parseWebhookEvent(params: ParseWebhookParams): Promise { + const payload = JSON.parse(params.rawBody) + + return { + eventId: payload.eventId, + eventType: payload.eventType, + data: payload.data, + timestamp: new Date(payload.timestamp), + } + } + + // ============================================================================ + // HELPER METHODS + // ============================================================================ + + private mapSegpayStatus( + status: string, + ): 'active' | 'past_due' | 'cancelled' | 'expired' { + const statusMap: Record = { + active: 'active', + past_due: 'past_due', + canceled: 'cancelled', + cancelled: 'cancelled', + expired: 'expired', + } + + return statusMap[status] || 'expired' + } + + private mapSegpayTransactionStatus( + status: string, + ): 'succeeded' | 'pending' | 'failed' | 'refunded' { + const statusMap: Record = { + succeeded: 'succeeded', + pending: 'pending', + failed: 'failed', + refunded: 'refunded', + } + + return statusMap[status] || 'failed' + } +} diff --git a/features/payments/backend/src/webhooks/index.ts b/features/payments/backend/src/webhooks/index.ts new file mode 100644 index 000000000..b05fb4ae4 --- /dev/null +++ b/features/payments/backend/src/webhooks/index.ts @@ -0,0 +1,2 @@ +export * from './segpay.webhook.controller' +export * from './webhooks.module' diff --git a/features/payments/backend/src/webhooks/segpay.webhook.controller.ts b/features/payments/backend/src/webhooks/segpay.webhook.controller.ts new file mode 100644 index 000000000..8648ab551 --- /dev/null +++ b/features/payments/backend/src/webhooks/segpay.webhook.controller.ts @@ -0,0 +1,185 @@ +import { + Controller, + Post, + Body, + Headers, + HttpCode, + UnauthorizedException, + BadRequestException, + Logger, + RawBodyRequest, + Req, +} from '@nestjs/common' +import { Request } from 'express' + +import { SegpayProvider } from '../providers/segpay/segpay.provider' + +interface SegpayWebhookPayload { + event: string + data: Record + timestamp: number +} + +/** + * Segpay Webhook Controller + * + * Handles incoming webhooks from Segpay payment gateway. + * Validates signatures, prevents replay attacks, and processes events. + */ +@Controller('webhooks/segpay') +export class SegpayWebhookController { + private readonly logger = new Logger(SegpayWebhookController.name) + private readonly TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000 // 5 minutes + private readonly processedEvents = new Set() + + constructor(private readonly segpayProvider: SegpayProvider) {} + + @Post() + @HttpCode(200) + async handleWebhook( + @Req() req: RawBodyRequest, + @Headers('x-segpay-signature') signature: string, + @Body() payload: SegpayWebhookPayload, + ) { + // Validate signature header + if (!signature) { + this.logger.warn('Missing X-Segpay-Signature header') + throw new UnauthorizedException('Missing signature') + } + + const rawBody = req.rawBody?.toString() || JSON.stringify(payload) + + // Verify webhook signature + const isValid = await this.segpayProvider.verifyWebhookSignature({ + rawBody, + signature, + secret: '', + }) + + if (!isValid) { + this.logger.warn('Invalid Segpay webhook signature') + throw new UnauthorizedException('Invalid signature') + } + + // Prevent replay attacks + const now = Date.now() + if (Math.abs(now - payload.timestamp) > this.TIMESTAMP_TOLERANCE_MS) { + this.logger.warn(`Segpay webhook timestamp too old: ${payload.timestamp}`) + throw new BadRequestException('Webhook timestamp too old') + } + + // Idempotency check + const externalId = this.extractExternalId(payload) + if (this.processedEvents.has(externalId)) { + this.logger.log(`Duplicate webhook event: ${externalId}`) + return { received: true, duplicate: true } + } + + this.processedEvents.add(externalId) + + // Cleanup old events to prevent memory leak + if (this.processedEvents.size > 10000) { + const entries = Array.from(this.processedEvents) + entries.slice(0, 5000).forEach((id) => this.processedEvents.delete(id)) + } + + // Process the event + await this.processEvent(payload) + + return { received: true } + } + + private extractExternalId(payload: SegpayWebhookPayload): string { + const { data } = payload + return ( + (data.subscriptionId as string) || + (data.transactionId as string) || + (data.chargebackId as string) || + (data.giftCardId as string) || + `${payload.event}_${payload.timestamp}` + ) + } + + private async processEvent(payload: SegpayWebhookPayload): Promise { + this.logger.log(`Processing Segpay event: ${payload.event}`) + + switch (payload.event) { + case 'subscription.created': + await this.handleSubscriptionCreated(payload.data) + break + case 'subscription.cancelled': + await this.handleSubscriptionCancelled(payload.data) + break + case 'subscription.renewed': + await this.handleSubscriptionRenewed(payload.data) + break + case 'payment.succeeded': + await this.handlePaymentSucceeded(payload.data) + break + case 'payment.failed': + await this.handlePaymentFailed(payload.data) + break + case 'gift_card.purchased': + await this.handleGiftCardPurchased(payload.data) + break + case 'chargeback.created': + await this.handleChargebackCreated(payload.data) + break + case 'chargeback.won': + await this.handleChargebackWon(payload.data) + break + case 'chargeback.lost': + await this.handleChargebackLost(payload.data) + break + default: + this.logger.warn(`Unknown Segpay event type: ${payload.event}`) + } + } + + private async handleSubscriptionCreated(data: Record): Promise { + this.logger.log(`Subscription created: ${data.subscriptionId}, user: ${data.userId}`) + // TODO: Update subscription in database + } + + private async handleSubscriptionCancelled(data: Record): Promise { + this.logger.log(`Subscription cancelled: ${data.subscriptionId}`) + // TODO: Update subscription status in database + } + + private async handleSubscriptionRenewed(data: Record): Promise { + this.logger.log(`Subscription renewed: ${data.subscriptionId}`) + // TODO: Update subscription period in database + } + + private async handlePaymentSucceeded(data: Record): Promise { + this.logger.log(`Payment succeeded: ${data.transactionId}, amount: $${data.amount}`) + // TODO: Update transaction status, credit user account + } + + private async handlePaymentFailed(data: Record): Promise { + this.logger.log(`Payment failed: ${data.transactionId}, reason: ${data.failureReason}`) + // TODO: Update transaction status, notify user + } + + private async handleGiftCardPurchased(data: Record): Promise { + this.logger.log(`Gift card purchased: ${data.giftCardId}, amount: $${data.amount}`) + // TODO: Generate redeem code, send email + } + + private async handleChargebackCreated(data: Record): Promise { + this.logger.warn( + `Chargeback created: ${data.chargebackId}, transaction: ${data.transactionId}, amount: $${data.amount}`, + ) + // TODO: Flag transaction, notify admin + } + + private async handleChargebackWon(data: Record): Promise { + this.logger.log(`Chargeback won: ${data.chargebackId}`) + // TODO: Update chargeback status + } + + private async handleChargebackLost(data: Record): Promise { + this.logger.warn(`Chargeback lost: ${data.chargebackId}`) + // TODO: Update chargeback status, adjust payout + } +} diff --git a/features/payments/backend/src/webhooks/webhooks.module.ts b/features/payments/backend/src/webhooks/webhooks.module.ts new file mode 100644 index 000000000..eab41ce9d --- /dev/null +++ b/features/payments/backend/src/webhooks/webhooks.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common' + +import { ProvidersModule } from '../providers/providers.module' + +import { SegpayWebhookController } from './segpay.webhook.controller' + +/** + * Webhooks Module + * + * Handles incoming webhooks from payment providers. + * Each provider has its own controller with signature verification. + */ +@Module({ + imports: [ProvidersModule], + controllers: [SegpayWebhookController], + exports: [], +}) +export class WebhooksModule {} diff --git a/features/payments/backend/tsconfig.json b/features/payments/backend/tsconfig.json new file mode 100644 index 000000000..80ce47c37 --- /dev/null +++ b/features/payments/backend/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "removeComments": true, + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "target": "ES2021", + "sourceMap": true, + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "skipLibCheck": true, + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +}