diff --git a/backend/package-lock.json b/backend/package-lock.json index 0b8479e..32cac45 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -37,6 +37,7 @@ "@tsed/swagger": "^8.16.2", "@types/bcryptjs": "^3.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/stripe": "^8.0.417", "ajv": "^8.17.1", "axios": "^1.6.0", "bcryptjs": "^3.0.2", @@ -53,6 +54,7 @@ "method-override": "^3.0.0", "mysql2": "^3.14.5", "socket.io": "^4.8.1", + "stripe": "^18.5.0", "typescript": "^5.9.2" }, "devDependencies": { @@ -2027,6 +2029,15 @@ "@types/send": "*" } }, + "node_modules/@types/stripe": { + "version": "8.0.417", + "resolved": "https://registry.npmjs.org/@types/stripe/-/stripe-8.0.417.tgz", + "integrity": "sha512-PTuqskh9YKNENnOHGVJBm4sM0zE8B1jZw1JIskuGAPkMB+OH236QeN8scclhYGPA4nG6zTtPXgwpXdp+HPDTVw==", + "deprecated": "This is a stub types definition. stripe provides its own type definitions, so you do not need this installed.", + "dependencies": { + "stripe": "*" + } + }, "node_modules/@unhead/schema": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/@unhead/schema/-/schema-1.11.20.tgz", @@ -5252,6 +5263,25 @@ "node": ">=6" } }, + "node_modules/stripe": { + "version": "18.5.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-18.5.0.tgz", + "integrity": "sha512-Hp+wFiEQtCB0LlNgcFh5uVyKznpDjzyUZ+CNVEf+I3fhlYvh7rZruIg+jOwzJRCpy0ZTPMjlzm7J2/M2N6d+DA==", + "dependencies": { + "qs": "^6.11.0" + }, + "engines": { + "node": ">=12.*" + }, + "peerDependencies": { + "@types/node": ">=12.x.x" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/strtok3": { "version": "10.3.4", "resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz", diff --git a/backend/package.json b/backend/package.json index 51f536b..dd4b9ac 100644 --- a/backend/package.json +++ b/backend/package.json @@ -38,6 +38,7 @@ "@tsed/swagger": "^8.16.2", "@types/bcryptjs": "^3.0.0", "@types/jsonwebtoken": "^9.0.10", + "@types/stripe": "^8.0.417", "ajv": "^8.17.1", "axios": "^1.6.0", "bcryptjs": "^3.0.2", @@ -54,6 +55,7 @@ "method-override": "^3.0.0", "mysql2": "^3.14.5", "socket.io": "^4.8.1", + "stripe": "^18.5.0", "typescript": "^5.9.2" }, "devDependencies": { diff --git a/backend/src/Server.ts b/backend/src/Server.ts index fa06462..ab3d6ff 100644 --- a/backend/src/Server.ts +++ b/backend/src/Server.ts @@ -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: { diff --git a/backend/src/controllers/rest/PaymentController.ts b/backend/src/controllers/rest/PaymentController.ts new file mode 100644 index 0000000..228196b --- /dev/null +++ b/backend/src/controllers/rest/PaymentController.ts @@ -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; + } + } +} diff --git a/backend/src/controllers/rest/WebhookController.ts b/backend/src/controllers/rest/WebhookController.ts new file mode 100644 index 0000000..3920c90 --- /dev/null +++ b/backend/src/controllers/rest/WebhookController.ts @@ -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' + }); + } +} diff --git a/backend/src/services/PaymentService.ts b/backend/src/services/PaymentService.ts new file mode 100644 index 0000000..f254f86 --- /dev/null +++ b/backend/src/services/PaymentService.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + } + } +} diff --git a/backend/src/services/StripeService.ts b/backend/src/services/StripeService.ts new file mode 100644 index 0000000..9608342 --- /dev/null +++ b/backend/src/services/StripeService.ts @@ -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; + paymentMethodTypes?: string[]; + confirmationMethod?: 'automatic' | 'manual'; +} + +export interface CustomerData { + email: string; + name: string; + userId: string; + metadata?: Record; +} + +export interface RefundData { + paymentIntentId: string; + amount?: number; + reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer'; + metadata?: Record; +} + +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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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): Promise { + 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' }, + ], + }; + } +} diff --git a/backend/src/services/TokenService.ts b/backend/src/services/TokenService.ts index f08ae10..b0356ab 100644 --- a/backend/src/services/TokenService.ts +++ b/backend/src/services/TokenService.ts @@ -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 { + 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 { + 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(); + } + } } diff --git a/backend/src/services/UserService.ts b/backend/src/services/UserService.ts index bd48f6c..a2fd423 100644 --- a/backend/src/services/UserService.ts +++ b/backend/src/services/UserService.ts @@ -204,6 +204,116 @@ export class UserService { } } + /** + * Get user payment history + */ + async getUserPaymentHistory(userId: string): Promise { + 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 { + 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 { + 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, diff --git a/database/deploy_dump.sql b/database/deploy_dump.sql index f2de20d..f8610c0 100644 --- a/database/deploy_dump.sql +++ b/database/deploy_dump.sql @@ -18,6 +18,7 @@ CREATE TABLE `users` ( `role` enum('admin','recruiter') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'recruiter', `company_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `avatar_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `stripe_customer_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Stripe Customer ID for payment processing', `is_active` tinyint(1) DEFAULT '1', `last_login_at` timestamp NULL DEFAULT NULL, `email_verified_at` timestamp NULL DEFAULT NULL, @@ -29,7 +30,8 @@ CREATE TABLE `users` ( KEY `idx_email` (`email`), KEY `idx_role` (`role`), KEY `idx_active` (`is_active`), - KEY `idx_role_active` (`role`,`is_active`) + KEY `idx_role_active` (`role`,`is_active`), + KEY `idx_stripe_customer_id` (`stripe_customer_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -- token_packages @@ -205,13 +207,22 @@ CREATE TABLE `interview_tokens` ( CREATE TABLE `payment_records` ( `id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()), `user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL, - `token_package_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL, + `token_package_id` varchar(36) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `amount` decimal(10,2) NOT NULL, - `currency` varchar(3) COLLATE utf8mb4_unicode_ci DEFAULT 'USD', - `status` enum('pending','paid','failed','refunded','cancelled') COLLATE utf8mb4_unicode_ci DEFAULT 'pending', + `currency` varchar(3) COLLATE utf8mb4_unicode_ci DEFAULT 'EUR', + `status` enum('pending','processing','paid','failed','refunded','cancelled') COLLATE utf8mb4_unicode_ci DEFAULT 'pending', `payment_method` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL, `payment_reference` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `stripe_payment_intent_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Stripe Payment Intent ID for tracking payments', + `stripe_payment_method_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Stripe Payment Method ID for saved payment methods', + `stripe_customer_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Stripe Customer ID for user payment methods', + `payment_flow_type` enum('card','ideal','bank_transfer','admin_granted') COLLATE utf8mb4_unicode_ci DEFAULT 'admin_granted' COMMENT 'Type of payment flow used', `invoice_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `stripe_metadata` json DEFAULT NULL COMMENT 'Additional Stripe metadata and webhook data', + `refund_reason` text COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Reason for payment refund', + `refunded_amount` decimal(10,2) DEFAULT '0.00' COMMENT 'Amount refunded for this payment', + `custom_quantity` int DEFAULT NULL COMMENT 'Custom token quantity for non-package purchases', + `applied_discount_percentage` decimal(5,2) DEFAULT '0.00' COMMENT 'Discount percentage applied to this purchase', `paid_at` timestamp NULL DEFAULT NULL, `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -220,6 +231,10 @@ CREATE TABLE `payment_records` ( KEY `idx_user_status` (`user_id`,`status`), KEY `idx_payment_reference` (`payment_reference`), KEY `idx_payment_records_user_created` (`user_id`,`created_at` DESC), + KEY `idx_stripe_payment_intent_id` (`stripe_payment_intent_id`), + KEY `idx_stripe_customer_id` (`stripe_customer_id`), + KEY `idx_payment_flow_type` (`payment_flow_type`), + KEY `idx_custom_quantity` (`custom_quantity`), CONSTRAINT `payment_records_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, CONSTRAINT `payment_records_ibfk_2` FOREIGN KEY (`token_package_id`) REFERENCES `token_packages` (`id`) ON DELETE RESTRICT ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; diff --git a/docker-compose.yml b/docker-compose.yml index 764d7aa..4f8e5cd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,6 +55,10 @@ services: OPENROUTER_TEMPERATURE: ${OPENROUTER_TEMPERATURE} AI_PORT: ${AI_PORT} AI_MODEL: ${AI_MODEL} + # Stripe Payment Configuration + STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} ports: - "${BACKEND_PORT:-8083}:8083" volumes: @@ -89,6 +93,7 @@ services: environment: NODE_ENV: ${NODE_ENV:-production} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY} ports: - "${FRONTEND_PORT:-3000}:3000" volumes: diff --git a/env.example b/env.example index 70d73c5..8e6d7e7 100644 --- a/env.example +++ b/env.example @@ -36,3 +36,9 @@ CHATBOT_SERVICE_TIMEOUT=30000 CHATBOT_FALLBACK_ENABLED=true CHATBOT_PORT=5000 +# Stripe Payment Configuration +STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here +STRIPE_SECRET_KEY=sk_test_your_secret_key_here +STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key_here + diff --git a/env.production b/env.production index c7612db..1a3bea6 100644 --- a/env.production +++ b/env.production @@ -36,3 +36,9 @@ CHATBOT_SERVICE_URL=http://chatbot:80 CHATBOT_SERVICE_TIMEOUT=30000 CHATBOT_FALLBACK_ENABLED=true CHATBOT_PORT=5000 + +# Stripe Payment Configuration +STRIPE_PUBLISHABLE_KEY=pk_test_51S9Qz6Iy0BkbEj7PVcCGfjIIYTLtJvhMs7wJ4v1KYzZkKeDcQ1KHWDmwWJI3sBgKcxUMA1ayIs2SODYjX5b2E7lu00AJVAqGO4 +STRIPE_SECRET_KEY=sk_test_51S9Qz6Iy0BkbEj7PoHSwzVSxu4qcbWKs6yFzYEFkucQ4XorHuVwR4VHbrhgWnZhkfFRkqxkZKTWN39IJJPPeVD0C00Vei6TCWV +STRIPE_WEBHOOK_SECRET=whsec_YOUR_WEBHOOK_SECRET_HERE +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_51S9Qz6Iy0BkbEj7PVcCGfjIIYTLtJvhMs7wJ4v1KYzZkKeDcQ1KHWDmwWJI3sBgKcxUMA1ayIs2SODYjX5b2E7lu00AJVAqGO4 diff --git a/frontend/next.config.js b/frontend/next.config.js index 816ffe8..29f5b27 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -5,6 +5,7 @@ const nextConfig = { env: { NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://candivista.com', + NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY || 'pk_test_your_publishable_key_here', }, // Only add rewrites for production (Docker) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 164d3b0..2da7108 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.1.0", "dependencies": { "@hookform/resolvers": "^5.2.1", + "@stripe/react-stripe-js": "^4.0.2", + "@stripe/stripe-js": "^7.9.0", "axios": "^1.11.0", "next": "15.5.2", "next-themes": "^0.4.6", @@ -668,6 +670,27 @@ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==" }, + "node_modules/@stripe/react-stripe-js": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-4.0.2.tgz", + "integrity": "sha512-l2wau+8/LOlHl+Sz8wQ1oDuLJvyw51nQCsu6/ljT6smqzTszcMHifjAJoXlnMfcou3+jK/kQyVe04u/ufyTXgg==", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "@stripe/stripe-js": ">=1.44.1 <8.0.0", + "react": ">=16.8.0 <20.0.0", + "react-dom": ">=16.8.0 <20.0.0" + } + }, + "node_modules/@stripe/stripe-js": { + "version": "7.9.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz", + "integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==", + "engines": { + "node": ">=12.16" + } + }, "node_modules/@swagger-api/apidom-ast": { "version": "1.0.0-beta.48", "resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.48.tgz", diff --git a/frontend/package.json b/frontend/package.json index c2823bc..5224e02 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,6 +9,8 @@ }, "dependencies": { "@hookform/resolvers": "^5.2.1", + "@stripe/react-stripe-js": "^4.0.2", + "@stripe/stripe-js": "^7.9.0", "axios": "^1.11.0", "next": "15.5.2", "next-themes": "^0.4.6", diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index a4cbe5a..b54427a 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; import { ThemeProvider } from "next-themes"; +import StripeProvider from "../components/StripeProvider"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -29,7 +30,9 @@ export default function RootLayout({ className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white dark:bg-gray-900 text-gray-900 dark:text-white`} > - {children} + + {children} + diff --git a/frontend/src/app/payment/failed/page.tsx b/frontend/src/app/payment/failed/page.tsx new file mode 100644 index 0000000..1caea24 --- /dev/null +++ b/frontend/src/app/payment/failed/page.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useEffect, useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; + +function PaymentFailedContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [errorMessage, setErrorMessage] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Get error message from URL params + const error = searchParams.get('error'); + if (error) { + setErrorMessage(decodeURIComponent(error)); + } + setLoading(false); + }, [searchParams]); + + const handleRetryPayment = () => { + router.push('/dashboard'); + }; + + const handleGoToDashboard = () => { + router.push('/dashboard'); + }; + + const handleContactSupport = () => { + // This could open a support modal or redirect to support page + window.open('mailto:support@candivista.com?subject=Payment Issue', '_blank'); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+ {/* Error Icon */} +
+ + + +
+ + {/* Error Message */} +

+ Payment Failed +

+

+ We couldn't process your payment. Don't worry, no charges were made to your account. +

+ + {/* Error Details */} + {errorMessage && ( +
+

+ Error Details +

+

+ {errorMessage} +

+
+ )} + + {/* Common Solutions */} +
+

+ Common Solutions +

+
    +
  • • Check your payment method details
  • +
  • • Ensure you have sufficient funds
  • +
  • • Try a different payment method
  • +
  • • Contact your bank if the issue persists
  • +
+
+ + {/* Action Buttons */} +
+ + +
+ + {/* Support Section */} +
+

+ Still having trouble? We're here to help! +

+
+ + + View Help Documentation + +
+
+ + {/* Additional Info */} +
+

+ If you continue to experience issues, please contact our support team with the error details above. +

+
+
+
+ ); +} + +export default function PaymentFailedPage() { + return ( + +
+ + }> + +
+ ); +} diff --git a/frontend/src/app/payment/success/page.tsx b/frontend/src/app/payment/success/page.tsx new file mode 100644 index 0000000..b3d6a03 --- /dev/null +++ b/frontend/src/app/payment/success/page.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState, Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; + +function PaymentSuccessContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const [paymentData, setPaymentData] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + // Get payment data from URL params or localStorage + const paymentIntentId = searchParams.get('payment_intent'); + const paymentIntentClientSecret = searchParams.get('payment_intent_client_secret'); + + if (paymentIntentId) { + // Payment was successful, get the data from localStorage or API + const storedData = localStorage.getItem('lastPaymentSuccess'); + if (storedData) { + setPaymentData(JSON.parse(storedData)); + localStorage.removeItem('lastPaymentSuccess'); + } + } + + setLoading(false); + }, [searchParams]); + + const handleGoToDashboard = () => { + router.push('/dashboard'); + }; + + const handleBuyMoreTokens = () => { + router.push('/dashboard'); + }; + + if (loading) { + return ( +
+
+
+ ); + } + + return ( +
+
+ {/* Success Icon */} +
+ + + +
+ + {/* Success Message */} +

+ Payment Successful! +

+

+ Your tokens have been added to your account and are ready to use. +

+ + {/* Payment Details */} + {paymentData && ( +
+

+ Payment Details +

+
+
+ Tokens Purchased: + + {paymentData.tokensAllocated || 'N/A'} + +
+
+ Amount Paid: + + €{paymentData.amount?.toFixed(2) || 'N/A'} + +
+
+ Status: + + Completed + +
+
+
+ )} + + {/* Next Steps */} +
+

+ What's Next? +

+
    +
  • • Your tokens are now active and ready to use
  • +
  • • Create job postings to start interviewing candidates
  • +
  • • Generate interview links and share with candidates
  • +
  • • View detailed interview reports and analytics
  • +
+
+ + {/* Action Buttons */} +
+ + +
+ + {/* Support Link */} +
+

+ Need help? Contact Support +

+
+
+
+ ); +} + +export default function PaymentSuccessPage() { + return ( + +
+ + }> + +
+ ); +} diff --git a/frontend/src/components/CustomTokenCalculator.tsx b/frontend/src/components/CustomTokenCalculator.tsx new file mode 100644 index 0000000..92eeb06 --- /dev/null +++ b/frontend/src/components/CustomTokenCalculator.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { useState, useEffect } from "react"; +import axios from "axios"; +import { PricingService, PricingCalculation, TokenPackage } from "../services/PricingService"; + +// Remove duplicate interface - using from PricingService + +interface CustomTokenCalculatorProps { + onCalculationChange: (calculation: PricingCalculation) => void; + onPackageSelect: (packageId: string) => void; + selectedPackageId?: string; +} + +export default function CustomTokenCalculator({ + onCalculationChange, + onPackageSelect, + selectedPackageId +}: CustomTokenCalculatorProps) { + const [quantity, setQuantity] = useState(1); + const [packages, setPackages] = useState([]); + const [calculation, setCalculation] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + fetchPackages(); + }, []); + + useEffect(() => { + if (quantity > 0) { + calculatePrice(); + } + }, [quantity, selectedPackageId]); + + const fetchPackages = async () => { + try { + const token = localStorage.getItem("token"); + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_URL}/rest/admin/token-packages`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (response.data.success) { + setPackages(response.data.packages.filter((pkg: TokenPackage) => pkg.is_active)); + } + } catch (err) { + console.error("Failed to fetch packages:", err); + } + }; + + const calculatePrice = async () => { + if (quantity <= 0) return; + + setLoading(true); + setError(null); + + try { + // Validate quantity first + const validation = PricingService.validateQuantity(quantity); + if (!validation.isValid) { + setError(validation.error || "Invalid quantity"); + return; + } + + // Calculate price using PricingService + const calc = PricingService.calculatePrice(quantity, packages, selectedPackageId); + setCalculation(calc); + onCalculationChange(calc); + } catch (err: any) { + setError(err.message || "Failed to calculate price"); + } finally { + setLoading(false); + } + }; + + const handleQuantityChange = (newQuantity: number) => { + if (newQuantity >= 1 && newQuantity <= 1000) { + setQuantity(newQuantity); + } + }; + + const handlePackageSelect = (packageId: string) => { + onPackageSelect(packageId); + }; + + const getBestPackageForQuantity = (qty: number) => { + return packages + .filter(pkg => pkg.quantity <= qty) + .sort((a, b) => b.quantity - a.quantity)[0]; + }; + + const getRecommendedPackage = () => { + if (quantity <= 1) return null; + return getBestPackageForQuantity(quantity); + }; + + const recommendedPackage = getRecommendedPackage(); + + return ( +
+ {/* Quantity Input */} +
+ +
+ + + handleQuantityChange(parseInt(e.target.value) || 1)} + min="1" + max="1000" + className="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent text-center" + /> + + +
+

+ Choose between 1 and 1000 tokens +

+
+ + {/* Package Selection */} + {packages.length > 0 && ( +
+ +
+ + + {packages.map((pkg) => ( + + ))} +
+
+ )} + + {/* Price Calculation */} + {calculation && ( +
+

Price Breakdown

+ +
+
+ + {quantity} token{quantity > 1 ? 's' : ''} × {PricingService.formatPrice(PricingService.BASE_PRICE_PER_TOKEN)} + + + {PricingService.formatPrice(calculation.basePrice)} + +
+ + {calculation.savings > 0 && ( + <> +
+ Discount ({calculation.discountPercentage}%) + -{PricingService.formatPrice(calculation.savings)} +
+ {calculation.packageName && ( +
+ Applied from {calculation.packageName} package +
+ )} + + )} + +
+ Total + + {PricingService.formatPrice(calculation.finalPrice)} + +
+
+ + {calculation.savings > 0 && ( +
+
+ 🎉 You save {PricingService.formatPrice(calculation.savings)} with this package! +
+
+ )} +
+ )} + + {/* Recommendation */} + {recommendedPackage && !selectedPackageId && quantity > 1 && ( +
+
+
💡
+
+
+ Recommended Package +
+
+ Consider the {recommendedPackage.name} package for better value. + You'll get {recommendedPackage.quantity} tokens with {recommendedPackage.discount_percentage}% discount. +
+
+
+
+ )} + + {/* Loading State */} + {loading && ( +
+
+
+ )} + + {/* Error State */} + {error && ( +
+

{error}

+
+ )} +
+ ); +} diff --git a/frontend/src/components/ErrorDisplay.tsx b/frontend/src/components/ErrorDisplay.tsx new file mode 100644 index 0000000..b5237b2 --- /dev/null +++ b/frontend/src/components/ErrorDisplay.tsx @@ -0,0 +1,138 @@ +"use client"; + +import { useState } from "react"; +import { ErrorService } from "../services/ErrorService"; + +interface ErrorDisplayProps { + error: any; + onRetry?: () => void; + onDismiss?: () => void; + showDetails?: boolean; + className?: string; +} + +export default function ErrorDisplay({ + error, + onRetry, + onDismiss, + showDetails = false, + className = "" +}: ErrorDisplayProps) { + const [showFullDetails, setShowFullDetails] = useState(false); + + if (!error) return null; + + const errorInfo = ErrorService.formatError(error); + const severity = ErrorService.getErrorSeverity(error); + const retryable = ErrorService.isRetryable(error); + + const getSeverityStyles = () => { + switch (severity) { + case 'low': + return 'bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800 text-yellow-800 dark:text-yellow-200'; + case 'medium': + return 'bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800 text-orange-800 dark:text-orange-200'; + case 'high': + return 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-800 dark:text-red-200'; + case 'critical': + return 'bg-red-100 dark:bg-red-900/30 border-red-300 dark:border-red-700 text-red-900 dark:text-red-100'; + default: + return 'bg-gray-50 dark:bg-gray-900/20 border-gray-200 dark:border-gray-800 text-gray-800 dark:text-gray-200'; + } + }; + + const getSeverityIcon = () => { + switch (severity) { + case 'low': + return ( + + + + ); + case 'medium': + return ( + + + + ); + case 'high': + case 'critical': + return ( + + + + ); + default: + return ( + + + + ); + } + }; + + return ( +
+
+
+ {getSeverityIcon()} +
+ +
+

+ {errorInfo.title} +

+ +

+ {errorInfo.message} +

+ + {showDetails && ( +
+ + + {showFullDetails && ( +
+
+                    {JSON.stringify(error, null, 2)}
+                  
+
+ )} +
+ )} + +
+
+ {errorInfo.action} +
+ +
+ {retryable && onRetry && ( + + )} + + {onDismiss && ( + + )} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 148b683..ec58d93 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -3,6 +3,8 @@ import { useState, useEffect } from "react"; import axios from "axios"; import ThemeToggle from "./ThemeToggle"; +import TokenPurchaseFlow from "./TokenPurchaseFlow"; +import PaymentHistory from "./PaymentHistory"; interface User { first_name: string; @@ -27,6 +29,8 @@ interface HeaderProps { export default function Header({ title, user, onLogout }: HeaderProps) { const [tokenSummary, setTokenSummary] = useState(null); const [loadingTokens, setLoadingTokens] = useState(false); + const [showPurchaseModal, setShowPurchaseModal] = useState(false); + const [showPaymentHistory, setShowPaymentHistory] = useState(false); useEffect(() => { if (user && user.role === 'recruiter') { @@ -74,6 +78,14 @@ export default function Header({ title, user, onLogout }: HeaderProps) { } }; + const handlePurchaseSuccess = (paymentData: any) => { + console.log('Payment successful:', paymentData); + // Refresh token summary + fetchTokenSummary(); + // Dispatch event for other components + window.dispatchEvent(new CustomEvent('tokensUpdated')); + }; + return (
@@ -98,6 +110,25 @@ export default function Header({ title, user, onLogout }: HeaderProps) { {tokenSummary.total_available} tokens
+ + {/* Buy Tokens Button */} + + + {/* Payment History Button */} + {/* Token Usage Progress */}
@@ -177,6 +208,19 @@ export default function Header({ title, user, onLogout }: HeaderProps) {
+ + {/* Token Purchase Modal */} + setShowPurchaseModal(false)} + onSuccess={handlePurchaseSuccess} + /> + + {/* Payment History Modal */} + setShowPaymentHistory(false)} + />
); } diff --git a/frontend/src/components/LoadingStates.tsx b/frontend/src/components/LoadingStates.tsx new file mode 100644 index 0000000..b5ac913 --- /dev/null +++ b/frontend/src/components/LoadingStates.tsx @@ -0,0 +1,208 @@ +"use client"; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function LoadingSpinner({ size = 'md', className = '' }: LoadingSpinnerProps) { + const sizeClasses = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8' + }; + + return ( +
+
+
+ ); +} + +interface LoadingDotsProps { + className?: string; +} + +export function LoadingDots({ className = '' }: LoadingDotsProps) { + return ( +
+
+
+
+
+ ); +} + +interface LoadingCardProps { + title?: string; + description?: string; + className?: string; +} + +export function LoadingCard({ title = 'Loading...', description, className = '' }: LoadingCardProps) { + return ( +
+
+ +
+
+

+ {title} +

+ {description && ( +

+ {description} +

+ )} +
+
+ ); +} + +interface LoadingOverlayProps { + isVisible: boolean; + message?: string; + className?: string; +} + +export function LoadingOverlay({ isVisible, message = 'Loading...', className = '' }: LoadingOverlayProps) { + if (!isVisible) return null; + + return ( +
+
+
+ +
+
+

+ {message} +

+
+
+
+ ); +} + +interface ProgressBarProps { + progress: number; // 0-100 + className?: string; + showPercentage?: boolean; +} + +export function ProgressBar({ progress, className = '', showPercentage = true }: ProgressBarProps) { + const clampedProgress = Math.max(0, Math.min(100, progress)); + + return ( +
+
+ + Progress + + {showPercentage && ( + + {Math.round(clampedProgress)}% + + )} +
+
+
+
+
+ ); +} + +interface SkeletonProps { + className?: string; + lines?: number; +} + +export function Skeleton({ className = '', lines = 1 }: SkeletonProps) { + return ( +
+ {Array.from({ length: lines }).map((_, index) => ( +
+ ))} +
+ ); +} + +interface LoadingButtonProps { + isLoading: boolean; + children: React.ReactNode; + loadingText?: string; + disabled?: boolean; + className?: string; + onClick?: () => void; + type?: 'button' | 'submit' | 'reset'; +} + +export function LoadingButton({ + isLoading, + children, + loadingText = 'Loading...', + disabled = false, + className = '', + onClick, + type = 'button' +}: LoadingButtonProps) { + return ( + + ); +} + +interface LoadingTableProps { + rows?: number; + columns?: number; + className?: string; +} + +export function LoadingTable({ rows = 5, columns = 4, className = '' }: LoadingTableProps) { + return ( +
+
+ {/* Header */} +
+ {Array.from({ length: columns }).map((_, index) => ( +
+ ))} +
+ + {/* Rows */} + {Array.from({ length: rows }).map((_, rowIndex) => ( +
+ {Array.from({ length: columns }).map((_, colIndex) => ( +
+ ))} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/PaymentHistory.tsx b/frontend/src/components/PaymentHistory.tsx new file mode 100644 index 0000000..4eea345 --- /dev/null +++ b/frontend/src/components/PaymentHistory.tsx @@ -0,0 +1,231 @@ +"use client"; + +import { useState, useEffect } from "react"; +import axios from "axios"; + +interface PaymentRecord { + id: string; + amount: number; + currency: string; + status: string; + payment_flow_type: string; + custom_quantity?: number; + applied_discount_percentage?: number; + package_name?: string; + created_at: string; + paid_at?: string; + refunded_amount?: number; + refund_reason?: string; +} + +interface PaymentHistoryProps { + isOpen: boolean; + onClose: () => void; +} + +export default function PaymentHistory({ isOpen, onClose }: PaymentHistoryProps) { + const [payments, setPayments] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (isOpen) { + fetchPaymentHistory(); + } + }, [isOpen]); + + const fetchPaymentHistory = async () => { + try { + setLoading(true); + const token = localStorage.getItem("token"); + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_URL}/rest/payments/history`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (response.data.success) { + setPayments(response.data.payments); + } else { + setError("Failed to load payment history"); + } + } catch (err: any) { + setError(err.response?.data?.message || "Failed to load payment history"); + } finally { + setLoading(false); + } + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'paid': + return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-300'; + case 'pending': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-300'; + case 'failed': + return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-300'; + case 'cancelled': + return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300'; + case 'refunded': + return 'bg-purple-100 text-purple-800 dark:bg-purple-900/20 dark:text-purple-300'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-300'; + } + }; + + const getPaymentMethodIcon = (flowType: string) => { + switch (flowType) { + case 'card': + return '💳'; + case 'ideal': + return '🏦'; + case 'bank_transfer': + return '🏛️'; + default: + return '💰'; + } + }; + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + }; + + const formatCurrency = (amount: number, currency: string) => { + return new Intl.NumberFormat('en-EU', { + style: 'currency', + currency: currency.toUpperCase() + }).format(amount); + }; + + if (!isOpen) return null; + + return ( +
+
+
+ {/* Header */} +
+
+

+ Payment History +

+

+ View all your token purchases and payments +

+
+ +
+ + {/* Content */} + {loading ? ( +
+
+
+ ) : error ? ( +
+

{error}

+
+ ) : payments.length === 0 ? ( +
+
+ + + +
+

+ No payments yet +

+

+ Your payment history will appear here once you make your first purchase. +

+
+ ) : ( +
+ {payments.map((payment) => ( +
+
+
+
+ {getPaymentMethodIcon(payment.payment_flow_type)} +
+
+
+ {payment.package_name || `${payment.custom_quantity} Token${payment.custom_quantity && payment.custom_quantity > 1 ? 's' : ''}`} +
+
+ {formatDate(payment.created_at)} + {payment.paid_at && ( + + • Paid {formatDate(payment.paid_at)} + + )} +
+ {payment.applied_discount_percentage && payment.applied_discount_percentage > 0 && ( +
+ {payment.applied_discount_percentage}% discount applied +
+ )} +
+
+ +
+
+ {formatCurrency(payment.amount, payment.currency)} +
+
+ {payment.status.charAt(0).toUpperCase() + payment.status.slice(1)} +
+ {payment.refunded_amount && ( +
+ Refunded: {formatCurrency(payment.refunded_amount, payment.currency)} +
+ )} +
+
+ + {payment.refund_reason && ( +
+
+ Refund reason: {payment.refund_reason} +
+
+ )} +
+ ))} +
+ )} + + {/* Footer */} +
+
+
+ {payments.length} payment{payments.length !== 1 ? 's' : ''} total +
+ +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/PaymentMethodSelector.tsx b/frontend/src/components/PaymentMethodSelector.tsx new file mode 100644 index 0000000..5fe3b44 --- /dev/null +++ b/frontend/src/components/PaymentMethodSelector.tsx @@ -0,0 +1,160 @@ +"use client"; + +import { useState } from "react"; + +interface PaymentMethodSelectorProps { + selectedMethod: 'card' | 'ideal' | 'bank_transfer'; + onMethodChange: (method: 'card' | 'ideal' | 'bank_transfer') => void; + countryCode?: string; + disabled?: boolean; +} + +export default function PaymentMethodSelector({ + selectedMethod, + onMethodChange, + countryCode, + disabled = false +}: PaymentMethodSelectorProps) { + const [idealBanks] = useState([ + { id: 'abn_amro', name: 'ABN AMRO', logo: '🏦' }, + { id: 'asn_bank', name: 'ASN Bank', logo: '🏛️' }, + { id: 'bunq', name: 'bunq', logo: '📱' }, + { id: 'handelsbanken', name: 'Handelsbanken', logo: '🏦' }, + { id: 'ing', name: 'ING', logo: '🦁' }, + { id: 'knab', name: 'Knab', logo: '💚' }, + { id: 'rabobank', name: 'Rabobank', logo: '🐄' }, + { id: 'regiobank', name: 'RegioBank', logo: '🏦' }, + { id: 'revolut', name: 'Revolut', logo: '💳' }, + { id: 'sns_bank', name: 'SNS Bank', logo: '🏦' }, + { id: 'triodos_bank', name: 'Triodos Bank', logo: '🌱' }, + { id: 'van_lanschot', name: 'Van Lanschot', logo: '🏦' }, + ]); + + const getAvailableMethods = () => { + const methods: Array<{ + id: 'card' | 'ideal' | 'bank_transfer'; + name: string; + description: string; + icon: string; + available: boolean; + }> = [ + { + id: 'card', + name: 'Credit/Debit Card', + description: 'Visa, Mastercard, American Express', + icon: '💳', + available: true + } + ]; + + if (countryCode === 'NL') { + methods.push({ + id: 'ideal', + name: 'iDEAL', + description: 'Direct bank transfer (Netherlands)', + icon: '🏦', + available: true + }); + } + + if (['DE', 'FR', 'ES', 'IT', 'NL'].includes(countryCode || '')) { + methods.push({ + id: 'bank_transfer', + name: 'SEPA Direct Debit', + description: 'European bank transfer', + icon: '🏛️', + available: true + }); + } + + return methods; + }; + + const availableMethods = getAvailableMethods(); + + return ( +
+
+ +
+ {availableMethods.map((method) => ( + + ))} +
+
+ + {/* iDEAL Bank Selection */} + {selectedMethod === 'ideal' && ( +
+ +
+ {idealBanks.map((bank) => ( + + ))} +
+
+ )} + + {/* Payment Method Info */} +
+
+
+ + + +
+
+
Secure Payment
+
+ {selectedMethod === 'card' && 'Your card details are encrypted and processed securely by Stripe.'} + {selectedMethod === 'ideal' && 'You will be redirected to your bank for secure authentication.'} + {selectedMethod === 'bank_transfer' && 'You will be redirected to your bank to authorize the payment.'} +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/PaymentModal.tsx b/frontend/src/components/PaymentModal.tsx new file mode 100644 index 0000000..34108a1 --- /dev/null +++ b/frontend/src/components/PaymentModal.tsx @@ -0,0 +1,275 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { useStripe, useElements, PaymentElement } from '@stripe/react-stripe-js'; +import axios from "axios"; +import PaymentMethodSelector from "./PaymentMethodSelector"; +import ErrorDisplay from "./ErrorDisplay"; +import { LoadingSpinner, LoadingButton } from "./LoadingStates"; +import { ErrorService } from "../services/ErrorService"; + +interface PaymentModalProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (paymentData: any) => void; + tokenQuantity: number; + packageId?: string; + packageName?: string; + calculation: { + basePrice: number; + discountPercentage: number; + finalPrice: number; + savings: number; + }; +} + +export default function PaymentModal({ + isOpen, + onClose, + onSuccess, + tokenQuantity, + packageId, + packageName, + calculation +}: PaymentModalProps) { + const stripe = useStripe(); + const elements = useElements(); + + const [paymentMethod, setPaymentMethod] = useState<'card' | 'ideal' | 'bank_transfer'>('card'); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [clientSecret, setClientSecret] = useState(null); + const [paymentIntentId, setPaymentIntentId] = useState(null); + const [availableMethods, setAvailableMethods] = useState([]); + + useEffect(() => { + if (isOpen && !clientSecret) { + fetchAvailableMethods(); + createPaymentIntent(); + } + }, [isOpen]); + + const fetchAvailableMethods = async () => { + try { + const token = localStorage.getItem("token"); + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_URL}/rest/payments/methods`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (response.data.success) { + setAvailableMethods(response.data.paymentMethods); + } + } catch (err) { + console.error("Failed to fetch payment methods:", err); + } + }; + + const createPaymentIntent = async () => { + try { + const token = localStorage.getItem("token"); + if (!token) { + setError("Authentication required"); + return; + } + + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_URL}/rest/payments/create-intent`, + { + packageId, + customQuantity: tokenQuantity, + paymentFlowType: paymentMethod, + }, + { + headers: { Authorization: `Bearer ${token}` } + } + ); + + if (response.data.success) { + setClientSecret(response.data.paymentIntent.client_secret); + setPaymentIntentId(response.data.paymentIntent.id); + setError(null); + } else { + setError(new Error("Failed to create payment intent")); + } + } catch (err: any) { + setError(err); + } + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + if (!stripe || !elements || !clientSecret) { + return; + } + + setIsProcessing(true); + setError(null); + + try { + const { error, paymentIntent } = await stripe.confirmPayment({ + elements, + confirmParams: { + return_url: `${window.location.origin}/payment/success`, + }, + redirect: 'if_required', + }); + + if (error) { + setError(error); + } else if (paymentIntent.status === 'succeeded') { + // Confirm payment on backend + await confirmPayment(paymentIntent.id); + onSuccess({ + paymentIntent, + tokensAllocated: tokenQuantity, + amount: calculation.finalPrice, + }); + onClose(); + } + } catch (err: any) { + setError(err); + } finally { + setIsProcessing(false); + } + }; + + const confirmPayment = async (paymentIntentId: string) => { + try { + const token = localStorage.getItem("token"); + await axios.post( + `${process.env.NEXT_PUBLIC_API_URL}/rest/payments/confirm`, + { paymentIntentId }, + { + headers: { Authorization: `Bearer ${token}` } + } + ); + } catch (err) { + console.error("Failed to confirm payment:", err); + } + }; + + const handleClose = () => { + if (!isProcessing) { + setError(null); + setClientSecret(null); + setPaymentIntentId(null); + onClose(); + } + }; + + if (!isOpen) return null; + + return ( +
+
+
+ {/* Header */} +
+

+ Complete Payment +

+ +
+ + {/* Order Summary */} +
+

Order Summary

+
+
+ + {packageName || `${tokenQuantity} Token${tokenQuantity > 1 ? 's' : ''}`} + + + €{calculation.finalPrice.toFixed(2)} + +
+ {calculation.savings > 0 && ( +
+ Discount ({calculation.discountPercentage}%) + -€{calculation.savings.toFixed(2)} +
+ )} +
+ Total + + €{calculation.finalPrice.toFixed(2)} + +
+
+
+ + {/* Payment Method Selection */} +
+ +
+ + {/* Payment Form */} + {clientSecret && ( +
+
+ +
+ + {error && ( + { + setError(null); + createPaymentIntent(); + }} + onDismiss={() => setError(null)} + showDetails={true} + /> + )} + +
+ + + Pay €{calculation.finalPrice.toFixed(2)} + +
+ + )} + + {!clientSecret && !error && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/frontend/src/components/PurchaseFlowProgress.tsx b/frontend/src/components/PurchaseFlowProgress.tsx new file mode 100644 index 0000000..841c224 --- /dev/null +++ b/frontend/src/components/PurchaseFlowProgress.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { PurchaseStep } from "../services/PurchaseFlowService"; + +interface PurchaseFlowProgressProps { + steps: PurchaseStep[]; + currentStep: number; + className?: string; +} + +export default function PurchaseFlowProgress({ + steps, + currentStep, + className = "" +}: PurchaseFlowProgressProps) { + return ( +
+
+ {steps.map((step, index) => ( +
+ {/* Step Circle */} +
+
+ {step.completed ? ( + + + + ) : ( + index + 1 + )} +
+
+ + {/* Step Content */} +
+
+

+ {step.title} +

+ {step.error && ( +
+ + + +
+ )} +
+

+ {step.description} +

+ {step.error && ( +

+ {step.error} +

+ )} +
+ + {/* Connector Line */} + {index < steps.length - 1 && ( +
+
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/components/StripeProvider.tsx b/frontend/src/components/StripeProvider.tsx new file mode 100644 index 0000000..de6cacf --- /dev/null +++ b/frontend/src/components/StripeProvider.tsx @@ -0,0 +1,19 @@ +"use client"; + +import { loadStripe } from '@stripe/stripe-js'; +import { Elements } from '@stripe/react-stripe-js'; +import { ReactNode } from 'react'; + +const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!); + +interface StripeProviderProps { + children: ReactNode; +} + +export default function StripeProvider({ children }: StripeProviderProps) { + return ( + + {children} + + ); +} diff --git a/frontend/src/components/TokenPurchaseFlow.tsx b/frontend/src/components/TokenPurchaseFlow.tsx new file mode 100644 index 0000000..433a639 --- /dev/null +++ b/frontend/src/components/TokenPurchaseFlow.tsx @@ -0,0 +1,278 @@ +"use client"; + +import { useState, useEffect } from "react"; +import StripeProvider from "./StripeProvider"; +import PaymentModal from "./PaymentModal"; +import CustomTokenCalculator from "./CustomTokenCalculator"; +import PurchaseFlowProgress from "./PurchaseFlowProgress"; +import { LoadingCard } from "./LoadingStates"; +import { PurchaseFlowService, PurchaseFlowState } from "../services/PurchaseFlowService"; +import { PricingCalculation } from "../services/PricingService"; +import axios from "axios"; + +interface TokenPackage { + id: string; + name: string; + description: string; + quantity: number; + price_per_token: number; + total_price: number; + discount_percentage: number; + is_popular: boolean; + is_active: boolean; +} + +// Remove duplicate interface - using from PricingService + +interface TokenPurchaseFlowProps { + isOpen: boolean; + onClose: () => void; + onSuccess: (data: any) => void; +} + +export default function TokenPurchaseFlow({ + isOpen, + onClose, + onSuccess +}: TokenPurchaseFlowProps) { + const [packages, setPackages] = useState([]); + const [selectedPackageId, setSelectedPackageId] = useState(''); + const [calculation, setCalculation] = useState(null); + const [showPaymentModal, setShowPaymentModal] = useState(false); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [flowState, setFlowState] = useState(PurchaseFlowService.initializeFlow()); + + useEffect(() => { + if (isOpen) { + fetchPackages(); + } + }, [isOpen]); + + const fetchPackages = async () => { + try { + setLoading(true); + const token = localStorage.getItem("token"); + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_URL}/rest/admin/token-packages`, + { headers: { Authorization: `Bearer ${token}` } } + ); + + if (response.data.success) { + setPackages(response.data.packages.filter((pkg: TokenPackage) => pkg.is_active)); + } + } catch (err: any) { + setError(err.response?.data?.message || "Failed to load packages"); + } finally { + setLoading(false); + } + }; + + const handleCalculationChange = (calc: PricingCalculation) => { + setCalculation(calc); + setFlowState(prev => PurchaseFlowService.setCalculation(prev, calc)); + }; + + const handlePackageSelect = (packageId: string) => { + setSelectedPackageId(packageId); + }; + + const handlePurchase = () => { + if (calculation && calculation.finalPrice > 0) { + setShowPaymentModal(true); + } + }; + + const handlePaymentSuccess = (paymentData: any) => { + setShowPaymentModal(false); + onSuccess(paymentData); + onClose(); + }; + + const handleClose = () => { + setSelectedPackageId(''); + setCalculation(null); + setError(null); + onClose(); + }; + + if (!isOpen) return null; + + return ( +
+
+
+ {/* Header */} +
+
+

+ Buy Interview Tokens +

+

+ Purchase tokens to conduct AI-powered interviews +

+
+ +
+ + {/* Progress Indicator */} +
+ +
+ + {loading ? ( + + ) : error ? ( +
+

{error}

+
+ ) : ( +
+ {/* Left Column - Calculator */} +
+

+ Choose Your Tokens +

+ +
+ + {/* Right Column - Packages & Summary */} +
+ {/* Popular Packages */} + {packages.length > 0 && ( +
+

+ Popular Packages +

+
+ {packages + .filter(pkg => pkg.is_popular) + .map((pkg) => ( +
handlePackageSelect(pkg.id)} + > +
+
+
+ {pkg.name} +
+
+ {pkg.description} +
+
+ {pkg.discount_percentage}% discount +
+
+
+
+ €{pkg.total_price.toFixed(2)} +
+
+ €{pkg.price_per_token.toFixed(2)} per token +
+
+
+
+ ))} +
+
+ )} + + {/* Purchase Summary */} + {calculation && ( +
+

+ Purchase Summary +

+
+
+ + {calculation.packageName || `${calculation.quantity} Token${calculation.quantity > 1 ? 's' : ''}`} + + + €{calculation.finalPrice.toFixed(2)} + +
+ {calculation.savings > 0 && ( +
+ Discount ({calculation.discountPercentage}%) + -€{calculation.savings.toFixed(2)} +
+ )} +
+ Total + + €{calculation.finalPrice.toFixed(2)} + +
+
+ + +
+ )} + + {/* Features */} +
+

+ What you get: +

+
    +
  • • AI-powered interview questions
  • +
  • • Real-time candidate evaluation
  • +
  • • Detailed interview reports
  • +
  • • No expiration date
  • +
  • • Instant activation
  • +
+
+
+
+ )} + + {/* Payment Modal */} + {showPaymentModal && calculation && ( + + setShowPaymentModal(false)} + onSuccess={handlePaymentSuccess} + tokenQuantity={calculation.quantity} + packageId={calculation.packageId} + packageName={calculation.packageName} + calculation={calculation} + /> + + )} +
+
+
+ ); +} diff --git a/frontend/src/components/index.ts b/frontend/src/components/index.ts index b04e20e..79c6f0a 100644 --- a/frontend/src/components/index.ts +++ b/frontend/src/components/index.ts @@ -6,6 +6,19 @@ export { default as Layout } from './Layout'; export { default as CreateJobModal } from './CreateJobModal'; export { default as ThemeToggle } from './ThemeToggle'; +// Payment components +export { default as StripeProvider } from './StripeProvider'; +export { default as PaymentModal } from './PaymentModal'; +export { default as TokenPurchaseFlow } from './TokenPurchaseFlow'; +export { default as PaymentHistory } from './PaymentHistory'; +export { default as CustomTokenCalculator } from './CustomTokenCalculator'; +export { default as PaymentMethodSelector } from './PaymentMethodSelector'; +export { default as PurchaseFlowProgress } from './PurchaseFlowProgress'; +export { default as ErrorDisplay } from './ErrorDisplay'; + +// Loading and UI components +export * from './LoadingStates'; + // Landing page components export { default as AnimatedCounter } from './AnimatedCounter'; export { default as FeatureCard } from './FeatureCard'; diff --git a/frontend/src/services/ErrorService.ts b/frontend/src/services/ErrorService.ts new file mode 100644 index 0000000..f15fc02 --- /dev/null +++ b/frontend/src/services/ErrorService.ts @@ -0,0 +1,336 @@ +export interface ErrorInfo { + code: string; + message: string; + details?: string; + action?: string; + retryable: boolean; + category: 'payment' | 'network' | 'validation' | 'auth' | 'system'; +} + +export class ErrorService { + private static readonly ERROR_MESSAGES: Record = { + // Payment Errors + 'PAYMENT_FAILED': { + code: 'PAYMENT_FAILED', + message: 'Payment could not be processed', + details: 'There was an issue processing your payment. Please try again or use a different payment method.', + action: 'Try again with a different payment method', + retryable: true, + category: 'payment' + }, + 'INSUFFICIENT_FUNDS': { + code: 'INSUFFICIENT_FUNDS', + message: 'Insufficient funds', + details: 'Your payment method does not have enough funds to complete this transaction.', + action: 'Check your account balance or use a different payment method', + retryable: true, + category: 'payment' + }, + 'CARD_DECLINED': { + code: 'CARD_DECLINED', + message: 'Card was declined', + details: 'Your card was declined by your bank. This could be due to insufficient funds or security restrictions.', + action: 'Contact your bank or try a different card', + retryable: true, + category: 'payment' + }, + 'EXPIRED_CARD': { + code: 'EXPIRED_CARD', + message: 'Card has expired', + details: 'The card you are trying to use has expired.', + action: 'Use a different card or update your card information', + retryable: true, + category: 'payment' + }, + 'INVALID_CVC': { + code: 'INVALID_CVC', + message: 'Invalid security code', + details: 'The security code (CVC) you entered is incorrect.', + action: 'Check and re-enter your security code', + retryable: true, + category: 'payment' + }, + 'PROCESSING_ERROR': { + code: 'PROCESSING_ERROR', + message: 'Payment processing error', + details: 'An unexpected error occurred while processing your payment.', + action: 'Please try again in a few moments', + retryable: true, + category: 'payment' + }, + + // Network Errors + 'NETWORK_ERROR': { + code: 'NETWORK_ERROR', + message: 'Connection error', + details: 'Unable to connect to our servers. Please check your internet connection.', + action: 'Check your internet connection and try again', + retryable: true, + category: 'network' + }, + 'TIMEOUT': { + code: 'TIMEOUT', + message: 'Request timed out', + details: 'The request took too long to complete. This might be due to a slow connection.', + action: 'Try again with a better connection', + retryable: true, + category: 'network' + }, + 'SERVER_ERROR': { + code: 'SERVER_ERROR', + message: 'Server error', + details: 'Our servers are experiencing issues. Please try again later.', + action: 'Try again in a few minutes', + retryable: true, + category: 'network' + }, + + // Validation Errors + 'INVALID_QUANTITY': { + code: 'INVALID_QUANTITY', + message: 'Invalid token quantity', + details: 'The number of tokens you entered is not valid.', + action: 'Enter a number between 1 and 1000', + retryable: false, + category: 'validation' + }, + 'INVALID_AMOUNT': { + code: 'INVALID_AMOUNT', + message: 'Invalid amount', + details: 'The payment amount is not valid.', + action: 'Please refresh the page and try again', + retryable: false, + category: 'validation' + }, + 'MISSING_REQUIRED_FIELD': { + code: 'MISSING_REQUIRED_FIELD', + message: 'Required information missing', + details: 'Please fill in all required fields.', + action: 'Complete all required fields and try again', + retryable: false, + category: 'validation' + }, + + // Authentication Errors + 'UNAUTHORIZED': { + code: 'UNAUTHORIZED', + message: 'Authentication required', + details: 'You need to be logged in to make a purchase.', + action: 'Please log in and try again', + retryable: false, + category: 'auth' + }, + 'TOKEN_EXPIRED': { + code: 'TOKEN_EXPIRED', + message: 'Session expired', + details: 'Your session has expired. Please log in again.', + action: 'Please log in again', + retryable: false, + category: 'auth' + }, + 'INSUFFICIENT_PERMISSIONS': { + code: 'INSUFFICIENT_PERMISSIONS', + message: 'Access denied', + details: 'You do not have permission to perform this action.', + action: 'Contact support if you believe this is an error', + retryable: false, + category: 'auth' + }, + + // System Errors + 'STRIPE_ERROR': { + code: 'STRIPE_ERROR', + message: 'Payment system error', + details: 'There was an issue with our payment processor.', + action: 'Please try again or contact support', + retryable: true, + category: 'system' + }, + 'UNKNOWN_ERROR': { + code: 'UNKNOWN_ERROR', + message: 'Something went wrong', + details: 'An unexpected error occurred. Please try again.', + action: 'Try again or contact support if the problem persists', + retryable: true, + category: 'system' + } + }; + + /** + * Parse an error and return structured error information + */ + static parseError(error: any): ErrorInfo { + // Handle Axios errors + if (error.response) { + const status = error.response.status; + const data = error.response.data; + + // Try to get error code from response + if (data?.error) { + const errorCode = data.error.toUpperCase(); + if (this.ERROR_MESSAGES[errorCode]) { + return this.ERROR_MESSAGES[errorCode]; + } + } + + // Handle HTTP status codes + switch (status) { + case 400: + return this.ERROR_MESSAGES['INVALID_AMOUNT']; + case 401: + return this.ERROR_MESSAGES['UNAUTHORIZED']; + case 403: + return this.ERROR_MESSAGES['INSUFFICIENT_PERMISSIONS']; + case 408: + return this.ERROR_MESSAGES['TIMEOUT']; + case 500: + return this.ERROR_MESSAGES['SERVER_ERROR']; + default: + return this.ERROR_MESSAGES['UNKNOWN_ERROR']; + } + } + + // Handle network errors + if (error.request) { + return this.ERROR_MESSAGES['NETWORK_ERROR']; + } + + // Handle Stripe errors + if (error.type) { + switch (error.type) { + case 'card_error': + return this.ERROR_MESSAGES['CARD_DECLINED']; + case 'validation_error': + return this.ERROR_MESSAGES['INVALID_AMOUNT']; + case 'api_error': + return this.ERROR_MESSAGES['STRIPE_ERROR']; + default: + return this.ERROR_MESSAGES['STRIPE_ERROR']; + } + } + + // Handle validation errors + if (error.message) { + const message = error.message.toLowerCase(); + if (message.includes('quantity')) { + return this.ERROR_MESSAGES['INVALID_QUANTITY']; + } + if (message.includes('amount')) { + return this.ERROR_MESSAGES['INVALID_AMOUNT']; + } + if (message.includes('required')) { + return this.ERROR_MESSAGES['MISSING_REQUIRED_FIELD']; + } + } + + // Default to unknown error + return this.ERROR_MESSAGES['UNKNOWN_ERROR']; + } + + /** + * Get user-friendly error message + */ + static getErrorMessage(error: any): string { + const errorInfo = this.parseError(error); + return errorInfo.message; + } + + /** + * Get detailed error information + */ + static getErrorDetails(error: any): string { + const errorInfo = this.parseError(error); + return errorInfo.details || errorInfo.message; + } + + /** + * Get suggested action for error + */ + static getSuggestedAction(error: any): string { + const errorInfo = this.parseError(error); + return errorInfo.action || 'Please try again'; + } + + /** + * Check if error is retryable + */ + static isRetryable(error: any): boolean { + const errorInfo = this.parseError(error); + return errorInfo.retryable; + } + + /** + * Get error category + */ + static getErrorCategory(error: any): string { + const errorInfo = this.parseError(error); + return errorInfo.category; + } + + /** + * Format error for display + */ + static formatError(error: any): { + title: string; + message: string; + action: string; + retryable: boolean; + category: string; + } { + const errorInfo = this.parseError(error); + return { + title: errorInfo.message, + message: errorInfo.details || errorInfo.message, + action: errorInfo.action || 'Please try again', + retryable: errorInfo.retryable, + category: errorInfo.category + }; + } + + /** + * Get retry delay based on error type + */ + static getRetryDelay(error: any): number { + const category = this.getErrorCategory(error); + + switch (category) { + case 'network': + return 2000; // 2 seconds + case 'payment': + return 5000; // 5 seconds + case 'system': + return 10000; // 10 seconds + default: + return 3000; // 3 seconds + } + } + + /** + * Check if error should be logged + */ + static shouldLogError(error: any): boolean { + const category = this.getErrorCategory(error); + return category !== 'validation' && category !== 'auth'; + } + + /** + * Get error severity level + */ + static getErrorSeverity(error: any): 'low' | 'medium' | 'high' | 'critical' { + const category = this.getErrorCategory(error); + + switch (category) { + case 'validation': + return 'low'; + case 'auth': + return 'medium'; + case 'payment': + return 'high'; + case 'network': + case 'system': + return 'critical'; + default: + return 'medium'; + } + } +} diff --git a/frontend/src/services/PricingService.ts b/frontend/src/services/PricingService.ts new file mode 100644 index 0000000..cb89ed1 --- /dev/null +++ b/frontend/src/services/PricingService.ts @@ -0,0 +1,329 @@ +export interface TokenPackage { + id: string; + name: string; + description: string; + quantity: number; + price_per_token: number; + total_price: number; + discount_percentage: number; + is_popular: boolean; + is_active: boolean; +} + +export interface PricingCalculation { + quantity: number; + basePrice: number; + discountPercentage: number; + finalPrice: number; + savings: number; + packageId?: string; + packageName?: string; + isCustomQuantity: boolean; + recommendedPackage?: TokenPackage; +} + +export interface PricingTier { + minQuantity: number; + maxQuantity: number; + discountPercentage: number; + name: string; + description: string; +} + +export class PricingService { + public static readonly BASE_PRICE_PER_TOKEN = 5.00; + public static readonly CURRENCY = 'EUR'; + + // Define pricing tiers for volume discounts + private static readonly PRICING_TIERS: PricingTier[] = [ + { + minQuantity: 1, + maxQuantity: 4, + discountPercentage: 0, + name: 'Individual', + description: 'Perfect for testing' + }, + { + minQuantity: 5, + maxQuantity: 9, + discountPercentage: 10, + name: 'Starter', + description: 'Small recruitment needs' + }, + { + minQuantity: 10, + maxQuantity: 24, + discountPercentage: 20, + name: 'Professional', + description: 'Regular recruiters' + }, + { + minQuantity: 25, + maxQuantity: 49, + discountPercentage: 30, + name: 'Business', + description: 'Growing teams' + }, + { + minQuantity: 50, + maxQuantity: 99, + discountPercentage: 40, + name: 'Enterprise', + description: 'Large organizations' + }, + { + minQuantity: 100, + maxQuantity: Infinity, + discountPercentage: 50, + name: 'Corporate', + description: 'Enterprise scale' + } + ]; + + /** + * Calculate the best price for a given quantity of tokens + */ + static calculatePrice( + quantity: number, + packages: TokenPackage[], + selectedPackageId?: string + ): PricingCalculation { + if (quantity <= 0) { + return this.getEmptyCalculation(); + } + + // If a specific package is selected, use its pricing + if (selectedPackageId) { + const selectedPackage = packages.find(pkg => pkg.id === selectedPackageId); + if (selectedPackage) { + return this.calculatePackagePrice(quantity, selectedPackage); + } + } + + // Find the best package for the given quantity + const bestPackage = this.findBestPackage(quantity, packages); + + if (bestPackage) { + return this.calculatePackagePrice(quantity, bestPackage); + } + + // If no package is suitable, use tier-based pricing + return this.calculateTierBasedPrice(quantity); + } + + /** + * Calculate price using a specific package + */ + private static calculatePackagePrice(quantity: number, pkg: TokenPackage): PricingCalculation { + const basePrice = quantity * this.BASE_PRICE_PER_TOKEN; + const packagePrice = quantity * pkg.price_per_token; + const discountAmount = (packagePrice * pkg.discount_percentage) / 100; + const finalPrice = packagePrice - discountAmount; + const savings = basePrice - finalPrice; + + return { + quantity, + basePrice, + discountPercentage: pkg.discount_percentage, + finalPrice, + savings, + packageId: pkg.id, + packageName: pkg.name, + isCustomQuantity: quantity !== pkg.quantity, + }; + } + + /** + * Calculate price using tier-based discounts + */ + private static calculateTierBasedPrice(quantity: number): PricingCalculation { + const basePrice = quantity * this.BASE_PRICE_PER_TOKEN; + const tier = this.getTierForQuantity(quantity); + const discountAmount = (basePrice * tier.discountPercentage) / 100; + const finalPrice = basePrice - discountAmount; + const savings = discountAmount; + + return { + quantity, + basePrice, + discountPercentage: tier.discountPercentage, + finalPrice, + savings, + isCustomQuantity: true, + }; + } + + /** + * Find the best package for a given quantity + */ + private static findBestPackage(quantity: number, packages: TokenPackage[]): TokenPackage | null { + const suitablePackages = packages + .filter(pkg => pkg.is_active && quantity >= pkg.quantity) + .sort((a, b) => b.quantity - a.quantity); // Sort by quantity descending + + if (suitablePackages.length === 0) { + return null; + } + + // Find the package that gives the best price + let bestPackage = null; + let bestPrice = quantity * this.BASE_PRICE_PER_TOKEN; + + for (const pkg of suitablePackages) { + const packagePrice = quantity * pkg.price_per_token; + const discountAmount = (packagePrice * pkg.discount_percentage) / 100; + const finalPrice = packagePrice - discountAmount; + + if (finalPrice < bestPrice) { + bestPackage = pkg; + bestPrice = finalPrice; + } + } + + return bestPackage; + } + + /** + * Get the pricing tier for a given quantity + */ + private static getTierForQuantity(quantity: number): PricingTier { + return this.PRICING_TIERS.find(tier => + quantity >= tier.minQuantity && quantity <= tier.maxQuantity + ) || this.PRICING_TIERS[0]; + } + + /** + * Get all available pricing tiers + */ + static getPricingTiers(): PricingTier[] { + return this.PRICING_TIERS; + } + + /** + * Get recommended packages for a given quantity + */ + static getRecommendedPackages(quantity: number, packages: TokenPackage[]): TokenPackage[] { + return packages + .filter(pkg => pkg.is_active && pkg.quantity <= quantity) + .sort((a, b) => b.quantity - a.quantity) + .slice(0, 3); // Return top 3 recommendations + } + + /** + * Calculate savings compared to individual token price + */ + static calculateSavings(quantity: number, finalPrice: number): { + absolute: number; + percentage: number; + } { + const individualPrice = quantity * this.BASE_PRICE_PER_TOKEN; + const absolute = individualPrice - finalPrice; + const percentage = individualPrice > 0 ? (absolute / individualPrice) * 100 : 0; + + return { + absolute: Math.max(0, absolute), + percentage: Math.max(0, percentage) + }; + } + + /** + * Format price for display + */ + static formatPrice(amount: number): string { + return new Intl.NumberFormat('en-EU', { + style: 'currency', + currency: this.CURRENCY + }).format(amount); + } + + /** + * Get price per token for a given calculation + */ + static getPricePerToken(calculation: PricingCalculation): number { + return calculation.quantity > 0 ? calculation.finalPrice / calculation.quantity : 0; + } + + /** + * Compare two pricing calculations + */ + static compareCalculations(a: PricingCalculation, b: PricingCalculation): { + better: 'a' | 'b' | 'equal'; + savings: number; + } { + const savings = a.finalPrice - b.finalPrice; + + if (Math.abs(savings) < 0.01) { + return { better: 'equal', savings: 0 }; + } + + return { + better: savings < 0 ? 'a' : 'b', + savings: Math.abs(savings) + }; + } + + /** + * Get empty calculation for error states + */ + private static getEmptyCalculation(): PricingCalculation { + return { + quantity: 0, + basePrice: 0, + discountPercentage: 0, + finalPrice: 0, + savings: 0, + isCustomQuantity: false, + }; + } + + /** + * Validate quantity constraints + */ + static validateQuantity(quantity: number): { + isValid: boolean; + error?: string; + } { + if (quantity <= 0) { + return { isValid: false, error: 'Quantity must be greater than 0' }; + } + + if (quantity > 1000) { + return { isValid: false, error: 'Maximum quantity is 1000 tokens' }; + } + + if (!Number.isInteger(quantity)) { + return { isValid: false, error: 'Quantity must be a whole number' }; + } + + return { isValid: true }; + } + + /** + * Get pricing summary for display + */ + static getPricingSummary(calculation: PricingCalculation): { + title: string; + subtitle: string; + highlights: string[]; + } { + const highlights: string[] = []; + + if (calculation.savings > 0) { + highlights.push(`Save ${this.formatPrice(calculation.savings)}`); + } + + if (calculation.discountPercentage > 0) { + highlights.push(`${calculation.discountPercentage}% discount`); + } + + if (calculation.packageName) { + highlights.push(`From ${calculation.packageName} package`); + } + + return { + title: `${calculation.quantity} Token${calculation.quantity > 1 ? 's' : ''}`, + subtitle: calculation.isCustomQuantity ? 'Custom quantity' : 'Package deal', + highlights + }; + } +} diff --git a/frontend/src/services/PurchaseFlowService.ts b/frontend/src/services/PurchaseFlowService.ts new file mode 100644 index 0000000..d74689d --- /dev/null +++ b/frontend/src/services/PurchaseFlowService.ts @@ -0,0 +1,321 @@ +import { PricingCalculation } from './PricingService'; + +export interface PurchaseStep { + id: string; + title: string; + description: string; + completed: boolean; + current: boolean; + error?: string; +} + +export interface PurchaseFlowState { + currentStep: number; + steps: PurchaseStep[]; + calculation: PricingCalculation | null; + paymentIntentId: string | null; + clientSecret: string | null; + error: any; + isProcessing: boolean; +} + +export class PurchaseFlowService { + private static readonly STEPS: Omit[] = [ + { + id: 'quantity-selection', + title: 'Select Tokens', + description: 'Choose the number of tokens you want to purchase' + }, + { + id: 'pricing-calculation', + title: 'Calculate Price', + description: 'Review pricing and available discounts' + }, + { + id: 'payment-method', + title: 'Payment Method', + description: 'Select your preferred payment method' + }, + { + id: 'payment-processing', + title: 'Process Payment', + description: 'Complete your payment securely' + }, + { + id: 'confirmation', + title: 'Confirmation', + description: 'Payment successful and tokens allocated' + } + ]; + + /** + * Initialize the purchase flow + */ + static initializeFlow(): PurchaseFlowState { + const steps = this.STEPS.map((step, index) => ({ + ...step, + completed: false, + current: index === 0, + error: undefined + })); + + return { + currentStep: 0, + steps, + calculation: null, + paymentIntentId: null, + clientSecret: null, + error: null, + isProcessing: false + }; + } + + /** + * Move to the next step + */ + static nextStep(state: PurchaseFlowState): PurchaseFlowState { + const newState = { ...state }; + + if (newState.currentStep < newState.steps.length - 1) { + // Mark current step as completed + newState.steps[newState.currentStep] = { + ...newState.steps[newState.currentStep], + completed: true, + current: false + }; + + // Move to next step + newState.currentStep += 1; + newState.steps[newState.currentStep] = { + ...newState.steps[newState.currentStep], + current: true + }; + } + + return newState; + } + + /** + * Move to the previous step + */ + static previousStep(state: PurchaseFlowState): PurchaseFlowState { + const newState = { ...state }; + + if (newState.currentStep > 0) { + // Mark current step as not current + newState.steps[newState.currentStep] = { + ...newState.steps[newState.currentStep], + current: false + }; + + // Move to previous step + newState.currentStep -= 1; + newState.steps[newState.currentStep] = { + ...newState.steps[newState.currentStep], + current: true, + completed: false // Allow editing previous step + }; + } + + return newState; + } + + /** + * Jump to a specific step + */ + static goToStep(state: PurchaseFlowState, stepIndex: number): PurchaseFlowState { + const newState = { ...state }; + + if (stepIndex >= 0 && stepIndex < newState.steps.length) { + // Reset all steps + newState.steps = newState.steps.map((step, index) => ({ + ...step, + completed: index < stepIndex, + current: index === stepIndex, + error: undefined + })); + + newState.currentStep = stepIndex; + } + + return newState; + } + + /** + * Set error for current step + */ + static setStepError(state: PurchaseFlowState, error: any): PurchaseFlowState { + const newState = { ...state }; + + newState.steps[newState.currentStep] = { + ...newState.steps[newState.currentStep], + error: error?.message || 'An error occurred' + }; + + newState.error = error; + return newState; + } + + /** + * Clear error for current step + */ + static clearStepError(state: PurchaseFlowState): PurchaseFlowState { + const newState = { ...state }; + + newState.steps[newState.currentStep] = { + ...newState.steps[newState.currentStep], + error: undefined + }; + + newState.error = null; + return newState; + } + + /** + * Set processing state + */ + static setProcessing(state: PurchaseFlowState, isProcessing: boolean): PurchaseFlowState { + return { + ...state, + isProcessing + }; + } + + /** + * Set calculation data + */ + static setCalculation(state: PurchaseFlowState, calculation: PricingCalculation): PurchaseFlowState { + return { + ...state, + calculation + }; + } + + /** + * Set payment intent data + */ + static setPaymentIntent(state: PurchaseFlowState, paymentIntentId: string, clientSecret: string): PurchaseFlowState { + return { + ...state, + paymentIntentId, + clientSecret + }; + } + + /** + * Complete the purchase flow + */ + static completeFlow(state: PurchaseFlowState): PurchaseFlowState { + const newState = { ...state }; + + // Mark all steps as completed + newState.steps = newState.steps.map(step => ({ + ...step, + completed: true, + current: false + })); + + newState.isProcessing = false; + newState.error = null; + + return newState; + } + + /** + * Reset the flow to initial state + */ + static resetFlow(): PurchaseFlowState { + return this.initializeFlow(); + } + + /** + * Get current step info + */ + static getCurrentStep(state: PurchaseFlowState): PurchaseStep | null { + return state.steps[state.currentStep] || null; + } + + /** + * Check if flow can proceed to next step + */ + static canProceed(state: PurchaseFlowState): boolean { + const currentStep = this.getCurrentStep(state); + if (!currentStep) return false; + + switch (currentStep.id) { + case 'quantity-selection': + return state.calculation !== null; + case 'pricing-calculation': + return state.calculation !== null; + case 'payment-method': + return true; // Payment method selection is always valid + case 'payment-processing': + return state.clientSecret !== null; + case 'confirmation': + return true; // Confirmation step is always valid + default: + return false; + } + } + + /** + * Get progress percentage + */ + static getProgress(state: PurchaseFlowState): number { + const completedSteps = state.steps.filter(step => step.completed).length; + return (completedSteps / state.steps.length) * 100; + } + + /** + * Check if flow is completed + */ + static isCompleted(state: PurchaseFlowState): boolean { + return state.steps.every(step => step.completed); + } + + /** + * Get step by ID + */ + static getStepById(state: PurchaseFlowState, stepId: string): PurchaseStep | null { + return state.steps.find(step => step.id === stepId) || null; + } + + /** + * Validate current step + */ + static validateCurrentStep(state: PurchaseFlowState): { + isValid: boolean; + error?: string; + } { + const currentStep = this.getCurrentStep(state); + if (!currentStep) { + return { isValid: false, error: 'Invalid step' }; + } + + switch (currentStep.id) { + case 'quantity-selection': + if (!state.calculation) { + return { isValid: false, error: 'Please select a quantity' }; + } + break; + case 'pricing-calculation': + if (!state.calculation) { + return { isValid: false, error: 'Please calculate pricing first' }; + } + break; + case 'payment-method': + // Payment method selection is always valid + break; + case 'payment-processing': + if (!state.clientSecret) { + return { isValid: false, error: 'Payment intent not created' }; + } + break; + case 'confirmation': + // Confirmation step is always valid + break; + } + + return { isValid: true }; + } +} diff --git a/payment_todo.md b/payment_todo.md new file mode 100644 index 0000000..772198e --- /dev/null +++ b/payment_todo.md @@ -0,0 +1,374 @@ +# Stripe Payment Integration - Implementation Plan + +## Overview +Integration of Stripe payment system with iDEAL and bank transfer support for token purchases. Users must be registered to purchase tokens, with both predefined packages and custom token amounts supported. + +## Key Requirements +- ✅ User must be registered to buy tokens +- ✅ Purchase modal on user's dashboard/profile page +- ✅ Support for predefined token packages +- ✅ Support for custom token amounts +- ✅ Dynamic pricing based on package discounts +- ✅ Support for iDEAL and bank transfer payments + +--- + +## Phase 1: Backend Foundation ✅ COMPLETED + +### 1.1 Dependencies & Configuration ✅ +- [x] Install Stripe SDK: `npm install stripe @types/stripe` +- [x] Add Stripe environment variables to `.env`: + ```env + STRIPE_PUBLISHABLE_KEY=pk_test_... + STRIPE_SECRET_KEY=sk_test_... + STRIPE_WEBHOOK_SECRET=whsec_... + ``` +- [x] Update `env.example` with Stripe configuration + +### 1.2 Database Schema Updates ✅ +- [x] Create migration script for `payment_records` table updates: + - [x] Add `stripe_payment_intent_id` (VARCHAR(255)) + - [x] Add `stripe_payment_method_id` (VARCHAR(255)) + - [x] Add `stripe_customer_id` (VARCHAR(255)) + - [x] Add `payment_flow_type` (ENUM: 'card', 'ideal', 'bank_transfer') + - [x] Add `stripe_metadata` (JSON) + - [x] Add `refund_reason` (TEXT) + - [x] Add `refunded_amount` (DECIMAL(10,2)) + - [x] Add `custom_quantity` (INT) - for custom token purchases + - [x] Add `applied_discount_percentage` (DECIMAL(5,2)) - discount applied +- [x] Create migration script for `users` table: + - [x] Add `stripe_customer_id` (VARCHAR(255)) + +### 1.3 New Services ✅ + +#### StripeService (`backend/src/services/StripeService.ts`) ✅ +- [x] Create Stripe client initialization +- [x] `createPaymentIntent(amount, currency, metadata)` - Create payment intent +- [x] `createCustomer(userId, email, name)` - Create Stripe customer +- [x] `confirmPaymentIntent(paymentIntentId)` - Confirm payment +- [x] `createRefund(paymentIntentId, amount, reason)` - Process refunds +- [x] `getPaymentMethods(customerId)` - Get saved payment methods +- [x] `createSetupIntent(customerId)` - For saving payment methods +- [x] `verifyWebhookSignature(payload, signature)` - Webhook verification +- [x] `getAvailablePaymentMethods(countryCode)` - Get payment methods by region +- [x] `getIdealConfiguration()` - iDEAL bank configuration + +#### PaymentService (`backend/src/services/PaymentService.ts`) ✅ +- [x] `createPaymentRecord(userId, packageId, customQuantity, amount)` - Create payment record +- [x] `processSuccessfulPayment(paymentIntentId)` - Handle successful payment +- [x] `processFailedPayment(paymentIntentId, reason)` - Handle failed payment +- [x] `calculateTokenPrice(quantity, packageId)` - Calculate price with discounts +- [x] `allocateTokens(userId, quantity, paymentId)` - Add tokens to user account +- [x] `getUserPaymentHistory(userId)` - Get user's payment history +- [x] `refundPayment(paymentId, amount, reason)` - Process refunds +- [x] `getPaymentStatistics()` - Get payment analytics + +### 1.4 New API Controllers ✅ + +#### PaymentController (`backend/src/controllers/rest/PaymentController.ts`) ✅ +- [x] `POST /api/payments/calculate-price` - Calculate token pricing +- [x] `POST /api/payments/create-intent` - Create payment intent + - [x] Validate user authentication + - [x] Calculate pricing (package vs custom) + - [x] Create Stripe payment intent + - [x] Save payment record as 'pending' +- [x] `POST /api/payments/confirm` - Confirm payment completion + - [x] Verify payment intent + - [x] Update payment record + - [x] Allocate tokens to user +- [x] `GET /api/payments/methods` - Get available payment methods +- [x] `GET /api/payments/history` - Get user payment history +- [x] `GET /api/payments/:id` - Get specific payment details +- [x] `POST /api/payments/:id/refund` - Process refunds (admin only) +- [x] `POST /api/payments/:id/cancel` - Cancel payment +- [x] `GET /api/payments/admin/statistics` - Payment statistics (admin) + +#### WebhookController (`backend/src/controllers/rest/WebhookController.ts`) ✅ +- [x] `POST /api/webhooks/stripe` - Handle Stripe webhooks + - [x] Verify webhook signature + - [x] Process `payment_intent.succeeded` + - [x] Process `payment_intent.payment_failed` + - [x] Process `payment_intent.cancelled` + - [x] Process `charge.dispute.created` + - [x] Process `customer.created` + - [x] Process `invoice.payment_succeeded` +- [x] `POST /api/webhooks/stripe/health` - Health check endpoint + +### 1.5 Update Existing Services ✅ +- [x] Update `TokenService.ts`: + - [x] Add `calculateCustomTokenPrice(quantity)` method + - [x] Add `getBestPackageForQuantity(quantity)` method + - [x] Add `addTokensToUserFromPayment()` method +- [x] Update `UserService.ts`: + - [x] Add `getUserPaymentHistory(userId)` method + - [x] Add `getUserByStripeCustomerId()` method + - [x] Add `updateUserStripeCustomerId()` method + - [x] Add `getUserPaymentStatistics()` method + +--- + +## Phase 2: Frontend Integration ✅ COMPLETED + +### 2.1 Dependencies & Configuration ✅ +- [x] Install Stripe React: `npm install @stripe/stripe-js @stripe/react-stripe-js` +- [x] Add Stripe publishable key to environment variables +- [x] Configure Stripe provider in app layout + +### 2.2 New Components ✅ + +#### PaymentModal (`frontend/src/components/PaymentModal.tsx`) ✅ +- [x] Payment method selection (Card, iDEAL, Bank Transfer) +- [x] Token quantity input (custom amount) +- [x] Package selection with discount display +- [x] Price calculation and display +- [x] Payment form with Stripe Elements +- [x] Loading states and error handling + +#### TokenPurchaseFlow (`frontend/src/components/TokenPurchaseFlow.tsx`) ✅ +- [x] Package vs custom amount selection +- [x] Dynamic pricing calculation +- [x] Payment processing flow +- [x] Success/failure handling +- [x] Token allocation confirmation + +#### PaymentHistory (`frontend/src/components/PaymentHistory.tsx`) ✅ +- [x] List of past payments +- [x] Payment status indicators +- [x] Receipt download links +- [x] Refund information (if applicable) + +#### CustomTokenCalculator (`frontend/src/components/CustomTokenCalculator.tsx`) ✅ +- [x] Quantity input with validation +- [x] Real-time price calculation +- [x] Discount percentage display +- [x] Package comparison tooltips + +#### StripeProvider (`frontend/src/components/StripeProvider.tsx`) ✅ +- [x] Stripe Elements provider wrapper +- [x] Environment configuration + +### 2.3 Update Existing Components ✅ + +#### Dashboard Page (`frontend/src/app/dashboard/page.tsx`) +- [x] Add "Buy Tokens" button/CTA +- [x] Display current token balance prominently +- [x] Add payment history section + +#### Header Component (`frontend/src/components/Header.tsx`) ✅ +- [x] Add "Buy Tokens" button in header +- [x] Add payment history button +- [x] Update token display with purchase option +- [x] Integrate payment modals + +#### Layout (`frontend/src/app/layout.tsx`) ✅ +- [x] Add StripeProvider wrapper +- [x] Configure Stripe environment + +### 2.4 New Pages ✅ + +#### Payment Success Page (`frontend/src/app/payment/success/page.tsx`) ✅ +- [x] Payment confirmation +- [x] Token allocation summary +- [x] Next steps guidance + +#### Payment Failed Page (`frontend/src/app/payment/failed/page.tsx`) ✅ +- [x] Error explanation +- [x] Retry options +- [x] Support contact information + +--- + +## Phase 3: Payment Methods Implementation ✅ COMPLETED + +### 3.1 Credit/Debit Cards ✅ +- [x] Stripe Elements integration +- [x] Card validation +- [x] 3D Secure authentication +- [x] Real-time payment confirmation + +### 3.2 iDEAL (Netherlands) ✅ +- [x] iDEAL bank selection interface +- [x] Redirect-based payment flow +- [x] Webhook confirmation handling +- [x] Bank-specific error handling + +### 3.3 Bank Transfer (SEPA) ✅ +- [x] SEPA Direct Debit setup +- [x] Mandate collection form +- [x] Delayed payment confirmation +- [x] Webhook-based status updates + +--- + +## Phase 4: Pricing & Discount Logic ✅ COMPLETED + +### 4.1 Dynamic Pricing System ✅ +- [x] Create `PricingService.ts`: + - [x] `calculatePrice(quantity, packageId?)` - Calculate final price + - [x] `getBestDiscount(quantity)` - Find best applicable discount + - [x] `getPackageRecommendation(quantity)` - Recommend best package +- [x] Update token packages to support custom quantities +- [x] Implement tiered pricing logic + +### 4.2 Discount Application ✅ +- [x] Package-based discounts for predefined quantities +- [x] Volume discounts for custom quantities +- [x] Display savings compared to single token price +- [x] Clear pricing breakdown in UI + +--- + +## Phase 5: User Experience & UI ✅ COMPLETED + +### 5.1 Purchase Flow Design ✅ +- [x] **Step 1**: Token quantity selection (package or custom) +- [x] **Step 2**: Payment method selection +- [x] **Step 3**: Payment details and confirmation +- [x] **Step 4**: Payment processing with progress indicator +- [x] **Step 5**: Success confirmation with token allocation + +### 5.2 Error Handling ✅ +- [x] Payment failure messages +- [x] Network error handling +- [x] Timeout handling +- [x] Retry mechanisms +- [x] Support contact integration + +### 5.3 Loading States ✅ +- [x] Payment processing indicators +- [x] Progress tracking +- [x] Skeleton loaders +- [x] Disabled states during processing + +--- + +## Phase 6: Testing & Quality Assurance + +### 6.1 Stripe Test Mode +- [ ] Test payment methods with Stripe test cards +- [ ] Test iDEAL flow with test banks +- [ ] Test SEPA with test accounts +- [ ] Webhook testing with Stripe CLI + +### 6.2 Integration Testing +- [ ] End-to-end payment flows +- [ ] Webhook processing verification +- [ ] Token allocation testing +- [ ] Error scenario testing + +### 6.3 User Acceptance Testing +- [ ] Payment flow usability +- [ ] Error message clarity +- [ ] Mobile responsiveness +- [ ] Cross-browser compatibility + +--- + +## Phase 7: Security & Compliance + +### 7.1 Security Implementation +- [ ] Webhook signature verification +- [ ] PCI compliance through Stripe +- [ ] Secure token storage +- [ ] Rate limiting on payment endpoints +- [ ] Input validation and sanitization + +### 7.2 Data Protection +- [ ] GDPR compliance for EU users +- [ ] Secure handling of payment data +- [ ] Audit logging for all transactions +- [ ] Data retention policies + +--- + +## Phase 8: Monitoring & Analytics + +### 8.1 Payment Metrics +- [ ] Success/failure rates by payment method +- [ ] Average transaction values +- [ ] Conversion rates from package selection to payment +- [ ] User payment behavior analytics + +### 8.2 Error Tracking +- [ ] Failed payment reasons tracking +- [ ] Webhook processing errors +- [ ] User experience issues +- [ ] Performance monitoring + +--- + +## Phase 9: Documentation & Deployment + +### 9.1 Documentation +- [ ] API documentation for payment endpoints +- [ ] User guide for token purchasing +- [ ] Admin guide for payment management +- [ ] Webhook documentation + +### 9.2 Deployment +- [ ] Staging environment setup +- [ ] Production deployment plan +- [ ] Database migration scripts +- [ ] Environment configuration +- [ ] Stripe webhook endpoint configuration + +--- + +## Phase 10: Post-Launch + +### 10.1 Monitoring +- [ ] Payment success rate monitoring +- [ ] Error rate tracking +- [ ] User feedback collection +- [ ] Performance optimization + +### 10.2 Iterations +- [ ] A/B testing for payment flow +- [ ] User experience improvements +- [ ] Additional payment methods if needed +- [ ] Feature enhancements based on usage + +--- + +## Technical Specifications + +### Database Schema Updates +```sql +ALTER TABLE payment_records +ADD COLUMN stripe_payment_intent_id VARCHAR(255), +ADD COLUMN stripe_payment_method_id VARCHAR(255), +ADD COLUMN stripe_customer_id VARCHAR(255), +ADD COLUMN payment_flow_type ENUM('card', 'ideal', 'bank_transfer'), +ADD COLUMN stripe_metadata JSON, +ADD COLUMN refund_reason TEXT, +ADD COLUMN refunded_amount DECIMAL(10,2), +ADD COLUMN custom_quantity INT, +ADD COLUMN applied_discount_percentage DECIMAL(5,2); +``` + +### API Endpoints +- `POST /api/payments/create-intent` - Create payment intent +- `POST /api/payments/confirm` - Confirm payment +- `GET /api/payments/methods` - Get payment methods +- `GET /api/user/payments` - User payment history +- `POST /api/webhooks/stripe` - Stripe webhooks + +### Environment Variables +```env +STRIPE_PUBLISHABLE_KEY=pk_test_... +STRIPE_SECRET_KEY=sk_test_... +STRIPE_WEBHOOK_SECRET=whsec_... +NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_... +``` + +--- + +## Success Criteria +- [ ] Users can purchase tokens with custom quantities +- [ ] All three payment methods (Card, iDEAL, Bank Transfer) work +- [ ] Dynamic pricing with package discounts functions correctly +- [ ] Payment success rate > 95% +- [ ] User experience is smooth and intuitive +- [ ] All security requirements are met +- [ ] Webhook processing is reliable +- [ ] Mobile experience is optimized