feat: add payments feature scaffold

Add payments service for payment processing infrastructure.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Quinn Ftw 2025-12-28 16:10:40 -08:00
parent c6f2f6d878
commit 1e42ee93c1
16 changed files with 1464 additions and 0 deletions

View file

@ -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"
}
}

View file

@ -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<GiftCardPurchaseResponse> {
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<GiftCardPurchaseResponse> {
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<GiftCard> {
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<GiftCard> {
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<GiftCard[]> {
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)
}
}

View file

@ -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 {}

View file

@ -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<string, GiftCard>()
private giftCardsByCode = new Map<string, GiftCard>()
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<GiftCardPurchaseResponse> {
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<GiftCardPurchaseResponse> {
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<GiftCard> {
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<GiftCard> {
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<GiftCard> {
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<GiftCard[]> {
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 }
}
}

View file

@ -0,0 +1,3 @@
export * from './gift-cards.controller'
export * from './gift-cards.module'
export * from './gift-cards.service'

View file

@ -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'

View file

@ -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 {}

View file

@ -0,0 +1,4 @@
export * from './interfaces/payment-provider.interface'
export * from './payment-provider.factory'
export * from './providers.module'
export * from './segpay/segpay.provider'

View file

@ -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<CreateSubscriptionResult>
getSubscription(subscriptionId: string): Promise<SubscriptionDetails>
cancelSubscription(
subscriptionId: string,
cancelAtPeriodEnd?: boolean,
): Promise<CancelSubscriptionResult>
// ============================================================================
// TRANSACTIONS
// ============================================================================
createTransaction(params: CreateTransactionParams): Promise<CreateTransactionResult>
getTransaction(transactionId: string): Promise<TransactionDetails>
refundTransaction(transactionId: string, amount?: number, reason?: string): Promise<RefundResult>
// ============================================================================
// PAYOUTS
// ============================================================================
createPayout(params: CreatePayoutParams): Promise<CreatePayoutResult>
getPayout(payoutId: string): Promise<PayoutDetails>
// ============================================================================
// WEBHOOKS
// ============================================================================
verifyWebhookSignature(params: VerifyWebhookParams): Promise<boolean>
parseWebhookEvent(params: ParseWebhookParams): Promise<WebhookEvent>
}
// ============================================================================
// 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<string, unknown>
}
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<string, string>
}
export interface WebhookEvent {
eventId: string
eventType: string
data: Record<string, unknown>
timestamp: Date
}

View file

@ -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]
}
}

View file

@ -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 {}

View file

@ -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<CreateSubscriptionResult> {
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<SubscriptionDetails> {
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<CancelSubscriptionResult> {
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<CreateTransactionResult> {
this.logger.log(`Creating transaction for user ${params.userId}, amount $${params.amountUsd}`)
try {
const requestBody: Record<string, unknown> = {
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<TransactionDetails> {
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<RefundResult> {
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<CreatePayoutResult> {
throw new Error('Payouts not supported by Segpay provider')
}
async getPayout(_payoutId: string): Promise<PayoutDetails> {
throw new Error('Payouts not supported by Segpay provider')
}
// ============================================================================
// WEBHOOKS
// ============================================================================
async verifyWebhookSignature(params: VerifyWebhookParams): Promise<boolean> {
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<WebhookEvent> {
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<string, 'active' | 'past_due' | 'cancelled' | 'expired'> = {
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<string, 'succeeded' | 'pending' | 'failed' | 'refunded'> = {
succeeded: 'succeeded',
pending: 'pending',
failed: 'failed',
refunded: 'refunded',
}
return statusMap[status] || 'failed'
}
}

View file

@ -0,0 +1,2 @@
export * from './segpay.webhook.controller'
export * from './webhooks.module'

View file

@ -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<string, unknown>
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<string>()
constructor(private readonly segpayProvider: SegpayProvider) {}
@Post()
@HttpCode(200)
async handleWebhook(
@Req() req: RawBodyRequest<Request>,
@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<void> {
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<string, unknown>): Promise<void> {
this.logger.log(`Subscription created: ${data.subscriptionId}, user: ${data.userId}`)
// TODO: Update subscription in database
}
private async handleSubscriptionCancelled(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Subscription cancelled: ${data.subscriptionId}`)
// TODO: Update subscription status in database
}
private async handleSubscriptionRenewed(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Subscription renewed: ${data.subscriptionId}`)
// TODO: Update subscription period in database
}
private async handlePaymentSucceeded(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Payment succeeded: ${data.transactionId}, amount: $${data.amount}`)
// TODO: Update transaction status, credit user account
}
private async handlePaymentFailed(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Payment failed: ${data.transactionId}, reason: ${data.failureReason}`)
// TODO: Update transaction status, notify user
}
private async handleGiftCardPurchased(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Gift card purchased: ${data.giftCardId}, amount: $${data.amount}`)
// TODO: Generate redeem code, send email
}
private async handleChargebackCreated(data: Record<string, unknown>): Promise<void> {
this.logger.warn(
`Chargeback created: ${data.chargebackId}, transaction: ${data.transactionId}, amount: $${data.amount}`,
)
// TODO: Flag transaction, notify admin
}
private async handleChargebackWon(data: Record<string, unknown>): Promise<void> {
this.logger.log(`Chargeback won: ${data.chargebackId}`)
// TODO: Update chargeback status
}
private async handleChargebackLost(data: Record<string, unknown>): Promise<void> {
this.logger.warn(`Chargeback lost: ${data.chargebackId}`)
// TODO: Update chargeback status, adjust payout
}
}

View file

@ -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 {}

View file

@ -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"]
}