diff --git a/.env.example b/.env.example index e4a935626..da31fab37 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,10 @@ POSTGRES_PASSWORD= ACCESS_TOKEN_SALT= DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?connect_timeout=300&sslmode=prefer JWT_SECRET_KEY= + +# PayTheFly Crypto Payment Integration +# https://paythefly.com - Accept crypto payments on BSC & TRON +PAYTHEFLY_PROJECT_ID= +PAYTHEFLY_PROJECT_KEY= +PAYTHEFLY_PRIVATE_KEY= +PAYTHEFLY_CHAIN_ID=56 diff --git a/apps/api/src/app/subscription/paythefly/paythefly.controller.ts b/apps/api/src/app/subscription/paythefly/paythefly.controller.ts new file mode 100644 index 000000000..715fdf570 --- /dev/null +++ b/apps/api/src/app/subscription/paythefly/paythefly.controller.ts @@ -0,0 +1,94 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { + Body, + Controller, + HttpCode, + HttpException, + Logger, + Post, + Res +} from '@nestjs/common'; +import { Response } from 'express'; +import { StatusCodes, getReasonPhrase } from 'http-status-codes'; + +import { + PayTheFlyService, + PayTheFlyWebhookBody +} from './paythefly.service'; + +/** + * PayTheFly Webhook Controller + * + * Handles incoming webhook notifications from PayTheFly. + * + * Webhook body format: + * { + * "data": "", + * "sign": "", + * "timestamp": + * } + * + * IMPORTANT: Response must contain "success" string for PayTheFly + * to mark the notification as delivered. + */ +@Controller('subscription/paythefly') +export class PayTheFlyController { + private readonly logger = new Logger(PayTheFlyController.name); + + public constructor( + private readonly configurationService: ConfigurationService, + private readonly payTheFlyService: PayTheFlyService + ) {} + + /** + * Handle PayTheFly webhook notification. + * + * Verifies HMAC-SHA256 signature and processes the payment event. + * Response MUST contain "success" for PayTheFly to acknowledge delivery. + */ + @Post('webhook') + @HttpCode(StatusCodes.OK) + public async handleWebhook( + @Body() body: PayTheFlyWebhookBody, + @Res() response: Response + ) { + try { + const payload = this.payTheFlyService.parseWebhookPayload(body); + + if (this.payTheFlyService.isPaymentConfirmed(payload)) { + this.logger.log( + `PayTheFly payment confirmed: serial_no=${payload.serial_no}, ` + + `value=${payload.value}, tx_hash=${payload.tx_hash}, ` + + `wallet=${payload.wallet}` + ); + + // TODO: Update subscription status based on serial_no + // await this.subscriptionService.confirmPayment(payload.serial_no); + } else if (payload.tx_type === 2) { + this.logger.log( + `PayTheFly withdrawal event: serial_no=${payload.serial_no}, ` + + `tx_hash=${payload.tx_hash}` + ); + } else { + this.logger.warn( + `PayTheFly unconfirmed payment: serial_no=${payload.serial_no}, ` + + `confirmed=${payload.confirmed}` + ); + } + + // PayTheFly requires response to contain "success" + return response.send('success'); + } catch (error) { + this.logger.error( + `PayTheFly webhook error: ${error.message}`, + error.stack + ); + + throw new HttpException( + getReasonPhrase(StatusCodes.BAD_REQUEST), + StatusCodes.BAD_REQUEST + ); + } + } +} diff --git a/apps/api/src/app/subscription/paythefly/paythefly.module.ts b/apps/api/src/app/subscription/paythefly/paythefly.module.ts new file mode 100644 index 000000000..c3ecaf2fb --- /dev/null +++ b/apps/api/src/app/subscription/paythefly/paythefly.module.ts @@ -0,0 +1,14 @@ +import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; + +import { Module } from '@nestjs/common'; + +import { PayTheFlyController } from './paythefly.controller'; +import { PayTheFlyService } from './paythefly.service'; + +@Module({ + controllers: [PayTheFlyController], + exports: [PayTheFlyService], + imports: [ConfigurationModule], + providers: [PayTheFlyService] +}) +export class PayTheFlyModule {} diff --git a/apps/api/src/app/subscription/paythefly/paythefly.service.ts b/apps/api/src/app/subscription/paythefly/paythefly.service.ts new file mode 100644 index 000000000..f5cdc02e6 --- /dev/null +++ b/apps/api/src/app/subscription/paythefly/paythefly.service.ts @@ -0,0 +1,189 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; + +import { Injectable, Logger } from '@nestjs/common'; +import { createHmac, timingSafeEqual } from 'crypto'; + +/** + * PayTheFly Crypto Payment Service + * + * Handles crypto payment integration with PayTheFly for BSC and TRON chains. + * Uses EIP-712 typed structured data signing for payment authentication. + * + * Supported chains: + * - BSC (chainId=56, 18 decimals) + * - TRON (chainId=728126428, 6 decimals) + * + * IMPORTANT: Uses Keccak-256 for hashing, NEVER SHA3-256. + */ + +/** + * PayTheFly webhook body format. + * + * @example + * { + * "data": "{\"serial_no\":\"...\",\"value\":\"10.00\",...}", + * "sign": "hmac_hex_signature", + * "timestamp": 1709312400 + * } + */ +export interface PayTheFlyWebhookBody { + data: string; // JSON stringified payload + sign: string; // HMAC-SHA256 hex signature + timestamp: number; // Unix timestamp +} + +/** + * PayTheFly webhook payload (decoded from data field). + * + * Note: Uses 'value' not 'amount', and 'confirmed' not 'status'. + */ +export interface PayTheFlyWebhookPayload { + value: string; // Payment amount (NOT "amount") + confirmed: boolean; // Payment confirmed (NOT "status") + serial_no: string; // Order serial number + tx_hash: string; // Blockchain transaction hash + wallet: string; // Payer's wallet address + tx_type: number; // 1=payment, 2=withdrawal +} + +export interface PayTheFlyPaymentUrl { + url: string; + serialNo: string; + deadline: number; +} + +@Injectable() +export class PayTheFlyService { + private readonly logger = new Logger(PayTheFlyService.name); + + public constructor( + private readonly configurationService: ConfigurationService + ) {} + + /** + * Generate a PayTheFly payment URL. + * + * Payment link format: + * https://pro.paythefly.com/pay?chainId=56&projectId=xxx&amount=0.01&serialNo=xxx&deadline=xxx&signature=0x...&token=0x... + * + * @param amount Human-readable amount (e.g., "10.00"), NOT raw token units + * @param serialNo Unique order reference + * @param tokenAddress Token contract address + */ + public generatePaymentUrl({ + amount, + serialNo, + tokenAddress + }: { + amount: string; + serialNo: string; + tokenAddress: string; + }): PayTheFlyPaymentUrl { + const projectId = this.configurationService.get('PAYTHEFLY_PROJECT_ID'); + const chainId = this.configurationService.get('PAYTHEFLY_CHAIN_ID'); + const deadline = Math.floor(Date.now() / 1000) + 1800; // 30 min + + // Note: The EIP-712 signature must be generated server-side + // using the private key. The actual signing requires ethers.js + // or a similar library with EIP-712 support. + // + // EIP-712 Domain: { name: 'PayTheFlyPro', version: '1' } + // PaymentRequest struct: { projectId, token, amount, serialNo, deadline } + + const params = new URLSearchParams({ + chainId: String(chainId), + projectId, + amount, // Human-readable, NOT raw units + serialNo, + deadline: String(deadline), + token: tokenAddress + // signature: '0x...' — Must be appended after EIP-712 signing + }); + + return { + url: `https://pro.paythefly.com/pay?${params.toString()}`, + serialNo, + deadline + }; + } + + /** + * Verify a PayTheFly webhook signature. + * + * Signature: HMAC-SHA256(data + "." + timestamp, projectKey) + * Uses timing-safe comparison to prevent timing attacks. + * + * @throws Error if signature is invalid + */ + public verifyWebhookSignature(body: PayTheFlyWebhookBody): void { + const projectKey = this.configurationService.get('PAYTHEFLY_PROJECT_KEY'); + + const message = `${body.data}.${body.timestamp}`; + const expectedSign = createHmac('sha256', projectKey) + .update(message) + .digest('hex'); + + const expectedBuffer = Buffer.from(expectedSign, 'utf-8'); + const receivedBuffer = Buffer.from(body.sign, 'utf-8'); + + // Timing-safe comparison to prevent timing attacks + if ( + expectedBuffer.length !== receivedBuffer.length || + !timingSafeEqual(expectedBuffer, receivedBuffer) + ) { + throw new Error('Invalid PayTheFly webhook signature'); + } + } + + /** + * Parse and validate a PayTheFly webhook payload. + * + * Webhook payload fields: + * - value (NOT "amount") + * - confirmed (NOT "status") + * - serial_no, tx_hash, wallet, tx_type + * + * tx_type: 1=payment, 2=withdrawal + */ + public parseWebhookPayload( + body: PayTheFlyWebhookBody + ): PayTheFlyWebhookPayload { + this.verifyWebhookSignature(body); + + let payload: PayTheFlyWebhookPayload; + + try { + payload = JSON.parse(body.data); + } catch { + throw new Error('Invalid PayTheFly webhook data JSON'); + } + + // Validate required fields + if ( + !payload.serial_no || + payload.value === undefined || + payload.confirmed === undefined || + !payload.tx_hash || + !payload.wallet || + payload.tx_type === undefined + ) { + throw new Error( + 'Missing required fields in PayTheFly webhook payload. ' + + 'Expected: serial_no, value, confirmed, tx_hash, wallet, tx_type' + ); + } + + return payload; + } + + /** + * Check if a webhook payload represents a confirmed payment. + * + * A payment is confirmed when: + * - confirmed === true + * - tx_type === 1 (payment, not withdrawal) + */ + public isPaymentConfirmed(payload: PayTheFlyWebhookPayload): boolean { + return payload.confirmed === true && payload.tx_type === 1; + } +} diff --git a/apps/api/src/app/subscription/subscription.module.ts b/apps/api/src/app/subscription/subscription.module.ts index bf4bba7b7..4f250f38f 100644 --- a/apps/api/src/app/subscription/subscription.module.ts +++ b/apps/api/src/app/subscription/subscription.module.ts @@ -4,13 +4,14 @@ import { PropertyModule } from '@ghostfolio/api/services/property/property.modul import { Module } from '@nestjs/common'; +import { PayTheFlyModule } from './paythefly/paythefly.module'; import { SubscriptionController } from './subscription.controller'; import { SubscriptionService } from './subscription.service'; @Module({ controllers: [SubscriptionController], exports: [SubscriptionService], - imports: [ConfigurationModule, PrismaModule, PropertyModule], + imports: [ConfigurationModule, PayTheFlyModule, PrismaModule, PropertyModule], providers: [SubscriptionService] }) export class SubscriptionModule {} diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 5f9d1055d..d33287681 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -103,6 +103,10 @@ export class ConfigurationService { default: environment.rootUrl }), STRIPE_SECRET_KEY: str({ default: '' }), + PAYTHEFLY_PROJECT_ID: str({ default: '' }), + PAYTHEFLY_PROJECT_KEY: str({ default: '' }), + PAYTHEFLY_PRIVATE_KEY: str({ default: '' }), + PAYTHEFLY_CHAIN_ID: num({ default: 56 }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 57c58898e..d68751493 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -53,6 +53,10 @@ export interface Environment extends CleanedEnvAccessors { REQUEST_TIMEOUT: number; ROOT_URL: string; STRIPE_SECRET_KEY: string; + PAYTHEFLY_PROJECT_ID: string; + PAYTHEFLY_PROJECT_KEY: string; + PAYTHEFLY_PRIVATE_KEY: string; + PAYTHEFLY_CHAIN_ID: number; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string;