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:
parent
c6f2f6d878
commit
1e42ee93c1
16 changed files with 1464 additions and 0 deletions
43
features/payments/backend/package.json
Normal file
43
features/payments/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
351
features/payments/backend/src/gift-cards/gift-cards.service.ts
Normal file
351
features/payments/backend/src/gift-cards/gift-cards.service.ts
Normal 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 }
|
||||
}
|
||||
}
|
||||
3
features/payments/backend/src/gift-cards/index.ts
Normal file
3
features/payments/backend/src/gift-cards/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './gift-cards.controller'
|
||||
export * from './gift-cards.module'
|
||||
export * from './gift-cards.service'
|
||||
11
features/payments/backend/src/index.ts
Normal file
11
features/payments/backend/src/index.ts
Normal 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'
|
||||
34
features/payments/backend/src/payments.module.ts
Normal file
34
features/payments/backend/src/payments.module.ts
Normal 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 {}
|
||||
4
features/payments/backend/src/providers/index.ts
Normal file
4
features/payments/backend/src/providers/index.ts
Normal 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'
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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]
|
||||
}
|
||||
}
|
||||
21
features/payments/backend/src/providers/providers.module.ts
Normal file
21
features/payments/backend/src/providers/providers.module.ts
Normal 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 {}
|
||||
|
|
@ -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'
|
||||
}
|
||||
}
|
||||
2
features/payments/backend/src/webhooks/index.ts
Normal file
2
features/payments/backend/src/webhooks/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './segpay.webhook.controller'
|
||||
export * from './webhooks.module'
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
18
features/payments/backend/src/webhooks/webhooks.module.ts
Normal file
18
features/payments/backend/src/webhooks/webhooks.module.ts
Normal 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 {}
|
||||
24
features/payments/backend/tsconfig.json
Normal file
24
features/payments/backend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue