TONS code - stripe integration

This commit is contained in:
2025-09-20 16:37:37 +02:00
parent 7a868d7f14
commit e75424aac0
34 changed files with 5273 additions and 8 deletions
+11 -3
View File
@@ -35,12 +35,18 @@ import {$log} from "@tsed/logger";
version: process.env.APP_VERSION || "1.0.0",
description:
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" +
"Includes endpoints for auth, users, jobs, tokens, AI-powered interviews (OpenRouter/Ollama), and admin reporting.\n\n" +
"Includes endpoints for auth, users, jobs, tokens, AI-powered interviews (OpenRouter/Ollama), payment processing, and admin reporting.\n\n" +
"AI Features:\n" +
"- OpenRouter integration for cloud-based AI interviews\n" +
"- Ollama support for local AI processing\n" +
"- Test mode for admin interview testing\n" +
"- Mandatory question support before AI interviews",
"- Mandatory question support before AI interviews\n\n" +
"Payment Features:\n" +
"- Stripe integration for secure payments\n" +
"- Support for credit cards, iDEAL, and bank transfers\n" +
"- Dynamic token pricing with package discounts\n" +
"- Custom token quantity purchases\n" +
"- Webhook-based payment confirmation",
contact: {
name: "Candivista Team",
url: "https://candivista.com",
@@ -56,7 +62,9 @@ import {$log} from "@tsed/logger";
{ name: "Users", description: "User profile and token summary" },
{ name: "Jobs", description: "Job posting and interview token operations" },
{ name: "Admin", description: "Administrative statistics and management" },
{ name: "AI", description: "AI-powered interview operations with OpenRouter and Ollama support" }
{ name: "AI", description: "AI-powered interview operations with OpenRouter and Ollama support" },
{ name: "Payments", description: "Stripe payment processing for token purchases" },
{ name: "Webhooks", description: "Stripe webhook handlers for payment events" }
],
components: {
securitySchemes: {
@@ -0,0 +1,441 @@
import { Controller } from "@tsed/di";
import { Get, Post, Put, Delete, Tags, Summary, Description, Returns, Security } from "@tsed/schema";
import { BodyParams, PathParams, QueryParams } from "@tsed/platform-params";
import { Req } from "@tsed/platform-http";
import { BadRequest, Unauthorized, NotFound } from "@tsed/exceptions";
import jwt from "jsonwebtoken";
import { PaymentService, CreatePaymentRequest } from "../../services/PaymentService.js";
import { StripeService } from "../../services/StripeService.js";
import { UserService } from "../../services/UserService.js";
import { pool } from "../../config/database.js";
const JWT_SECRET = process.env.JWT_SECRET || "your-secret-key";
@Controller("/payments")
@Tags("Payments")
export class PaymentController {
private paymentService = new PaymentService();
private stripeService = new StripeService();
private userService = new UserService();
// Middleware to check if user is authenticated
private async checkAuth(req: any) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) {
throw new Unauthorized("No token provided");
}
try {
const decoded = jwt.verify(token, JWT_SECRET) as any;
const user = await this.userService.getUserById(decoded.userId);
if (!user) {
throw new Unauthorized("User not found");
}
return user;
} catch (error) {
throw new Unauthorized("Invalid token");
}
}
// Middleware to check if user is admin
private async checkAdmin(req: any) {
const user = await this.checkAuth(req);
if (user.role !== 'admin') {
throw new Unauthorized("Admin access required");
}
return user;
}
/**
* Calculate token price for a given quantity
*/
@Post("/calculate-price")
@Security("bearerAuth")
@Summary("Calculate token price")
@Description("Calculate the best price for a given quantity of tokens")
@Returns(200).Description("Price calculation returned")
@Returns(401).Description("Unauthorized")
@Returns(400).Description("Invalid request")
async calculatePrice(
@Req() req: any,
@BodyParams() body: { quantity: number; packageId?: string }
) {
try {
await this.checkAuth(req);
if (!body.quantity || body.quantity <= 0) {
throw new BadRequest("Quantity must be a positive number");
}
const calculation = await this.paymentService.calculateTokenPrice(
body.quantity,
body.packageId
);
return {
success: true,
calculation,
};
} catch (error: any) {
throw error;
}
}
/**
* Create a payment intent
*/
@Post("/create-intent")
@Security("bearerAuth")
@Summary("Create payment intent")
@Description("Create a Stripe payment intent for token purchase")
@Returns(200).Description("Payment intent created")
@Returns(401).Description("Unauthorized")
@Returns(400).Description("Invalid request")
async createPaymentIntent(
@Req() req: any,
@BodyParams() body: {
packageId?: string;
customQuantity?: number;
paymentFlowType: 'card' | 'ideal' | 'bank_transfer';
}
) {
try {
const user = await this.checkAuth(req);
if (!body.paymentFlowType) {
throw new BadRequest("Payment flow type is required");
}
if (!body.packageId && !body.customQuantity) {
throw new BadRequest("Either packageId or customQuantity is required");
}
if (body.customQuantity && (body.customQuantity <= 0 || body.customQuantity > 1000)) {
throw new BadRequest("Custom quantity must be between 1 and 1000");
}
const request: CreatePaymentRequest = {
userId: user.id,
packageId: body.packageId,
customQuantity: body.customQuantity,
paymentFlowType: body.paymentFlowType,
userEmail: user.email,
userName: `${user.first_name} ${user.last_name}`,
};
const result = await this.paymentService.createPaymentIntent(request);
return {
success: true,
paymentIntent: {
id: result.paymentIntent.id,
client_secret: result.paymentIntent.client_secret,
status: result.paymentIntent.status,
},
paymentRecord: {
id: result.paymentRecord.id,
amount: result.paymentRecord.amount,
currency: result.paymentRecord.currency,
status: result.paymentRecord.status,
},
calculation: result.calculation,
};
} catch (error: any) {
throw error;
}
}
/**
* Confirm payment completion
*/
@Post("/confirm")
@Security("bearerAuth")
@Summary("Confirm payment")
@Description("Confirm payment completion and allocate tokens")
@Returns(200).Description("Payment confirmed")
@Returns(401).Description("Unauthorized")
@Returns(400).Description("Invalid request")
async confirmPayment(
@Req() req: any,
@BodyParams() body: { paymentIntentId: string }
) {
try {
const user = await this.checkAuth(req);
if (!body.paymentIntentId) {
throw new BadRequest("Payment intent ID is required");
}
// Get payment intent from Stripe
const paymentIntent = await this.stripeService.getPaymentIntent(body.paymentIntentId);
if (paymentIntent.status === 'succeeded') {
// Process successful payment
const paymentRecord = await this.paymentService.processSuccessfulPayment(body.paymentIntentId);
return {
success: true,
message: "Payment confirmed successfully",
paymentRecord: {
id: paymentRecord.id,
amount: paymentRecord.amount,
currency: paymentRecord.currency,
status: paymentRecord.status,
tokensAllocated: paymentRecord.custom_quantity || 1,
},
};
} else if (paymentIntent.status === 'requires_action') {
return {
success: false,
requires_action: true,
message: "Payment requires additional action",
payment_intent: {
id: paymentIntent.id,
status: paymentIntent.status,
client_secret: paymentIntent.client_secret,
},
};
} else {
throw new BadRequest(`Payment not successful. Status: ${paymentIntent.status}`);
}
} catch (error: any) {
throw error;
}
}
/**
* Get available payment methods
*/
@Get("/methods")
@Security("bearerAuth")
@Summary("Get payment methods")
@Description("Get available payment methods for the user's region")
@Returns(200).Description("Payment methods returned")
@Returns(401).Description("Unauthorized")
async getPaymentMethods(
@Req() req: any,
@QueryParams("country") countryCode?: string
) {
try {
await this.checkAuth(req);
const paymentMethods = this.stripeService.getAvailablePaymentMethods(countryCode);
const idealConfig = countryCode === 'NL' ? this.stripeService.getIdealConfiguration() : null;
return {
success: true,
paymentMethods,
ideal: idealConfig,
};
} catch (error: any) {
throw error;
}
}
/**
* Get user payment history
*/
@Get("/history")
@Security("bearerAuth")
@Summary("Get payment history")
@Description("Get payment history for the authenticated user")
@Returns(200).Description("Payment history returned")
@Returns(401).Description("Unauthorized")
async getPaymentHistory(@Req() req: any) {
try {
const user = await this.checkAuth(req);
const payments = await this.paymentService.getUserPaymentHistory(user.id);
return {
success: true,
payments: payments.map(payment => ({
id: payment.id,
amount: payment.amount,
currency: payment.currency,
status: payment.status,
payment_flow_type: payment.payment_flow_type,
custom_quantity: payment.custom_quantity,
applied_discount_percentage: payment.applied_discount_percentage,
package_name: payment.package_name,
created_at: payment.created_at,
paid_at: payment.paid_at,
})),
};
} catch (error: any) {
throw error;
}
}
/**
* Get specific payment details
*/
@Get("/:id")
@Security("bearerAuth")
@Summary("Get payment details")
@Description("Get details of a specific payment")
@Returns(200).Description("Payment details returned")
@Returns(401).Description("Unauthorized")
@Returns(404).Description("Payment not found")
async getPaymentDetails(
@Req() req: any,
@PathParams("id") paymentId: string
) {
try {
const user = await this.checkAuth(req);
const payment = await this.paymentService.getPaymentById(paymentId);
if (!payment) {
throw new NotFound("Payment not found");
}
// Check if user owns this payment or is admin
if (payment.user_id !== user.id && user.role !== 'admin') {
throw new Unauthorized("Access denied");
}
return {
success: true,
payment: {
id: payment.id,
amount: payment.amount,
currency: payment.currency,
status: payment.status,
payment_flow_type: payment.payment_flow_type,
custom_quantity: payment.custom_quantity,
applied_discount_percentage: payment.applied_discount_percentage,
package_name: payment.package_name,
stripe_payment_intent_id: payment.stripe_payment_intent_id,
created_at: payment.created_at,
paid_at: payment.paid_at,
refunded_amount: payment.refunded_amount,
refund_reason: payment.refund_reason,
},
};
} catch (error: any) {
throw error;
}
}
/**
* Process refund (Admin only)
*/
@Post("/:id/refund")
@Security("bearerAuth")
@Summary("Process refund")
@Description("Process a refund for a payment (Admin only)")
@Returns(200).Description("Refund processed")
@Returns(401).Description("Unauthorized")
@Returns(404).Description("Payment not found")
async processRefund(
@Req() req: any,
@PathParams("id") paymentId: string,
@BodyParams() body: { amount?: number; reason?: string }
) {
try {
await this.checkAdmin(req);
const refund = await this.paymentService.processRefund(
paymentId,
body.amount,
body.reason
);
return {
success: true,
message: "Refund processed successfully",
refund: {
id: refund.id,
amount: refund.amount,
status: refund.status,
reason: refund.reason,
},
};
} catch (error: any) {
throw error;
}
}
/**
* Get payment statistics (Admin only)
*/
@Get("/admin/statistics")
@Security("bearerAuth")
@Summary("Get payment statistics")
@Description("Get payment statistics (Admin only)")
@Returns(200).Description("Statistics returned")
@Returns(401).Description("Unauthorized")
async getPaymentStatistics(@Req() req: any) {
try {
await this.checkAdmin(req);
const statistics = await this.paymentService.getPaymentStatistics();
return {
success: true,
statistics,
};
} catch (error: any) {
throw error;
}
}
/**
* Cancel payment intent
*/
@Post("/:id/cancel")
@Security("bearerAuth")
@Summary("Cancel payment")
@Description("Cancel a pending payment intent")
@Returns(200).Description("Payment cancelled")
@Returns(401).Description("Unauthorized")
@Returns(404).Description("Payment not found")
async cancelPayment(
@Req() req: any,
@PathParams("id") paymentId: string
) {
try {
const user = await this.checkAuth(req);
const payment = await this.paymentService.getPaymentById(paymentId);
if (!payment) {
throw new NotFound("Payment not found");
}
if (payment.user_id !== user.id) {
throw new Unauthorized("Access denied");
}
if (payment.status !== 'pending') {
throw new BadRequest("Only pending payments can be cancelled");
}
if (payment.stripe_payment_intent_id) {
await this.stripeService.cancelPaymentIntent(payment.stripe_payment_intent_id);
}
// Update payment record
const connection = await pool.getConnection();
await connection.execute(`
UPDATE payment_records
SET status = 'cancelled', updated_at = NOW()
WHERE id = ?
`, [paymentId]);
connection.release();
return {
success: true,
message: "Payment cancelled successfully",
};
} catch (error: any) {
throw error;
}
}
}
@@ -0,0 +1,290 @@
import { Controller } from "@tsed/di";
import { Post, Tags, Summary, Description, Returns } from "@tsed/schema";
import { Req, Res } from "@tsed/platform-http";
import { $log } from "@tsed/logger";
import { PaymentService } from "../../services/PaymentService.js";
import { StripeService } from "../../services/StripeService.js";
import { UserService } from "../../services/UserService.js";
import { pool } from "../../config/database.js";
@Controller("/webhooks")
@Tags("Webhooks")
export class WebhookController {
private paymentService = new PaymentService();
private stripeService = new StripeService();
private userService = new UserService();
/**
* Handle Stripe webhooks
*/
@Post("/stripe")
@Summary("Stripe webhook handler")
@Description("Handle Stripe webhook events for payment processing")
@Returns(200).Description("Webhook processed successfully")
@Returns(400).Description("Invalid webhook signature")
async handleStripeWebhook(@Req() req: any, @Res() res: any) {
const sig = req.headers['stripe-signature'];
const payload = req.body;
try {
// Verify webhook signature
const event = this.stripeService.verifyWebhookSignature(payload, sig);
$log.info(`Processing Stripe webhook: ${event.type}, ID: ${event.id}`);
// Handle the event
switch (event.type) {
case 'payment_intent.succeeded':
await this.handlePaymentIntentSucceeded(event.data.object);
break;
case 'payment_intent.payment_failed':
await this.handlePaymentIntentFailed(event.data.object);
break;
case 'payment_intent.cancelled':
await this.handlePaymentIntentCancelled(event.data.object);
break;
case 'charge.dispute.created':
await this.handleChargeDisputeCreated(event.data.object);
break;
case 'invoice.payment_succeeded':
await this.handleInvoicePaymentSucceeded(event.data.object);
break;
case 'customer.created':
await this.handleCustomerCreated(event.data.object);
break;
default:
$log.info(`Unhandled event type: ${event.type}`);
}
res.status(200).json({ received: true });
} catch (error: any) {
$log.error('Webhook signature verification failed:', error);
res.status(400).json({ error: 'Invalid webhook signature' });
}
}
/**
* Handle successful payment intent
*/
private async handlePaymentIntentSucceeded(paymentIntent: any) {
try {
$log.info(`Payment succeeded: ${paymentIntent.id}`);
// Process successful payment
const paymentRecord = await this.paymentService.processSuccessfulPayment(paymentIntent.id);
// Send confirmation email (if needed)
await this.sendPaymentConfirmationEmail(paymentRecord);
$log.info(`Successfully processed payment: ${paymentIntent.id} for user: ${paymentRecord.user_id}`);
} catch (error) {
$log.error(`Error processing successful payment ${paymentIntent.id}:`, error);
}
}
/**
* Handle failed payment intent
*/
private async handlePaymentIntentFailed(paymentIntent: any) {
try {
$log.info(`Payment failed: ${paymentIntent.id}`);
// Process failed payment
const paymentRecord = await this.paymentService.processFailedPayment(
paymentIntent.id,
paymentIntent.last_payment_error?.message || 'Payment failed'
);
// Send failure notification (if needed)
await this.sendPaymentFailureEmail(paymentRecord);
$log.info(`Successfully processed failed payment: ${paymentIntent.id}`);
} catch (error) {
$log.error(`Error processing failed payment ${paymentIntent.id}:`, error);
}
}
/**
* Handle cancelled payment intent
*/
private async handlePaymentIntentCancelled(paymentIntent: any) {
try {
$log.info(`Payment cancelled: ${paymentIntent.id}`);
// Update payment record status
const connection = await pool.getConnection();
await connection.execute(`
UPDATE payment_records
SET status = 'cancelled', updated_at = NOW()
WHERE stripe_payment_intent_id = ?
`, [paymentIntent.id]);
connection.release();
$log.info(`Successfully processed cancelled payment: ${paymentIntent.id}`);
} catch (error) {
$log.error(`Error processing cancelled payment ${paymentIntent.id}:`, error);
}
}
/**
* Handle charge dispute created
*/
private async handleChargeDisputeCreated(dispute: any) {
try {
$log.warn(`Charge dispute created: ${dispute.id} for charge: ${dispute.charge}`);
// Update payment record with dispute information
const connection = await pool.getConnection();
await connection.execute(`
UPDATE payment_records
SET stripe_metadata = JSON_SET(
COALESCE(stripe_metadata, '{}'),
'$.dispute_id',
?
), updated_at = NOW()
WHERE stripe_payment_intent_id = (
SELECT id FROM stripe_payment_intents WHERE charge_id = ?
)
`, [dispute.id, dispute.charge]);
connection.release();
// Send dispute notification to admin
await this.sendDisputeNotificationEmail(dispute);
$log.info(`Successfully processed dispute: ${dispute.id}`);
} catch (error) {
$log.error(`Error processing dispute ${dispute.id}:`, error);
}
}
/**
* Handle invoice payment succeeded (for recurring payments if implemented)
*/
private async handleInvoicePaymentSucceeded(invoice: any) {
try {
$log.info(`Invoice payment succeeded: ${invoice.id}`);
// This would be used for recurring payments if implemented in the future
// For now, we'll just log it
$log.info(`Successfully processed invoice payment: ${invoice.id}`);
} catch (error) {
$log.error(`Error processing invoice payment ${invoice.id}:`, error);
}
}
/**
* Handle customer created
*/
private async handleCustomerCreated(customer: any) {
try {
$log.info(`Customer created: ${customer.id}`);
// Update user record with Stripe customer ID if needed
if (customer.metadata?.userId) {
const connection = await pool.getConnection();
await connection.execute(`
UPDATE users
SET stripe_customer_id = ?, updated_at = NOW()
WHERE id = ?
`, [customer.id, customer.metadata.userId]);
connection.release();
}
$log.info(`Successfully processed customer creation: ${customer.id}`);
} catch (error) {
$log.error(`Error processing customer creation ${customer.id}:`, error);
}
}
/**
* Send payment confirmation email
*/
private async sendPaymentConfirmationEmail(paymentRecord: any) {
try {
// Get user details
const user = await this.userService.getUserById(paymentRecord.user_id);
if (!user) {
$log.error(`User not found for payment confirmation: ${paymentRecord.user_id}`);
return;
}
// TODO: Implement email service
// For now, just log the confirmation
$log.info(`Payment confirmation email would be sent to: ${user.email} for payment: ${paymentRecord.id}`);
// Email content would include:
// - Payment amount
// - Tokens allocated
// - Payment method used
// - Receipt information
} catch (error) {
$log.error('Error sending payment confirmation email:', error);
}
}
/**
* Send payment failure email
*/
private async sendPaymentFailureEmail(paymentRecord: any) {
try {
// Get user details
const user = await this.userService.getUserById(paymentRecord.user_id);
if (!user) {
$log.error(`User not found for payment failure notification: ${paymentRecord.user_id}`);
return;
}
// TODO: Implement email service
// For now, just log the failure notification
$log.info(`Payment failure email would be sent to: ${user.email} for payment: ${paymentRecord.id}`);
// Email content would include:
// - Payment amount
// - Failure reason
// - Retry instructions
// - Support contact information
} catch (error) {
$log.error('Error sending payment failure email:', error);
}
}
/**
* Send dispute notification email to admin
*/
private async sendDisputeNotificationEmail(dispute: any) {
try {
// TODO: Implement email service for admin notifications
$log.warn(`Dispute notification email would be sent to admin for dispute: ${dispute.id}`);
// Email content would include:
// - Dispute details
// - Charge information
// - Customer information
// - Required actions
} catch (error) {
$log.error('Error sending dispute notification email:', error);
}
}
/**
* Health check endpoint for webhook
*/
@Post("/stripe/health")
@Summary("Stripe webhook health check")
@Description("Health check endpoint for Stripe webhook")
@Returns(200).Description("Webhook is healthy")
async healthCheck(@Req() req: any, @Res() res: any) {
res.status(200).json({
status: 'healthy',
timestamp: new Date().toISOString(),
service: 'stripe-webhook'
});
}
}
+479
View File
@@ -0,0 +1,479 @@
import { pool } from '../config/database.js';
import { $log } from '@tsed/logger';
import { randomUUID } from 'crypto';
import { StripeService, PaymentIntentData, CustomerData } from './StripeService.js';
import { TokenService } from './TokenService.js';
export interface CreatePaymentRequest {
userId: string;
packageId?: string;
customQuantity?: number;
paymentFlowType: 'card' | 'ideal' | 'bank_transfer';
userEmail: string;
userName: string;
}
export interface PaymentRecord {
id: string;
user_id: string;
token_package_id?: string;
amount: number;
currency: string;
status: string;
payment_method?: string;
payment_reference?: string;
invoice_url?: string;
paid_at?: string;
created_at: string;
updated_at: string;
stripe_payment_intent_id?: string;
stripe_payment_method_id?: string;
stripe_customer_id?: string;
payment_flow_type: string;
stripe_metadata?: any;
refund_reason?: string;
refunded_amount?: number;
custom_quantity?: number;
applied_discount_percentage?: number;
first_name?: string;
last_name?: string;
email?: string;
package_name?: string;
}
export interface PaymentCalculation {
quantity: number;
basePrice: number;
discountPercentage: number;
finalPrice: number;
savings: number;
packageId?: string;
packageName?: string;
}
export class PaymentService {
private stripeService: StripeService;
private tokenService: TokenService;
constructor() {
this.stripeService = new StripeService();
this.tokenService = new TokenService();
}
/**
* Calculate the best price for a given quantity of tokens
*/
async calculateTokenPrice(quantity: number, packageId?: string): Promise<PaymentCalculation> {
const connection = await pool.getConnection();
try {
// If a specific package is selected, use its pricing
if (packageId) {
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE id = ? AND is_active = 1',
[packageId]
);
if (Array.isArray(rows) && rows.length > 0) {
const pkg = rows[0] as any;
const basePrice = quantity * pkg.price_per_token;
const discountAmount = (basePrice * pkg.discount_percentage) / 100;
const finalPrice = basePrice - discountAmount;
return {
quantity,
basePrice,
discountPercentage: pkg.discount_percentage,
finalPrice,
savings: discountAmount,
packageId: pkg.id,
packageName: pkg.name,
};
}
}
// Find the best package for the given quantity
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE is_active = 1 ORDER BY quantity ASC'
);
const packages = Array.isArray(rows) ? rows as any[] : [];
if (packages.length === 0) {
// No packages available, use base price
const basePrice = quantity * 5.00; // Default price per token
return {
quantity,
basePrice,
discountPercentage: 0,
finalPrice: basePrice,
savings: 0,
};
}
// Find the package that gives the best discount for this quantity
let bestPackage = null;
let bestPrice = quantity * 5.00; // Default base price
let bestDiscount = 0;
let bestSavings = 0;
for (const pkg of packages) {
if (quantity >= pkg.quantity) {
const basePrice = quantity * pkg.price_per_token;
const discountAmount = (basePrice * pkg.discount_percentage) / 100;
const finalPrice = basePrice - discountAmount;
if (finalPrice < bestPrice) {
bestPackage = pkg;
bestPrice = finalPrice;
bestDiscount = pkg.discount_percentage;
bestSavings = discountAmount;
}
}
}
return {
quantity,
basePrice: quantity * 5.00,
discountPercentage: bestDiscount,
finalPrice: bestPrice,
savings: bestSavings,
packageId: bestPackage?.id,
packageName: bestPackage?.name,
};
} catch (error) {
$log.error('Error calculating token price:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Create a payment intent for token purchase
*/
async createPaymentIntent(request: CreatePaymentRequest): Promise<{
paymentIntent: any;
paymentRecord: PaymentRecord;
calculation: PaymentCalculation;
}> {
const connection = await pool.getConnection();
try {
// Calculate pricing
const calculation = await this.calculateTokenPrice(
request.customQuantity || 1,
request.packageId
);
// Get or create Stripe customer
const customerData: CustomerData = {
email: request.userEmail,
name: request.userName,
userId: request.userId,
};
const customer = await this.stripeService.getOrCreateCustomer(customerData);
// Create payment intent
const paymentIntentData: PaymentIntentData = {
amount: calculation.finalPrice,
currency: 'eur', // Default to EUR for European market
customerId: customer.id,
metadata: {
userId: request.userId,
quantity: calculation.quantity.toString(),
packageId: calculation.packageId || '',
packageName: calculation.packageName || '',
discountPercentage: calculation.discountPercentage.toString(),
},
paymentMethodTypes: this.stripeService.getAvailablePaymentMethods(),
};
const paymentIntent = await this.stripeService.createPaymentIntent(paymentIntentData);
// Create payment record
const paymentRecordId = randomUUID();
await connection.execute(`
INSERT INTO payment_records (
id, user_id, token_package_id, amount, currency, status,
payment_method, payment_reference, stripe_payment_intent_id,
stripe_customer_id, payment_flow_type, custom_quantity,
applied_discount_percentage, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
paymentRecordId,
request.userId,
calculation.packageId || null,
calculation.finalPrice,
'eur',
'pending',
request.paymentFlowType,
paymentIntent.id,
paymentIntent.id,
customer.id,
request.paymentFlowType,
calculation.quantity,
calculation.discountPercentage,
]);
// Get the created payment record
const [rows] = await connection.execute(
'SELECT * FROM payment_records WHERE id = ?',
[paymentRecordId]
);
const paymentRecord = Array.isArray(rows) ? rows[0] as PaymentRecord : null;
$log.info(`Created payment intent: ${paymentIntent.id} for user: ${request.userId}`);
return {
paymentIntent,
paymentRecord: paymentRecord!,
calculation,
};
} catch (error) {
$log.error('Error creating payment intent:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Process a successful payment
*/
async processSuccessfulPayment(paymentIntentId: string): Promise<PaymentRecord> {
const connection = await pool.getConnection();
try {
// Get payment record
const [rows] = await connection.execute(
'SELECT * FROM payment_records WHERE stripe_payment_intent_id = ?',
[paymentIntentId]
);
if (!Array.isArray(rows) || rows.length === 0) {
throw new Error('Payment record not found');
}
const paymentRecord = rows[0] as PaymentRecord;
// Update payment record status
await connection.execute(`
UPDATE payment_records
SET status = 'paid', paid_at = NOW(), updated_at = NOW()
WHERE stripe_payment_intent_id = ?
`, [paymentIntentId]);
// Allocate tokens to user
const quantity = paymentRecord.custom_quantity || 1;
const pricePerToken = paymentRecord.amount / quantity;
await this.tokenService.addTokensToUser(
paymentRecord.user_id,
quantity,
pricePerToken
);
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
`, [paymentRecord.user_id, quantity, quantity]);
$log.info(`Processed successful payment: ${paymentIntentId} for user: ${paymentRecord.user_id}`);
return paymentRecord;
} catch (error) {
$log.error('Error processing successful payment:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Process a failed payment
*/
async processFailedPayment(paymentIntentId: string, reason?: string): Promise<PaymentRecord> {
const connection = await pool.getConnection();
try {
// Update payment record status
await connection.execute(`
UPDATE payment_records
SET status = 'failed', updated_at = NOW()
WHERE stripe_payment_intent_id = ?
`, [paymentIntentId]);
// Get updated payment record
const [rows] = await connection.execute(
'SELECT * FROM payment_records WHERE stripe_payment_intent_id = ?',
[paymentIntentId]
);
const paymentRecord = Array.isArray(rows) ? rows[0] as PaymentRecord : null;
$log.info(`Processed failed payment: ${paymentIntentId}, reason: ${reason}`);
return paymentRecord!;
} catch (error) {
$log.error('Error processing failed payment:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get user payment history
*/
async getUserPaymentHistory(userId: string): Promise<PaymentRecord[]> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
u.first_name,
u.last_name,
u.email,
tp.name as package_name
FROM payment_records pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
WHERE pr.user_id = ?
ORDER BY pr.created_at DESC
`, [userId]);
return Array.isArray(rows) ? rows as PaymentRecord[] : [];
} catch (error) {
$log.error('Error getting user payment history:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get payment by ID
*/
async getPaymentById(paymentId: string): Promise<PaymentRecord | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
u.first_name,
u.last_name,
u.email,
tp.name as package_name
FROM payment_records pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
WHERE pr.id = ?
`, [paymentId]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as PaymentRecord;
}
return null;
} catch (error) {
$log.error('Error getting payment by ID:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Process refund
*/
async processRefund(paymentId: string, amount?: number, reason?: string): Promise<any> {
const connection = await pool.getConnection();
try {
// Get payment record
const paymentRecord = await this.getPaymentById(paymentId);
if (!paymentRecord) {
throw new Error('Payment record not found');
}
if (!paymentRecord.stripe_payment_intent_id) {
throw new Error('No Stripe payment intent found for this payment');
}
// Create refund via Stripe
const refund = await this.stripeService.createRefund({
paymentIntentId: paymentRecord.stripe_payment_intent_id,
amount: amount || paymentRecord.amount,
reason: reason as any,
metadata: {
paymentId: paymentId,
refundedBy: 'admin', // This should come from the admin user context
},
});
// Update payment record
await connection.execute(`
UPDATE payment_records
SET status = 'refunded', refunded_amount = ?, refund_reason = ?, updated_at = NOW()
WHERE id = ?
`, [amount || paymentRecord.amount, reason, paymentId]);
$log.info(`Processed refund: ${refund.id} for payment: ${paymentId}`);
return refund;
} catch (error) {
$log.error('Error processing refund:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get payment statistics
*/
async getPaymentStatistics(): Promise<{
totalPayments: number;
totalRevenue: number;
successRate: number;
averageTransactionValue: number;
}> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
COUNT(*) as total_payments,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as total_revenue,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as successful_payments,
AVG(CASE WHEN status = 'paid' THEN amount ELSE NULL END) as avg_transaction_value
FROM payment_records
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
`);
const stats = Array.isArray(rows) ? rows[0] as any : {};
const totalPayments = stats.total_payments || 0;
const successfulPayments = stats.successful_payments || 0;
const successRate = totalPayments > 0 ? (successfulPayments / totalPayments) * 100 : 0;
return {
totalPayments,
totalRevenue: stats.total_revenue || 0,
successRate: Math.round(successRate * 100) / 100,
averageTransactionValue: stats.avg_transaction_value || 0,
};
} catch (error) {
$log.error('Error getting payment statistics:', error);
throw error;
} finally {
connection.release();
}
}
}
+321
View File
@@ -0,0 +1,321 @@
import Stripe from 'stripe';
import { $log } from '@tsed/logger';
export interface PaymentIntentData {
amount: number;
currency: string;
customerId?: string;
paymentMethodId?: string;
metadata: Record<string, string>;
paymentMethodTypes?: string[];
confirmationMethod?: 'automatic' | 'manual';
}
export interface CustomerData {
email: string;
name: string;
userId: string;
metadata?: Record<string, string>;
}
export interface RefundData {
paymentIntentId: string;
amount?: number;
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
metadata?: Record<string, string>;
}
export class StripeService {
private stripe: Stripe;
constructor() {
const secretKey = process.env.STRIPE_SECRET_KEY?.trim();
if (!secretKey ||
secretKey.includes('your_secret_key_here') ||
secretKey.includes('sk_test_your_secret_key_here') ||
secretKey.includes('placeholder') ||
!secretKey.startsWith('sk_test_') && !secretKey.startsWith('sk_live_')) {
$log.warn('STRIPE_SECRET_KEY is not properly configured. Payment features will be disabled.');
// Create a mock Stripe instance for development
this.stripe = null as any;
return;
}
try {
this.stripe = new Stripe(secretKey, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
$log.info('Stripe service initialized successfully');
} catch (error) {
$log.error('Failed to initialize Stripe service:', error);
this.stripe = null as any;
}
}
/**
* Create a Stripe customer
*/
async createCustomer(customerData: CustomerData): Promise<Stripe.Customer> {
if (!this.stripe) {
throw new Error('Stripe is not configured. Please set up STRIPE_SECRET_KEY.');
}
try {
const customer = await this.stripe.customers.create({
email: customerData.email,
name: customerData.name,
metadata: {
userId: customerData.userId,
...customerData.metadata,
},
});
$log.info(`Created Stripe customer: ${customer.id} for user: ${customerData.userId}`);
return customer;
} catch (error) {
$log.error('Error creating Stripe customer:', error);
throw new Error('Failed to create customer');
}
}
/**
* Get or create a Stripe customer for a user
*/
async getOrCreateCustomer(customerData: CustomerData): Promise<Stripe.Customer> {
if (!this.stripe) {
throw new Error('Stripe is not configured. Please set up STRIPE_SECRET_KEY.');
}
try {
// First, try to find existing customer by email
const existingCustomers = await this.stripe.customers.list({
email: customerData.email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
const customer = existingCustomers.data[0];
$log.info(`Found existing Stripe customer: ${customer.id} for user: ${customerData.userId}`);
return customer;
}
// Create new customer if not found
return await this.createCustomer(customerData);
} catch (error) {
$log.error('Error getting or creating Stripe customer:', error);
throw new Error('Failed to get or create customer');
}
}
/**
* Create a payment intent
*/
async createPaymentIntent(data: PaymentIntentData): Promise<Stripe.PaymentIntent> {
if (!this.stripe) {
throw new Error('Stripe is not configured. Please set up STRIPE_SECRET_KEY.');
}
try {
const paymentIntentData: Stripe.PaymentIntentCreateParams = {
amount: Math.round(data.amount * 100), // Convert to cents
currency: data.currency,
metadata: data.metadata,
payment_method_types: data.paymentMethodTypes || ['card'],
confirmation_method: data.confirmationMethod || 'automatic',
};
if (data.customerId) {
paymentIntentData.customer = data.customerId;
}
if (data.paymentMethodId) {
paymentIntentData.payment_method = data.paymentMethodId;
paymentIntentData.confirmation_method = 'manual';
}
const paymentIntent = await this.stripe.paymentIntents.create(paymentIntentData);
$log.info(`Created payment intent: ${paymentIntent.id} for amount: ${data.amount}`);
return paymentIntent;
} catch (error) {
$log.error('Error creating payment intent:', error);
throw new Error('Failed to create payment intent');
}
}
/**
* Confirm a payment intent
*/
async confirmPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
if (!this.stripe) {
throw new Error('Stripe is not configured. Please set up STRIPE_SECRET_KEY.');
}
try {
const paymentIntent = await this.stripe.paymentIntents.confirm(paymentIntentId);
$log.info(`Confirmed payment intent: ${paymentIntentId}, status: ${paymentIntent.status}`);
return paymentIntent;
} catch (error) {
$log.error('Error confirming payment intent:', error);
throw new Error('Failed to confirm payment intent');
}
}
/**
* Retrieve a payment intent
*/
async getPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
try {
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId);
$log.info(`Retrieved payment intent: ${paymentIntentId}, status: ${paymentIntent.status}`);
return paymentIntent;
} catch (error) {
$log.error('Error retrieving payment intent:', error);
throw new Error('Failed to retrieve payment intent');
}
}
/**
* Cancel a payment intent
*/
async cancelPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
try {
const paymentIntent = await this.stripe.paymentIntents.cancel(paymentIntentId);
$log.info(`Cancelled payment intent: ${paymentIntentId}`);
return paymentIntent;
} catch (error) {
$log.error('Error cancelling payment intent:', error);
throw new Error('Failed to cancel payment intent');
}
}
/**
* Create a refund
*/
async createRefund(data: RefundData): Promise<Stripe.Refund> {
try {
const refundData: Stripe.RefundCreateParams = {
payment_intent: data.paymentIntentId,
metadata: data.metadata,
};
if (data.amount) {
refundData.amount = Math.round(data.amount * 100); // Convert to cents
}
if (data.reason) {
refundData.reason = data.reason;
}
const refund = await this.stripe.refunds.create(refundData);
$log.info(`Created refund: ${refund.id} for payment intent: ${data.paymentIntentId}`);
return refund;
} catch (error) {
$log.error('Error creating refund:', error);
throw new Error('Failed to create refund');
}
}
/**
* Get payment methods for a customer
*/
async getPaymentMethods(customerId: string): Promise<Stripe.PaymentMethod[]> {
try {
const paymentMethods = await this.stripe.paymentMethods.list({
customer: customerId,
type: 'card',
});
$log.info(`Retrieved ${paymentMethods.data.length} payment methods for customer: ${customerId}`);
return paymentMethods.data;
} catch (error) {
$log.error('Error retrieving payment methods:', error);
throw new Error('Failed to retrieve payment methods');
}
}
/**
* Create a setup intent for saving payment methods
*/
async createSetupIntent(customerId: string, metadata?: Record<string, string>): Promise<Stripe.SetupIntent> {
try {
const setupIntent = await this.stripe.setupIntents.create({
customer: customerId,
payment_method_types: ['card'],
metadata: metadata || {},
});
$log.info(`Created setup intent: ${setupIntent.id} for customer: ${customerId}`);
return setupIntent;
} catch (error) {
$log.error('Error creating setup intent:', error);
throw new Error('Failed to create setup intent');
}
}
/**
* Verify webhook signature
*/
verifyWebhookSignature(payload: string | Buffer, signature: string): Stripe.Event {
try {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('STRIPE_WEBHOOK_SECRET environment variable is required');
}
const event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
$log.info(`Verified webhook event: ${event.type}, id: ${event.id}`);
return event;
} catch (error) {
$log.error('Error verifying webhook signature:', error);
throw new Error('Invalid webhook signature');
}
}
/**
* Get available payment methods for different regions
*/
getAvailablePaymentMethods(countryCode?: string): string[] {
const baseMethods = ['card'];
if (countryCode === 'NL') {
return [...baseMethods, 'ideal'];
}
if (countryCode === 'DE' || countryCode === 'FR' || countryCode === 'ES' || countryCode === 'IT') {
return [...baseMethods, 'sepa_debit'];
}
return baseMethods;
}
/**
* Get payment method configuration for iDEAL
*/
getIdealConfiguration(): { banks: Array<{ id: string; name: string }> } {
return {
banks: [
{ id: 'abn_amro', name: 'ABN AMRO' },
{ id: 'asn_bank', name: 'ASN Bank' },
{ id: 'bunq', name: 'bunq' },
{ id: 'handelsbanken', name: 'Handelsbanken' },
{ id: 'ing', name: 'ING' },
{ id: 'knab', name: 'Knab' },
{ id: 'rabobank', name: 'Rabobank' },
{ id: 'regiobank', name: 'RegioBank' },
{ id: 'revolut', name: 'Revolut' },
{ id: 'sns_bank', name: 'SNS Bank' },
{ id: 'triodos_bank', name: 'Triodos Bank' },
{ id: 'van_lanschot', name: 'Van Lanschot' },
],
};
}
}
+138
View File
@@ -440,4 +440,142 @@ export class TokenService {
connection.release();
}
}
/**
* Calculate custom token price based on quantity
*/
async calculateCustomTokenPrice(quantity: number): Promise<{
basePrice: number;
discountPercentage: number;
finalPrice: number;
savings: number;
}> {
const connection = await pool.getConnection();
try {
// Get all active packages
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE is_active = 1 ORDER BY quantity ASC'
);
const packages = Array.isArray(rows) ? rows as TokenPackage[] : [];
if (packages.length === 0) {
// No packages available, use base price
const basePrice = quantity * 5.00; // Default price per token
return {
basePrice,
discountPercentage: 0,
finalPrice: basePrice,
savings: 0,
};
}
// Find the package that gives the best discount for this quantity
let bestPackage = null;
let bestPrice = quantity * 5.00; // Default base price
let bestDiscount = 0;
let bestSavings = 0;
for (const pkg of packages) {
if (quantity >= pkg.quantity) {
const basePrice = quantity * pkg.price_per_token;
const discountAmount = (basePrice * pkg.discount_percentage) / 100;
const finalPrice = basePrice - discountAmount;
if (finalPrice < bestPrice) {
bestPackage = pkg;
bestPrice = finalPrice;
bestDiscount = pkg.discount_percentage;
bestSavings = discountAmount;
}
}
}
return {
basePrice: quantity * 5.00,
discountPercentage: bestDiscount,
finalPrice: bestPrice,
savings: bestSavings,
};
} catch (error) {
$log.error('Error calculating custom token price:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get the best package for a given quantity
*/
async getBestPackageForQuantity(quantity: number): Promise<TokenPackage | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE is_active = 1 AND quantity <= ? ORDER BY quantity DESC LIMIT 1',
[quantity]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as TokenPackage;
}
return null;
} catch (error) {
$log.error('Error getting best package for quantity:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Add tokens to user account (updated for payment-based tokens)
*/
async addTokensToUserFromPayment(
userId: string,
quantity: number,
pricePerToken: number,
paymentId: string
): Promise<InterviewToken> {
const connection = await pool.getConnection();
try {
const totalPrice = quantity * pricePerToken;
const tokenId = randomUUID();
// Create token record
await connection.execute(`
INSERT INTO interview_tokens (
id, user_id, token_type, quantity, price_per_token,
total_price, status, purchased_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), NOW())
`, [
tokenId,
userId,
quantity === 1 ? 'single' : 'bulk',
quantity,
pricePerToken,
totalPrice
]);
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
`, [userId, quantity, quantity]);
$log.info(`Added ${quantity} tokens to user ${userId} from payment ${paymentId}`);
return await this.getTokenById(tokenId) as InterviewToken;
} catch (error) {
$log.error('Error adding tokens to user from payment:', error);
throw error;
} finally {
connection.release();
}
}
}
+110
View File
@@ -204,6 +204,116 @@ export class UserService {
}
}
/**
* Get user payment history
*/
async getUserPaymentHistory(userId: string): Promise<any[]> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
tp.name as package_name
FROM payment_records pr
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
WHERE pr.user_id = ?
ORDER BY pr.created_at DESC
`, [userId]);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting user payment history:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get user by Stripe customer ID
*/
async getUserByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM users WHERE stripe_customer_id = ? AND deleted_at IS NULL',
[stripeCustomerId]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as User;
}
return null;
} catch (error) {
$log.error('Error getting user by Stripe customer ID:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Update user's Stripe customer ID
*/
async updateUserStripeCustomerId(userId: string, stripeCustomerId: string): Promise<void> {
const connection = await pool.getConnection();
try {
await connection.execute(
'UPDATE users SET stripe_customer_id = ?, updated_at = NOW() WHERE id = ?',
[stripeCustomerId, userId]
);
$log.info(`Updated Stripe customer ID for user: ${userId}`);
} catch (error) {
$log.error('Error updating user Stripe customer ID:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get user payment statistics
*/
async getUserPaymentStatistics(userId: string): Promise<{
totalSpent: number;
totalPayments: number;
averagePaymentValue: number;
lastPaymentDate: string | null;
}> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
COUNT(*) as total_payments,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as total_spent,
AVG(CASE WHEN status = 'paid' THEN amount ELSE NULL END) as avg_payment_value,
MAX(CASE WHEN status = 'paid' THEN paid_at ELSE NULL END) as last_payment_date
FROM payment_records
WHERE user_id = ?
`, [userId]);
const stats = Array.isArray(rows) ? rows[0] as any : {};
return {
totalSpent: stats.total_spent || 0,
totalPayments: stats.total_payments || 0,
averagePaymentValue: stats.avg_payment_value || 0,
lastPaymentDate: stats.last_payment_date || null,
};
} catch (error) {
$log.error('Error getting user payment statistics:', error);
throw error;
} finally {
connection.release();
}
}
private mapUserToResponse(user: User): UserResponse {
return {
id: user.id,