TONS code - stripe integration

This commit is contained in:
Nixon 2025-09-20 16:37:37 +02:00
parent 7a868d7f14
commit e75424aac0
34 changed files with 5273 additions and 8 deletions

View File

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

View File

@ -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": {

View File

@ -35,12 +35,18 @@ import {$log} from "@tsed/logger";
version: process.env.APP_VERSION || "1.0.0",
description:
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" +
"Includes endpoints for auth, users, jobs, tokens, AI-powered interviews (OpenRouter/Ollama), and admin reporting.\n\n" +
"Includes endpoints for auth, users, jobs, tokens, AI-powered interviews (OpenRouter/Ollama), payment processing, and admin reporting.\n\n" +
"AI Features:\n" +
"- OpenRouter integration for cloud-based AI interviews\n" +
"- Ollama support for local AI processing\n" +
"- Test mode for admin interview testing\n" +
"- Mandatory question support before AI interviews",
"- Mandatory question support before AI interviews\n\n" +
"Payment Features:\n" +
"- Stripe integration for secure payments\n" +
"- Support for credit cards, iDEAL, and bank transfers\n" +
"- Dynamic token pricing with package discounts\n" +
"- Custom token quantity purchases\n" +
"- Webhook-based payment confirmation",
contact: {
name: "Candivista Team",
url: "https://candivista.com",
@ -56,7 +62,9 @@ import {$log} from "@tsed/logger";
{ name: "Users", description: "User profile and token summary" },
{ name: "Jobs", description: "Job posting and interview token operations" },
{ name: "Admin", description: "Administrative statistics and management" },
{ name: "AI", description: "AI-powered interview operations with OpenRouter and Ollama support" }
{ name: "AI", description: "AI-powered interview operations with OpenRouter and Ollama support" },
{ name: "Payments", description: "Stripe payment processing for token purchases" },
{ name: "Webhooks", description: "Stripe webhook handlers for payment events" }
],
components: {
securitySchemes: {

View File

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

View File

@ -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'
});
}
}

View File

@ -0,0 +1,479 @@
import { pool } from '../config/database.js';
import { $log } from '@tsed/logger';
import { randomUUID } from 'crypto';
import { StripeService, PaymentIntentData, CustomerData } from './StripeService.js';
import { TokenService } from './TokenService.js';
export interface CreatePaymentRequest {
userId: string;
packageId?: string;
customQuantity?: number;
paymentFlowType: 'card' | 'ideal' | 'bank_transfer';
userEmail: string;
userName: string;
}
export interface PaymentRecord {
id: string;
user_id: string;
token_package_id?: string;
amount: number;
currency: string;
status: string;
payment_method?: string;
payment_reference?: string;
invoice_url?: string;
paid_at?: string;
created_at: string;
updated_at: string;
stripe_payment_intent_id?: string;
stripe_payment_method_id?: string;
stripe_customer_id?: string;
payment_flow_type: string;
stripe_metadata?: any;
refund_reason?: string;
refunded_amount?: number;
custom_quantity?: number;
applied_discount_percentage?: number;
first_name?: string;
last_name?: string;
email?: string;
package_name?: string;
}
export interface PaymentCalculation {
quantity: number;
basePrice: number;
discountPercentage: number;
finalPrice: number;
savings: number;
packageId?: string;
packageName?: string;
}
export class PaymentService {
private stripeService: StripeService;
private tokenService: TokenService;
constructor() {
this.stripeService = new StripeService();
this.tokenService = new TokenService();
}
/**
* Calculate the best price for a given quantity of tokens
*/
async calculateTokenPrice(quantity: number, packageId?: string): Promise<PaymentCalculation> {
const connection = await pool.getConnection();
try {
// If a specific package is selected, use its pricing
if (packageId) {
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE id = ? AND is_active = 1',
[packageId]
);
if (Array.isArray(rows) && rows.length > 0) {
const pkg = rows[0] as any;
const basePrice = quantity * pkg.price_per_token;
const discountAmount = (basePrice * pkg.discount_percentage) / 100;
const finalPrice = basePrice - discountAmount;
return {
quantity,
basePrice,
discountPercentage: pkg.discount_percentage,
finalPrice,
savings: discountAmount,
packageId: pkg.id,
packageName: pkg.name,
};
}
}
// Find the best package for the given quantity
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE is_active = 1 ORDER BY quantity ASC'
);
const packages = Array.isArray(rows) ? rows as any[] : [];
if (packages.length === 0) {
// No packages available, use base price
const basePrice = quantity * 5.00; // Default price per token
return {
quantity,
basePrice,
discountPercentage: 0,
finalPrice: basePrice,
savings: 0,
};
}
// Find the package that gives the best discount for this quantity
let bestPackage = null;
let bestPrice = quantity * 5.00; // Default base price
let bestDiscount = 0;
let bestSavings = 0;
for (const pkg of packages) {
if (quantity >= pkg.quantity) {
const basePrice = quantity * pkg.price_per_token;
const discountAmount = (basePrice * pkg.discount_percentage) / 100;
const finalPrice = basePrice - discountAmount;
if (finalPrice < bestPrice) {
bestPackage = pkg;
bestPrice = finalPrice;
bestDiscount = pkg.discount_percentage;
bestSavings = discountAmount;
}
}
}
return {
quantity,
basePrice: quantity * 5.00,
discountPercentage: bestDiscount,
finalPrice: bestPrice,
savings: bestSavings,
packageId: bestPackage?.id,
packageName: bestPackage?.name,
};
} catch (error) {
$log.error('Error calculating token price:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Create a payment intent for token purchase
*/
async createPaymentIntent(request: CreatePaymentRequest): Promise<{
paymentIntent: any;
paymentRecord: PaymentRecord;
calculation: PaymentCalculation;
}> {
const connection = await pool.getConnection();
try {
// Calculate pricing
const calculation = await this.calculateTokenPrice(
request.customQuantity || 1,
request.packageId
);
// Get or create Stripe customer
const customerData: CustomerData = {
email: request.userEmail,
name: request.userName,
userId: request.userId,
};
const customer = await this.stripeService.getOrCreateCustomer(customerData);
// Create payment intent
const paymentIntentData: PaymentIntentData = {
amount: calculation.finalPrice,
currency: 'eur', // Default to EUR for European market
customerId: customer.id,
metadata: {
userId: request.userId,
quantity: calculation.quantity.toString(),
packageId: calculation.packageId || '',
packageName: calculation.packageName || '',
discountPercentage: calculation.discountPercentage.toString(),
},
paymentMethodTypes: this.stripeService.getAvailablePaymentMethods(),
};
const paymentIntent = await this.stripeService.createPaymentIntent(paymentIntentData);
// Create payment record
const paymentRecordId = randomUUID();
await connection.execute(`
INSERT INTO payment_records (
id, user_id, token_package_id, amount, currency, status,
payment_method, payment_reference, stripe_payment_intent_id,
stripe_customer_id, payment_flow_type, custom_quantity,
applied_discount_percentage, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NOW(), NOW())
`, [
paymentRecordId,
request.userId,
calculation.packageId || null,
calculation.finalPrice,
'eur',
'pending',
request.paymentFlowType,
paymentIntent.id,
paymentIntent.id,
customer.id,
request.paymentFlowType,
calculation.quantity,
calculation.discountPercentage,
]);
// Get the created payment record
const [rows] = await connection.execute(
'SELECT * FROM payment_records WHERE id = ?',
[paymentRecordId]
);
const paymentRecord = Array.isArray(rows) ? rows[0] as PaymentRecord : null;
$log.info(`Created payment intent: ${paymentIntent.id} for user: ${request.userId}`);
return {
paymentIntent,
paymentRecord: paymentRecord!,
calculation,
};
} catch (error) {
$log.error('Error creating payment intent:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Process a successful payment
*/
async processSuccessfulPayment(paymentIntentId: string): Promise<PaymentRecord> {
const connection = await pool.getConnection();
try {
// Get payment record
const [rows] = await connection.execute(
'SELECT * FROM payment_records WHERE stripe_payment_intent_id = ?',
[paymentIntentId]
);
if (!Array.isArray(rows) || rows.length === 0) {
throw new Error('Payment record not found');
}
const paymentRecord = rows[0] as PaymentRecord;
// Update payment record status
await connection.execute(`
UPDATE payment_records
SET status = 'paid', paid_at = NOW(), updated_at = NOW()
WHERE stripe_payment_intent_id = ?
`, [paymentIntentId]);
// Allocate tokens to user
const quantity = paymentRecord.custom_quantity || 1;
const pricePerToken = paymentRecord.amount / quantity;
await this.tokenService.addTokensToUser(
paymentRecord.user_id,
quantity,
pricePerToken
);
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
`, [paymentRecord.user_id, quantity, quantity]);
$log.info(`Processed successful payment: ${paymentIntentId} for user: ${paymentRecord.user_id}`);
return paymentRecord;
} catch (error) {
$log.error('Error processing successful payment:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Process a failed payment
*/
async processFailedPayment(paymentIntentId: string, reason?: string): Promise<PaymentRecord> {
const connection = await pool.getConnection();
try {
// Update payment record status
await connection.execute(`
UPDATE payment_records
SET status = 'failed', updated_at = NOW()
WHERE stripe_payment_intent_id = ?
`, [paymentIntentId]);
// Get updated payment record
const [rows] = await connection.execute(
'SELECT * FROM payment_records WHERE stripe_payment_intent_id = ?',
[paymentIntentId]
);
const paymentRecord = Array.isArray(rows) ? rows[0] as PaymentRecord : null;
$log.info(`Processed failed payment: ${paymentIntentId}, reason: ${reason}`);
return paymentRecord!;
} catch (error) {
$log.error('Error processing failed payment:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get user payment history
*/
async getUserPaymentHistory(userId: string): Promise<PaymentRecord[]> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
u.first_name,
u.last_name,
u.email,
tp.name as package_name
FROM payment_records pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
WHERE pr.user_id = ?
ORDER BY pr.created_at DESC
`, [userId]);
return Array.isArray(rows) ? rows as PaymentRecord[] : [];
} catch (error) {
$log.error('Error getting user payment history:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get payment by ID
*/
async getPaymentById(paymentId: string): Promise<PaymentRecord | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
u.first_name,
u.last_name,
u.email,
tp.name as package_name
FROM payment_records pr
LEFT JOIN users u ON pr.user_id = u.id
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
WHERE pr.id = ?
`, [paymentId]);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as PaymentRecord;
}
return null;
} catch (error) {
$log.error('Error getting payment by ID:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Process refund
*/
async processRefund(paymentId: string, amount?: number, reason?: string): Promise<any> {
const connection = await pool.getConnection();
try {
// Get payment record
const paymentRecord = await this.getPaymentById(paymentId);
if (!paymentRecord) {
throw new Error('Payment record not found');
}
if (!paymentRecord.stripe_payment_intent_id) {
throw new Error('No Stripe payment intent found for this payment');
}
// Create refund via Stripe
const refund = await this.stripeService.createRefund({
paymentIntentId: paymentRecord.stripe_payment_intent_id,
amount: amount || paymentRecord.amount,
reason: reason as any,
metadata: {
paymentId: paymentId,
refundedBy: 'admin', // This should come from the admin user context
},
});
// Update payment record
await connection.execute(`
UPDATE payment_records
SET status = 'refunded', refunded_amount = ?, refund_reason = ?, updated_at = NOW()
WHERE id = ?
`, [amount || paymentRecord.amount, reason, paymentId]);
$log.info(`Processed refund: ${refund.id} for payment: ${paymentId}`);
return refund;
} catch (error) {
$log.error('Error processing refund:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get payment statistics
*/
async getPaymentStatistics(): Promise<{
totalPayments: number;
totalRevenue: number;
successRate: number;
averageTransactionValue: number;
}> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
COUNT(*) as total_payments,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as total_revenue,
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as successful_payments,
AVG(CASE WHEN status = 'paid' THEN amount ELSE NULL END) as avg_transaction_value
FROM payment_records
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
`);
const stats = Array.isArray(rows) ? rows[0] as any : {};
const totalPayments = stats.total_payments || 0;
const successfulPayments = stats.successful_payments || 0;
const successRate = totalPayments > 0 ? (successfulPayments / totalPayments) * 100 : 0;
return {
totalPayments,
totalRevenue: stats.total_revenue || 0,
successRate: Math.round(successRate * 100) / 100,
averageTransactionValue: stats.avg_transaction_value || 0,
};
} catch (error) {
$log.error('Error getting payment statistics:', error);
throw error;
} finally {
connection.release();
}
}
}

View File

@ -0,0 +1,321 @@
import Stripe from 'stripe';
import { $log } from '@tsed/logger';
export interface PaymentIntentData {
amount: number;
currency: string;
customerId?: string;
paymentMethodId?: string;
metadata: Record<string, string>;
paymentMethodTypes?: string[];
confirmationMethod?: 'automatic' | 'manual';
}
export interface CustomerData {
email: string;
name: string;
userId: string;
metadata?: Record<string, string>;
}
export interface RefundData {
paymentIntentId: string;
amount?: number;
reason?: 'duplicate' | 'fraudulent' | 'requested_by_customer';
metadata?: Record<string, string>;
}
export class StripeService {
private stripe: Stripe;
constructor() {
const secretKey = process.env.STRIPE_SECRET_KEY?.trim();
if (!secretKey ||
secretKey.includes('your_secret_key_here') ||
secretKey.includes('sk_test_your_secret_key_here') ||
secretKey.includes('placeholder') ||
!secretKey.startsWith('sk_test_') && !secretKey.startsWith('sk_live_')) {
$log.warn('STRIPE_SECRET_KEY is not properly configured. Payment features will be disabled.');
// Create a mock Stripe instance for development
this.stripe = null as any;
return;
}
try {
this.stripe = new Stripe(secretKey, {
apiVersion: '2024-12-18.acacia',
typescript: true,
});
$log.info('Stripe service initialized successfully');
} catch (error) {
$log.error('Failed to initialize Stripe service:', error);
this.stripe = null as any;
}
}
/**
* Create a Stripe customer
*/
async createCustomer(customerData: CustomerData): Promise<Stripe.Customer> {
if (!this.stripe) {
throw new Error('Stripe is not configured. Please set up STRIPE_SECRET_KEY.');
}
try {
const customer = await this.stripe.customers.create({
email: customerData.email,
name: customerData.name,
metadata: {
userId: customerData.userId,
...customerData.metadata,
},
});
$log.info(`Created Stripe customer: ${customer.id} for user: ${customerData.userId}`);
return customer;
} catch (error) {
$log.error('Error creating Stripe customer:', error);
throw new Error('Failed to create customer');
}
}
/**
* Get or create a Stripe customer for a user
*/
async getOrCreateCustomer(customerData: CustomerData): Promise<Stripe.Customer> {
if (!this.stripe) {
throw new Error('Stripe is not configured. Please set up STRIPE_SECRET_KEY.');
}
try {
// First, try to find existing customer by email
const existingCustomers = await this.stripe.customers.list({
email: customerData.email,
limit: 1,
});
if (existingCustomers.data.length > 0) {
const customer = existingCustomers.data[0];
$log.info(`Found existing Stripe customer: ${customer.id} for user: ${customerData.userId}`);
return customer;
}
// Create new customer if not found
return await this.createCustomer(customerData);
} catch (error) {
$log.error('Error getting or creating Stripe customer:', error);
throw new Error('Failed to get or create customer');
}
}
/**
* Create a payment intent
*/
async createPaymentIntent(data: PaymentIntentData): Promise<Stripe.PaymentIntent> {
if (!this.stripe) {
throw new Error('Stripe is not configured. Please set up STRIPE_SECRET_KEY.');
}
try {
const paymentIntentData: Stripe.PaymentIntentCreateParams = {
amount: Math.round(data.amount * 100), // Convert to cents
currency: data.currency,
metadata: data.metadata,
payment_method_types: data.paymentMethodTypes || ['card'],
confirmation_method: data.confirmationMethod || 'automatic',
};
if (data.customerId) {
paymentIntentData.customer = data.customerId;
}
if (data.paymentMethodId) {
paymentIntentData.payment_method = data.paymentMethodId;
paymentIntentData.confirmation_method = 'manual';
}
const paymentIntent = await this.stripe.paymentIntents.create(paymentIntentData);
$log.info(`Created payment intent: ${paymentIntent.id} for amount: ${data.amount}`);
return paymentIntent;
} catch (error) {
$log.error('Error creating payment intent:', error);
throw new Error('Failed to create payment intent');
}
}
/**
* Confirm a payment intent
*/
async confirmPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
if (!this.stripe) {
throw new Error('Stripe is not configured. Please set up STRIPE_SECRET_KEY.');
}
try {
const paymentIntent = await this.stripe.paymentIntents.confirm(paymentIntentId);
$log.info(`Confirmed payment intent: ${paymentIntentId}, status: ${paymentIntent.status}`);
return paymentIntent;
} catch (error) {
$log.error('Error confirming payment intent:', error);
throw new Error('Failed to confirm payment intent');
}
}
/**
* Retrieve a payment intent
*/
async getPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
try {
const paymentIntent = await this.stripe.paymentIntents.retrieve(paymentIntentId);
$log.info(`Retrieved payment intent: ${paymentIntentId}, status: ${paymentIntent.status}`);
return paymentIntent;
} catch (error) {
$log.error('Error retrieving payment intent:', error);
throw new Error('Failed to retrieve payment intent');
}
}
/**
* Cancel a payment intent
*/
async cancelPaymentIntent(paymentIntentId: string): Promise<Stripe.PaymentIntent> {
try {
const paymentIntent = await this.stripe.paymentIntents.cancel(paymentIntentId);
$log.info(`Cancelled payment intent: ${paymentIntentId}`);
return paymentIntent;
} catch (error) {
$log.error('Error cancelling payment intent:', error);
throw new Error('Failed to cancel payment intent');
}
}
/**
* Create a refund
*/
async createRefund(data: RefundData): Promise<Stripe.Refund> {
try {
const refundData: Stripe.RefundCreateParams = {
payment_intent: data.paymentIntentId,
metadata: data.metadata,
};
if (data.amount) {
refundData.amount = Math.round(data.amount * 100); // Convert to cents
}
if (data.reason) {
refundData.reason = data.reason;
}
const refund = await this.stripe.refunds.create(refundData);
$log.info(`Created refund: ${refund.id} for payment intent: ${data.paymentIntentId}`);
return refund;
} catch (error) {
$log.error('Error creating refund:', error);
throw new Error('Failed to create refund');
}
}
/**
* Get payment methods for a customer
*/
async getPaymentMethods(customerId: string): Promise<Stripe.PaymentMethod[]> {
try {
const paymentMethods = await this.stripe.paymentMethods.list({
customer: customerId,
type: 'card',
});
$log.info(`Retrieved ${paymentMethods.data.length} payment methods for customer: ${customerId}`);
return paymentMethods.data;
} catch (error) {
$log.error('Error retrieving payment methods:', error);
throw new Error('Failed to retrieve payment methods');
}
}
/**
* Create a setup intent for saving payment methods
*/
async createSetupIntent(customerId: string, metadata?: Record<string, string>): Promise<Stripe.SetupIntent> {
try {
const setupIntent = await this.stripe.setupIntents.create({
customer: customerId,
payment_method_types: ['card'],
metadata: metadata || {},
});
$log.info(`Created setup intent: ${setupIntent.id} for customer: ${customerId}`);
return setupIntent;
} catch (error) {
$log.error('Error creating setup intent:', error);
throw new Error('Failed to create setup intent');
}
}
/**
* Verify webhook signature
*/
verifyWebhookSignature(payload: string | Buffer, signature: string): Stripe.Event {
try {
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) {
throw new Error('STRIPE_WEBHOOK_SECRET environment variable is required');
}
const event = this.stripe.webhooks.constructEvent(payload, signature, webhookSecret);
$log.info(`Verified webhook event: ${event.type}, id: ${event.id}`);
return event;
} catch (error) {
$log.error('Error verifying webhook signature:', error);
throw new Error('Invalid webhook signature');
}
}
/**
* Get available payment methods for different regions
*/
getAvailablePaymentMethods(countryCode?: string): string[] {
const baseMethods = ['card'];
if (countryCode === 'NL') {
return [...baseMethods, 'ideal'];
}
if (countryCode === 'DE' || countryCode === 'FR' || countryCode === 'ES' || countryCode === 'IT') {
return [...baseMethods, 'sepa_debit'];
}
return baseMethods;
}
/**
* Get payment method configuration for iDEAL
*/
getIdealConfiguration(): { banks: Array<{ id: string; name: string }> } {
return {
banks: [
{ id: 'abn_amro', name: 'ABN AMRO' },
{ id: 'asn_bank', name: 'ASN Bank' },
{ id: 'bunq', name: 'bunq' },
{ id: 'handelsbanken', name: 'Handelsbanken' },
{ id: 'ing', name: 'ING' },
{ id: 'knab', name: 'Knab' },
{ id: 'rabobank', name: 'Rabobank' },
{ id: 'regiobank', name: 'RegioBank' },
{ id: 'revolut', name: 'Revolut' },
{ id: 'sns_bank', name: 'SNS Bank' },
{ id: 'triodos_bank', name: 'Triodos Bank' },
{ id: 'van_lanschot', name: 'Van Lanschot' },
],
};
}
}

View File

@ -440,4 +440,142 @@ export class TokenService {
connection.release();
}
}
/**
* Calculate custom token price based on quantity
*/
async calculateCustomTokenPrice(quantity: number): Promise<{
basePrice: number;
discountPercentage: number;
finalPrice: number;
savings: number;
}> {
const connection = await pool.getConnection();
try {
// Get all active packages
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE is_active = 1 ORDER BY quantity ASC'
);
const packages = Array.isArray(rows) ? rows as TokenPackage[] : [];
if (packages.length === 0) {
// No packages available, use base price
const basePrice = quantity * 5.00; // Default price per token
return {
basePrice,
discountPercentage: 0,
finalPrice: basePrice,
savings: 0,
};
}
// Find the package that gives the best discount for this quantity
let bestPackage = null;
let bestPrice = quantity * 5.00; // Default base price
let bestDiscount = 0;
let bestSavings = 0;
for (const pkg of packages) {
if (quantity >= pkg.quantity) {
const basePrice = quantity * pkg.price_per_token;
const discountAmount = (basePrice * pkg.discount_percentage) / 100;
const finalPrice = basePrice - discountAmount;
if (finalPrice < bestPrice) {
bestPackage = pkg;
bestPrice = finalPrice;
bestDiscount = pkg.discount_percentage;
bestSavings = discountAmount;
}
}
}
return {
basePrice: quantity * 5.00,
discountPercentage: bestDiscount,
finalPrice: bestPrice,
savings: bestSavings,
};
} catch (error) {
$log.error('Error calculating custom token price:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get the best package for a given quantity
*/
async getBestPackageForQuantity(quantity: number): Promise<TokenPackage | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM token_packages WHERE is_active = 1 AND quantity <= ? ORDER BY quantity DESC LIMIT 1',
[quantity]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as TokenPackage;
}
return null;
} catch (error) {
$log.error('Error getting best package for quantity:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Add tokens to user account (updated for payment-based tokens)
*/
async addTokensToUserFromPayment(
userId: string,
quantity: number,
pricePerToken: number,
paymentId: string
): Promise<InterviewToken> {
const connection = await pool.getConnection();
try {
const totalPrice = quantity * pricePerToken;
const tokenId = randomUUID();
// Create token record
await connection.execute(`
INSERT INTO interview_tokens (
id, user_id, token_type, quantity, price_per_token,
total_price, status, purchased_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 'active', NOW(), NOW(), NOW())
`, [
tokenId,
userId,
quantity === 1 ? 'single' : 'bulk',
quantity,
pricePerToken,
totalPrice
]);
// Update user usage
await connection.execute(`
INSERT INTO user_usage (user_id, tokens_purchased)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE tokens_purchased = tokens_purchased + ?
`, [userId, quantity, quantity]);
$log.info(`Added ${quantity} tokens to user ${userId} from payment ${paymentId}`);
return await this.getTokenById(tokenId) as InterviewToken;
} catch (error) {
$log.error('Error adding tokens to user from payment:', error);
throw error;
} finally {
connection.release();
}
}
}

View File

@ -204,6 +204,116 @@ export class UserService {
}
}
/**
* Get user payment history
*/
async getUserPaymentHistory(userId: string): Promise<any[]> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
pr.*,
tp.name as package_name
FROM payment_records pr
LEFT JOIN token_packages tp ON pr.token_package_id = tp.id
WHERE pr.user_id = ?
ORDER BY pr.created_at DESC
`, [userId]);
return Array.isArray(rows) ? rows : [];
} catch (error) {
$log.error('Error getting user payment history:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get user by Stripe customer ID
*/
async getUserByStripeCustomerId(stripeCustomerId: string): Promise<User | null> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(
'SELECT * FROM users WHERE stripe_customer_id = ? AND deleted_at IS NULL',
[stripeCustomerId]
);
if (Array.isArray(rows) && rows.length > 0) {
return rows[0] as User;
}
return null;
} catch (error) {
$log.error('Error getting user by Stripe customer ID:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Update user's Stripe customer ID
*/
async updateUserStripeCustomerId(userId: string, stripeCustomerId: string): Promise<void> {
const connection = await pool.getConnection();
try {
await connection.execute(
'UPDATE users SET stripe_customer_id = ?, updated_at = NOW() WHERE id = ?',
[stripeCustomerId, userId]
);
$log.info(`Updated Stripe customer ID for user: ${userId}`);
} catch (error) {
$log.error('Error updating user Stripe customer ID:', error);
throw error;
} finally {
connection.release();
}
}
/**
* Get user payment statistics
*/
async getUserPaymentStatistics(userId: string): Promise<{
totalSpent: number;
totalPayments: number;
averagePaymentValue: number;
lastPaymentDate: string | null;
}> {
const connection = await pool.getConnection();
try {
const [rows] = await connection.execute(`
SELECT
COUNT(*) as total_payments,
SUM(CASE WHEN status = 'paid' THEN amount ELSE 0 END) as total_spent,
AVG(CASE WHEN status = 'paid' THEN amount ELSE NULL END) as avg_payment_value,
MAX(CASE WHEN status = 'paid' THEN paid_at ELSE NULL END) as last_payment_date
FROM payment_records
WHERE user_id = ?
`, [userId]);
const stats = Array.isArray(rows) ? rows[0] as any : {};
return {
totalSpent: stats.total_spent || 0,
totalPayments: stats.total_payments || 0,
averagePaymentValue: stats.avg_payment_value || 0,
lastPaymentDate: stats.last_payment_date || null,
};
} catch (error) {
$log.error('Error getting user payment statistics:', error);
throw error;
} finally {
connection.release();
}
}
private mapUserToResponse(user: User): UserResponse {
return {
id: user.id,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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`}
>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<StripeProvider>
{children}
</StripeProvider>
</ThemeProvider>
</body>
</html>

View File

@ -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<string>("");
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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-red-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 text-center">
{/* Error Icon */}
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-red-100 dark:bg-red-900/20 mb-6">
<svg className="h-8 w-8 text-red-600 dark:text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</div>
{/* Error Message */}
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Payment Failed
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
We couldn't process your payment. Don't worry, no charges were made to your account.
</p>
{/* Error Details */}
{errorMessage && (
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<h3 className="font-medium text-red-800 dark:text-red-200 mb-2">
Error Details
</h3>
<p className="text-sm text-red-700 dark:text-red-300">
{errorMessage}
</p>
</div>
)}
{/* Common Solutions */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 rounded-lg p-4 mb-6">
<h3 className="font-medium text-yellow-800 dark:text-yellow-200 mb-2">
Common Solutions
</h3>
<ul className="text-sm text-yellow-700 dark:text-yellow-300 space-y-1 text-left">
<li> Check your payment method details</li>
<li> Ensure you have sufficient funds</li>
<li> Try a different payment method</li>
<li> Contact your bank if the issue persists</li>
</ul>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={handleRetryPayment}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Try Again
</button>
<button
onClick={handleGoToDashboard}
className="w-full px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Go to Dashboard
</button>
</div>
{/* Support Section */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
Still having trouble? We're here to help!
</p>
<div className="space-y-2">
<button
onClick={handleContactSupport}
className="w-full px-4 py-2 bg-green-100 dark:bg-green-900/20 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-200 dark:hover:bg-green-900/30 transition-colors"
>
Contact Support
</button>
<Link
href="/docs"
className="block w-full px-4 py-2 text-blue-600 dark:text-blue-400 hover:underline text-sm"
>
View Help Documentation
</Link>
</div>
</div>
{/* Additional Info */}
<div className="mt-6 text-xs text-gray-500 dark:text-gray-400">
<p>
If you continue to experience issues, please contact our support team with the error details above.
</p>
</div>
</div>
</div>
);
}
export default function PaymentFailedPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-red-600 border-t-transparent rounded-full animate-spin"></div>
</div>
}>
<PaymentFailedContent />
</Suspense>
);
}

View File

@ -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<any>(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 (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center p-4">
<div className="max-w-md w-full bg-white dark:bg-gray-800 rounded-lg shadow-xl p-8 text-center">
{/* Success Icon */}
<div className="mx-auto flex items-center justify-center h-16 w-16 rounded-full bg-green-100 dark:bg-green-900/20 mb-6">
<svg className="h-8 w-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
{/* Success Message */}
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Payment Successful!
</h1>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Your tokens have been added to your account and are ready to use.
</p>
{/* Payment Details */}
{paymentData && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Payment Details
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Tokens Purchased:</span>
<span className="text-gray-900 dark:text-white">
{paymentData.tokensAllocated || 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Amount Paid:</span>
<span className="text-gray-900 dark:text-white">
{paymentData.amount?.toFixed(2) || 'N/A'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">Status:</span>
<span className="text-green-600 dark:text-green-400 font-medium">
Completed
</span>
</div>
</div>
</div>
)}
{/* Next Steps */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4 mb-6">
<h3 className="font-medium text-blue-900 dark:text-blue-200 mb-2">
What's Next?
</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-1 text-left">
<li> Your tokens are now active and ready to use</li>
<li> Create job postings to start interviewing candidates</li>
<li> Generate interview links and share with candidates</li>
<li> View detailed interview reports and analytics</li>
</ul>
</div>
{/* Action Buttons */}
<div className="space-y-3">
<button
onClick={handleGoToDashboard}
className="w-full px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
Go to Dashboard
</button>
<button
onClick={handleBuyMoreTokens}
className="w-full px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors"
>
Buy More Tokens
</button>
</div>
{/* Support Link */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
<p className="text-xs text-gray-500 dark:text-gray-400">
Need help? <Link href="/docs" className="text-blue-600 dark:text-blue-400 hover:underline">Contact Support</Link>
</p>
</div>
</div>
</div>
);
}
export default function PaymentSuccessPage() {
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
}>
<PaymentSuccessContent />
</Suspense>
);
}

View File

@ -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<TokenPackage[]>([]);
const [calculation, setCalculation] = useState<PricingCalculation | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
{/* Quantity Input */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Number of Tokens
</label>
<div className="flex items-center space-x-3">
<button
onClick={() => handleQuantityChange(quantity - 1)}
disabled={quantity <= 1}
className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<input
type="number"
value={quantity}
onChange={(e) => 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"
/>
<button
onClick={() => handleQuantityChange(quantity + 1)}
disabled={quantity >= 1000}
className="w-10 h-10 rounded-lg border border-gray-300 dark:border-gray-600 flex items-center justify-center hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
</svg>
</button>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Choose between 1 and 1000 tokens
</p>
</div>
{/* Package Selection */}
{packages.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Choose Package (Optional)
</label>
<div className="space-y-2">
<button
onClick={() => handlePackageSelect('')}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
!selectedPackageId
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="font-medium">Custom Amount</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{quantity} token{quantity > 1 ? 's' : ''} at 5.00 each
</div>
</button>
{packages.map((pkg) => (
<button
key={pkg.id}
onClick={() => handlePackageSelect(pkg.id)}
className={`w-full p-3 rounded-lg border text-left transition-colors ${
selectedPackageId === pkg.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium">{pkg.name}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pkg.description}
</div>
</div>
<div className="text-right">
<div className="font-medium">{pkg.total_price.toFixed(2)}</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pkg.discount_percentage}% off
</div>
</div>
</div>
</button>
))}
</div>
</div>
)}
{/* Price Calculation */}
{calculation && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Price Breakdown</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
{quantity} token{quantity > 1 ? 's' : ''} × {PricingService.formatPrice(PricingService.BASE_PRICE_PER_TOKEN)}
</span>
<span className="text-gray-900 dark:text-white">
{PricingService.formatPrice(calculation.basePrice)}
</span>
</div>
{calculation.savings > 0 && (
<>
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>Discount ({calculation.discountPercentage}%)</span>
<span>-{PricingService.formatPrice(calculation.savings)}</span>
</div>
{calculation.packageName && (
<div className="text-xs text-gray-500 dark:text-gray-400">
Applied from {calculation.packageName} package
</div>
)}
</>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 flex justify-between font-medium text-lg">
<span className="text-gray-900 dark:text-white">Total</span>
<span className="text-gray-900 dark:text-white">
{PricingService.formatPrice(calculation.finalPrice)}
</span>
</div>
</div>
{calculation.savings > 0 && (
<div className="mt-3 p-2 bg-green-50 dark:bg-green-900/20 rounded-lg">
<div className="text-sm text-green-700 dark:text-green-300">
🎉 You save {PricingService.formatPrice(calculation.savings)} with this package!
</div>
</div>
)}
</div>
)}
{/* Recommendation */}
{recommendedPackage && !selectedPackageId && quantity > 1 && (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<div className="flex items-start space-x-2">
<div className="text-blue-500 mt-0.5">💡</div>
<div>
<div className="text-sm font-medium text-blue-800 dark:text-blue-200">
Recommended Package
</div>
<div className="text-sm text-blue-700 dark:text-blue-300">
Consider the <strong>{recommendedPackage.name}</strong> package for better value.
You'll get {recommendedPackage.quantity} tokens with {recommendedPackage.discount_percentage}% discount.
</div>
</div>
</div>
</div>
)}
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-4">
<div className="w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
)}
{/* Error State */}
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
)}
</div>
);
}

View File

@ -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 (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
case 'medium':
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
case 'high':
case 'critical':
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
</svg>
);
default:
return (
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
);
}
};
return (
<div className={`rounded-lg border p-4 ${getSeverityStyles()} ${className}`}>
<div className="flex items-start">
<div className="flex-shrink-0 mr-3">
{getSeverityIcon()}
</div>
<div className="flex-1 min-w-0">
<h3 className="text-sm font-medium mb-1">
{errorInfo.title}
</h3>
<p className="text-sm mb-3">
{errorInfo.message}
</p>
{showDetails && (
<div className="mb-3">
<button
onClick={() => setShowFullDetails(!showFullDetails)}
className="text-xs underline hover:no-underline"
>
{showFullDetails ? 'Hide' : 'Show'} technical details
</button>
{showFullDetails && (
<div className="mt-2 p-2 bg-black/5 dark:bg-white/5 rounded text-xs font-mono">
<pre className="whitespace-pre-wrap">
{JSON.stringify(error, null, 2)}
</pre>
</div>
)}
</div>
)}
<div className="flex items-center justify-between">
<div className="text-xs opacity-75">
{errorInfo.action}
</div>
<div className="flex items-center space-x-2">
{retryable && onRetry && (
<button
onClick={onRetry}
className="text-xs px-3 py-1 bg-white/20 dark:bg-black/20 rounded hover:bg-white/30 dark:hover:bg-black/30 transition-colors"
>
Try Again
</button>
)}
{onDismiss && (
<button
onClick={onDismiss}
className="text-xs px-3 py-1 bg-white/20 dark:bg-black/20 rounded hover:bg-white/30 dark:hover:bg-black/30 transition-colors"
>
Dismiss
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<TokenSummary | null>(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 (
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700 px-6 py-4">
<div className="flex items-center justify-between">
@ -98,6 +110,25 @@ export default function Header({ title, user, onLogout }: HeaderProps) {
{tokenSummary.total_available} tokens
</span>
</div>
{/* Buy Tokens Button */}
<button
onClick={() => setShowPurchaseModal(true)}
className="px-3 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors"
>
Buy Tokens
</button>
{/* Payment History Button */}
<button
onClick={() => setShowPaymentHistory(true)}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
title="Payment History"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
{/* Token Usage Progress */}
<div className="flex items-center space-x-2">
@ -177,6 +208,19 @@ export default function Header({ title, user, onLogout }: HeaderProps) {
</div>
</div>
</div>
{/* Token Purchase Modal */}
<TokenPurchaseFlow
isOpen={showPurchaseModal}
onClose={() => setShowPurchaseModal(false)}
onSuccess={handlePurchaseSuccess}
/>
{/* Payment History Modal */}
<PaymentHistory
isOpen={showPaymentHistory}
onClose={() => setShowPaymentHistory(false)}
/>
</header>
);
}

View File

@ -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 (
<div className={`${sizeClasses[size]} ${className}`}>
<div className="w-full h-full border-2 border-gray-300 dark:border-gray-600 border-t-blue-600 rounded-full animate-spin"></div>
</div>
);
}
interface LoadingDotsProps {
className?: string;
}
export function LoadingDots({ className = '' }: LoadingDotsProps) {
return (
<div className={`flex space-x-1 ${className}`}>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce"></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.1s' }}></div>
<div className="w-2 h-2 bg-blue-600 rounded-full animate-bounce" style={{ animationDelay: '0.2s' }}></div>
</div>
);
}
interface LoadingCardProps {
title?: string;
description?: string;
className?: string;
}
export function LoadingCard({ title = 'Loading...', description, className = '' }: LoadingCardProps) {
return (
<div className={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6 ${className}`}>
<div className="flex items-center justify-center mb-4">
<LoadingSpinner size="lg" />
</div>
<div className="text-center">
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{title}
</h3>
{description && (
<p className="text-sm text-gray-600 dark:text-gray-400">
{description}
</p>
)}
</div>
</div>
);
}
interface LoadingOverlayProps {
isVisible: boolean;
message?: string;
className?: string;
}
export function LoadingOverlay({ isVisible, message = 'Loading...', className = '' }: LoadingOverlayProps) {
if (!isVisible) return null;
return (
<div className={`fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 ${className}`}>
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl p-6 max-w-sm w-full mx-4">
<div className="flex items-center justify-center mb-4">
<LoadingSpinner size="lg" />
</div>
<div className="text-center">
<p className="text-gray-900 dark:text-white font-medium">
{message}
</p>
</div>
</div>
</div>
);
}
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 (
<div className={`w-full ${className}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
Progress
</span>
{showPercentage && (
<span className="text-sm text-gray-500 dark:text-gray-400">
{Math.round(clampedProgress)}%
</span>
)}
</div>
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
className="bg-blue-600 h-2 rounded-full transition-all duration-300 ease-out"
style={{ width: `${clampedProgress}%` }}
></div>
</div>
</div>
);
}
interface SkeletonProps {
className?: string;
lines?: number;
}
export function Skeleton({ className = '', lines = 1 }: SkeletonProps) {
return (
<div className={`animate-pulse ${className}`}>
{Array.from({ length: lines }).map((_, index) => (
<div
key={index}
className="h-4 bg-gray-200 dark:bg-gray-700 rounded mb-2"
style={{ width: `${Math.random() * 40 + 60}%` }}
></div>
))}
</div>
);
}
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 (
<button
type={type}
onClick={onClick}
disabled={disabled || isLoading}
className={`relative ${className} ${(disabled || isLoading) ? 'opacity-50 cursor-not-allowed' : ''}`}
>
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingSpinner size="sm" className="mr-2" />
<span>{loadingText}</span>
</div>
)}
<span className={isLoading ? 'opacity-0' : ''}>
{children}
</span>
</button>
);
}
interface LoadingTableProps {
rows?: number;
columns?: number;
className?: string;
}
export function LoadingTable({ rows = 5, columns = 4, className = '' }: LoadingTableProps) {
return (
<div className={`overflow-hidden ${className}`}>
<div className="animate-pulse">
{/* Header */}
<div className="grid gap-4 mb-4" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, index) => (
<div key={index} className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
))}
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<div key={rowIndex} className="grid gap-4 mb-3" style={{ gridTemplateColumns: `repeat(${columns}, 1fr)` }}>
{Array.from({ length: columns }).map((_, colIndex) => (
<div
key={colIndex}
className="h-3 bg-gray-200 dark:bg-gray-700 rounded"
style={{ width: `${Math.random() * 40 + 60}%` }}
></div>
))}
</div>
))}
</div>
</div>
);
}

View File

@ -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<PaymentRecord[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Payment History
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
View all your token purchases and payments
</p>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="w-8 h-8 border-2 border-blue-600 border-t-transparent rounded-full animate-spin"></div>
</div>
) : error ? (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : payments.length === 0 ? (
<div className="text-center py-12">
<div className="text-gray-400 dark:text-gray-500 mb-4">
<svg className="w-16 h-16 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
No payments yet
</h3>
<p className="text-gray-600 dark:text-gray-400">
Your payment history will appear here once you make your first purchase.
</p>
</div>
) : (
<div className="space-y-4">
{payments.map((payment) => (
<div
key={payment.id}
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<div className="text-2xl">
{getPaymentMethodIcon(payment.payment_flow_type)}
</div>
<div>
<div className="font-medium text-gray-900 dark:text-white">
{payment.package_name || `${payment.custom_quantity} Token${payment.custom_quantity && payment.custom_quantity > 1 ? 's' : ''}`}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{formatDate(payment.created_at)}
{payment.paid_at && (
<span className="ml-2">
Paid {formatDate(payment.paid_at)}
</span>
)}
</div>
{payment.applied_discount_percentage && payment.applied_discount_percentage > 0 && (
<div className="text-xs text-green-600 dark:text-green-400 mt-1">
{payment.applied_discount_percentage}% discount applied
</div>
)}
</div>
</div>
<div className="text-right">
<div className="font-bold text-lg text-gray-900 dark:text-white">
{formatCurrency(payment.amount, payment.currency)}
</div>
<div className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(payment.status)}`}>
{payment.status.charAt(0).toUpperCase() + payment.status.slice(1)}
</div>
{payment.refunded_amount && (
<div className="text-xs text-purple-600 dark:text-purple-400 mt-1">
Refunded: {formatCurrency(payment.refunded_amount, payment.currency)}
</div>
)}
</div>
</div>
{payment.refund_reason && (
<div className="mt-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded-lg">
<div className="text-xs text-purple-700 dark:text-purple-300">
<strong>Refund reason:</strong> {payment.refund_reason}
</div>
</div>
)}
</div>
))}
</div>
)}
{/* Footer */}
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-600">
<div className="flex justify-between items-center">
<div className="text-sm text-gray-600 dark:text-gray-400">
{payments.length} payment{payments.length !== 1 ? 's' : ''} total
</div>
<button
onClick={onClose}
className="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600"
>
Close
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">
Choose Payment Method
</label>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{availableMethods.map((method) => (
<button
key={method.id}
type="button"
onClick={() => onMethodChange(method.id)}
disabled={disabled || !method.available}
className={`p-4 rounded-lg border text-left transition-all duration-200 ${
selectedMethod === method.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300 ring-2 ring-blue-500 ring-opacity-50'
: method.available
? 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700'
: 'border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 text-gray-400 dark:text-gray-500 cursor-not-allowed'
}`}
>
<div className="flex items-center space-x-3">
<div className="text-2xl">{method.icon}</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm">{method.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400 mt-1">
{method.description}
</div>
</div>
{selectedMethod === method.id && (
<div className="text-blue-500">
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
</button>
))}
</div>
</div>
{/* iDEAL Bank Selection */}
{selectedMethod === 'ideal' && (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Your Bank
</label>
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{idealBanks.map((bank) => (
<button
key={bank.id}
type="button"
className="p-3 rounded-lg border border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors text-center"
>
<div className="text-lg mb-1">{bank.logo}</div>
<div className="text-xs font-medium text-gray-700 dark:text-gray-300">
{bank.name}
</div>
</button>
))}
</div>
</div>
)}
{/* Payment Method Info */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-3">
<div className="flex items-start space-x-2">
<div className="text-blue-500 mt-0.5">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clipRule="evenodd" />
</svg>
</div>
<div className="text-sm text-blue-700 dark:text-blue-300">
<div className="font-medium mb-1">Secure Payment</div>
<div className="text-xs">
{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.'}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<any>(null);
const [clientSecret, setClientSecret] = useState<string | null>(null);
const [paymentIntentId, setPaymentIntentId] = useState<string | null>(null);
const [availableMethods, setAvailableMethods] = useState<string[]>([]);
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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
Complete Payment
</h2>
<button
onClick={handleClose}
disabled={isProcessing}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 disabled:opacity-50"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Order Summary */}
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4 mb-6">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">Order Summary</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
{packageName || `${tokenQuantity} Token${tokenQuantity > 1 ? 's' : ''}`}
</span>
<span className="text-gray-900 dark:text-white">
{calculation.finalPrice.toFixed(2)}
</span>
</div>
{calculation.savings > 0 && (
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>Discount ({calculation.discountPercentage}%)</span>
<span>-{calculation.savings.toFixed(2)}</span>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 flex justify-between font-medium">
<span className="text-gray-900 dark:text-white">Total</span>
<span className="text-gray-900 dark:text-white">
{calculation.finalPrice.toFixed(2)}
</span>
</div>
</div>
</div>
{/* Payment Method Selection */}
<div className="mb-6">
<PaymentMethodSelector
selectedMethod={paymentMethod}
onMethodChange={setPaymentMethod}
countryCode="NL" // This could be dynamic based on user location
disabled={isProcessing}
/>
</div>
{/* Payment Form */}
{clientSecret && (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="p-4 border border-gray-200 dark:border-gray-600 rounded-lg">
<PaymentElement
options={{
layout: 'tabs',
}}
/>
</div>
{error && (
<ErrorDisplay
error={error}
onRetry={() => {
setError(null);
createPaymentIntent();
}}
onDismiss={() => setError(null)}
showDetails={true}
/>
)}
<div className="flex space-x-3">
<button
type="button"
onClick={handleClose}
disabled={isProcessing}
className="flex-1 px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
>
Cancel
</button>
<LoadingButton
type="submit"
isLoading={isProcessing}
loadingText="Processing..."
disabled={!stripe}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Pay {calculation.finalPrice.toFixed(2)}
</LoadingButton>
</div>
</form>
)}
{!clientSecret && !error && (
<div className="flex items-center justify-center py-8">
<LoadingSpinner size="lg" />
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -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 (
<div className={`w-full ${className}`}>
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
{/* Step Circle */}
<div className="flex items-center justify-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium transition-colors ${
step.completed
? 'bg-green-500 text-white'
: step.current
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{step.completed ? (
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
) : (
index + 1
)}
</div>
</div>
{/* Step Content */}
<div className="ml-3 min-w-0 flex-1">
<div className="flex items-center">
<h3
className={`text-sm font-medium ${
step.current
? 'text-blue-600 dark:text-blue-400'
: step.completed
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 dark:text-gray-400'
}`}
>
{step.title}
</h3>
{step.error && (
<div className="ml-2">
<svg className="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
<p
className={`text-xs mt-1 ${
step.current
? 'text-blue-500 dark:text-blue-300'
: step.completed
? 'text-green-500 dark:text-green-300'
: 'text-gray-400 dark:text-gray-500'
}`}
>
{step.description}
</p>
{step.error && (
<p className="text-xs text-red-500 dark:text-red-400 mt-1">
{step.error}
</p>
)}
</div>
{/* Connector Line */}
{index < steps.length - 1 && (
<div className="flex-1 mx-4">
<div
className={`h-0.5 ${
step.completed
? 'bg-green-500'
: 'bg-gray-200 dark:bg-gray-700'
}`}
></div>
</div>
)}
</div>
))}
</div>
</div>
);
}

View File

@ -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 (
<Elements stripe={stripePromise}>
{children}
</Elements>
);
}

View File

@ -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<TokenPackage[]>([]);
const [selectedPackageId, setSelectedPackageId] = useState<string>('');
const [calculation, setCalculation] = useState<PricingCalculation | null>(null);
const [showPaymentModal, setShowPaymentModal] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [flowState, setFlowState] = useState<PurchaseFlowState>(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 (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full max-h-[90vh] overflow-y-auto">
<div className="p-6">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Buy Interview Tokens
</h2>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Purchase tokens to conduct AI-powered interviews
</p>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Progress Indicator */}
<div className="mb-8">
<PurchaseFlowProgress
steps={flowState.steps}
currentStep={flowState.currentStep}
/>
</div>
{loading ? (
<LoadingCard
title="Loading packages..."
description="Please wait while we load available token packages"
/>
) : error ? (
<div className="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400">{error}</p>
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Left Column - Calculator */}
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Choose Your Tokens
</h3>
<CustomTokenCalculator
onCalculationChange={handleCalculationChange}
onPackageSelect={handlePackageSelect}
selectedPackageId={selectedPackageId}
/>
</div>
{/* Right Column - Packages & Summary */}
<div className="space-y-6">
{/* Popular Packages */}
{packages.length > 0 && (
<div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Popular Packages
</h3>
<div className="space-y-3">
{packages
.filter(pkg => pkg.is_popular)
.map((pkg) => (
<div
key={pkg.id}
className={`p-4 rounded-lg border cursor-pointer transition-colors ${
selectedPackageId === pkg.id
? 'border-blue-500 bg-blue-50 dark:bg-blue-900/20'
: 'border-gray-200 dark:border-gray-600 hover:border-gray-300 dark:hover:border-gray-500'
}`}
onClick={() => handlePackageSelect(pkg.id)}
>
<div className="flex items-center justify-between">
<div>
<div className="font-medium text-gray-900 dark:text-white">
{pkg.name}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pkg.description}
</div>
<div className="text-sm text-green-600 dark:text-green-400 mt-1">
{pkg.discount_percentage}% discount
</div>
</div>
<div className="text-right">
<div className="font-bold text-lg text-gray-900 dark:text-white">
{pkg.total_price.toFixed(2)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
{pkg.price_per_token.toFixed(2)} per token
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Purchase Summary */}
{calculation && (
<div className="bg-gray-50 dark:bg-gray-700 rounded-lg p-4">
<h3 className="font-medium text-gray-900 dark:text-white mb-3">
Purchase Summary
</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-400">
{calculation.packageName || `${calculation.quantity} Token${calculation.quantity > 1 ? 's' : ''}`}
</span>
<span className="text-gray-900 dark:text-white">
{calculation.finalPrice.toFixed(2)}
</span>
</div>
{calculation.savings > 0 && (
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>Discount ({calculation.discountPercentage}%)</span>
<span>-{calculation.savings.toFixed(2)}</span>
</div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 flex justify-between font-medium text-lg">
<span className="text-gray-900 dark:text-white">Total</span>
<span className="text-gray-900 dark:text-white">
{calculation.finalPrice.toFixed(2)}
</span>
</div>
</div>
<button
onClick={handlePurchase}
disabled={!calculation || calculation.finalPrice <= 0}
className="w-full mt-4 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Proceed to Payment
</button>
</div>
)}
{/* Features */}
<div className="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
<h3 className="font-medium text-blue-900 dark:text-blue-200 mb-2">
What you get:
</h3>
<ul className="text-sm text-blue-800 dark:text-blue-300 space-y-1">
<li> AI-powered interview questions</li>
<li> Real-time candidate evaluation</li>
<li> Detailed interview reports</li>
<li> No expiration date</li>
<li> Instant activation</li>
</ul>
</div>
</div>
</div>
)}
{/* Payment Modal */}
{showPaymentModal && calculation && (
<StripeProvider>
<PaymentModal
isOpen={showPaymentModal}
onClose={() => setShowPaymentModal(false)}
onSuccess={handlePaymentSuccess}
tokenQuantity={calculation.quantity}
packageId={calculation.packageId}
packageName={calculation.packageName}
calculation={calculation}
/>
</StripeProvider>
)}
</div>
</div>
</div>
);
}

View File

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

View File

@ -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<string, ErrorInfo> = {
// 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';
}
}
}

View File

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

View File

@ -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<PurchaseStep, 'completed' | 'current' | 'error'>[] = [
{
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 };
}
}

374
payment_todo.md Normal file
View File

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