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