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",
|
"@tsed/swagger": "^8.16.2",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/stripe": "^8.0.417",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
@ -53,6 +54,7 @@
|
|||||||
"method-override": "^3.0.0",
|
"method-override": "^3.0.0",
|
||||||
"mysql2": "^3.14.5",
|
"mysql2": "^3.14.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
"stripe": "^18.5.0",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -2027,6 +2029,15 @@
|
|||||||
"@types/send": "*"
|
"@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": {
|
"node_modules/@unhead/schema": {
|
||||||
"version": "1.11.20",
|
"version": "1.11.20",
|
||||||
"resolved": "https://registry.npmjs.org/@unhead/schema/-/schema-1.11.20.tgz",
|
"resolved": "https://registry.npmjs.org/@unhead/schema/-/schema-1.11.20.tgz",
|
||||||
@ -5252,6 +5263,25 @@
|
|||||||
"node": ">=6"
|
"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": {
|
"node_modules/strtok3": {
|
||||||
"version": "10.3.4",
|
"version": "10.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/strtok3/-/strtok3-10.3.4.tgz",
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
"@tsed/swagger": "^8.16.2",
|
"@tsed/swagger": "^8.16.2",
|
||||||
"@types/bcryptjs": "^3.0.0",
|
"@types/bcryptjs": "^3.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.10",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/stripe": "^8.0.417",
|
||||||
"ajv": "^8.17.1",
|
"ajv": "^8.17.1",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
@ -54,6 +55,7 @@
|
|||||||
"method-override": "^3.0.0",
|
"method-override": "^3.0.0",
|
||||||
"mysql2": "^3.14.5",
|
"mysql2": "^3.14.5",
|
||||||
"socket.io": "^4.8.1",
|
"socket.io": "^4.8.1",
|
||||||
|
"stripe": "^18.5.0",
|
||||||
"typescript": "^5.9.2"
|
"typescript": "^5.9.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -35,12 +35,18 @@ import {$log} from "@tsed/logger";
|
|||||||
version: process.env.APP_VERSION || "1.0.0",
|
version: process.env.APP_VERSION || "1.0.0",
|
||||||
description:
|
description:
|
||||||
"REST API for Candivista. Authentication via JWT Bearer tokens.\n\n" +
|
"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" +
|
"AI Features:\n" +
|
||||||
"- OpenRouter integration for cloud-based AI interviews\n" +
|
"- OpenRouter integration for cloud-based AI interviews\n" +
|
||||||
"- Ollama support for local AI processing\n" +
|
"- Ollama support for local AI processing\n" +
|
||||||
"- Test mode for admin interview testing\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: {
|
contact: {
|
||||||
name: "Candivista Team",
|
name: "Candivista Team",
|
||||||
url: "https://candivista.com",
|
url: "https://candivista.com",
|
||||||
@ -56,7 +62,9 @@ import {$log} from "@tsed/logger";
|
|||||||
{ name: "Users", description: "User profile and token summary" },
|
{ name: "Users", description: "User profile and token summary" },
|
||||||
{ name: "Jobs", description: "Job posting and interview token operations" },
|
{ name: "Jobs", description: "Job posting and interview token operations" },
|
||||||
{ name: "Admin", description: "Administrative statistics and management" },
|
{ 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: {
|
components: {
|
||||||
securitySchemes: {
|
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();
|
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 {
|
private mapUserToResponse(user: User): UserResponse {
|
||||||
return {
|
return {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
|
|||||||
@ -18,6 +18,7 @@ CREATE TABLE `users` (
|
|||||||
`role` enum('admin','recruiter') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'recruiter',
|
`role` enum('admin','recruiter') COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'recruiter',
|
||||||
`company_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
`company_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||||
`avatar_url` varchar(500) 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',
|
`is_active` tinyint(1) DEFAULT '1',
|
||||||
`last_login_at` timestamp NULL DEFAULT NULL,
|
`last_login_at` timestamp NULL DEFAULT NULL,
|
||||||
`email_verified_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_email` (`email`),
|
||||||
KEY `idx_role` (`role`),
|
KEY `idx_role` (`role`),
|
||||||
KEY `idx_active` (`is_active`),
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
-- token_packages
|
-- token_packages
|
||||||
@ -205,13 +207,22 @@ CREATE TABLE `interview_tokens` (
|
|||||||
CREATE TABLE `payment_records` (
|
CREATE TABLE `payment_records` (
|
||||||
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
|
`id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT (uuid()),
|
||||||
`user_id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL,
|
`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,
|
`amount` decimal(10,2) NOT NULL,
|
||||||
`currency` varchar(3) COLLATE utf8mb4_unicode_ci DEFAULT 'USD',
|
`currency` varchar(3) COLLATE utf8mb4_unicode_ci DEFAULT 'EUR',
|
||||||
`status` enum('pending','paid','failed','refunded','cancelled') COLLATE utf8mb4_unicode_ci DEFAULT 'pending',
|
`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_method` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL,
|
||||||
`payment_reference` varchar(255) 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,
|
`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,
|
`paid_at` timestamp NULL DEFAULT NULL,
|
||||||
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
`created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
`updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE 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_user_status` (`user_id`,`status`),
|
||||||
KEY `idx_payment_reference` (`payment_reference`),
|
KEY `idx_payment_reference` (`payment_reference`),
|
||||||
KEY `idx_payment_records_user_created` (`user_id`,`created_at` DESC),
|
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_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
|
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;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|||||||
@ -55,6 +55,10 @@ services:
|
|||||||
OPENROUTER_TEMPERATURE: ${OPENROUTER_TEMPERATURE}
|
OPENROUTER_TEMPERATURE: ${OPENROUTER_TEMPERATURE}
|
||||||
AI_PORT: ${AI_PORT}
|
AI_PORT: ${AI_PORT}
|
||||||
AI_MODEL: ${AI_MODEL}
|
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:
|
ports:
|
||||||
- "${BACKEND_PORT:-8083}:8083"
|
- "${BACKEND_PORT:-8083}:8083"
|
||||||
volumes:
|
volumes:
|
||||||
@ -89,6 +93,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
NODE_ENV: ${NODE_ENV:-production}
|
NODE_ENV: ${NODE_ENV:-production}
|
||||||
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
|
||||||
|
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: ${NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY}
|
||||||
ports:
|
ports:
|
||||||
- "${FRONTEND_PORT:-3000}:3000"
|
- "${FRONTEND_PORT:-3000}:3000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@ -36,3 +36,9 @@ CHATBOT_SERVICE_TIMEOUT=30000
|
|||||||
CHATBOT_FALLBACK_ENABLED=true
|
CHATBOT_FALLBACK_ENABLED=true
|
||||||
CHATBOT_PORT=5000
|
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_SERVICE_TIMEOUT=30000
|
||||||
CHATBOT_FALLBACK_ENABLED=true
|
CHATBOT_FALLBACK_ENABLED=true
|
||||||
CHATBOT_PORT=5000
|
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: {
|
env: {
|
||||||
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL || 'https://candivista.com',
|
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)
|
// 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",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
"@stripe/react-stripe-js": "^4.0.2",
|
||||||
|
"@stripe/stripe-js": "^7.9.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"next": "15.5.2",
|
"next": "15.5.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
@ -668,6 +670,27 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
|
"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": {
|
"node_modules/@swagger-api/apidom-ast": {
|
||||||
"version": "1.0.0-beta.48",
|
"version": "1.0.0-beta.48",
|
||||||
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.48.tgz",
|
"resolved": "https://registry.npmjs.org/@swagger-api/apidom-ast/-/apidom-ast-1.0.0-beta.48.tgz",
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.2.1",
|
"@hookform/resolvers": "^5.2.1",
|
||||||
|
"@stripe/react-stripe-js": "^4.0.2",
|
||||||
|
"@stripe/stripe-js": "^7.9.0",
|
||||||
"axios": "^1.11.0",
|
"axios": "^1.11.0",
|
||||||
"next": "15.5.2",
|
"next": "15.5.2",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist, Geist_Mono } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { ThemeProvider } from "next-themes";
|
import { ThemeProvider } from "next-themes";
|
||||||
|
import StripeProvider from "../components/StripeProvider";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
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`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased bg-white dark:bg-gray-900 text-gray-900 dark:text-white`}
|
||||||
>
|
>
|
||||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
{children}
|
<StripeProvider>
|
||||||
|
{children}
|
||||||
|
</StripeProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 { useState, useEffect } from "react";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import ThemeToggle from "./ThemeToggle";
|
import ThemeToggle from "./ThemeToggle";
|
||||||
|
import TokenPurchaseFlow from "./TokenPurchaseFlow";
|
||||||
|
import PaymentHistory from "./PaymentHistory";
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
first_name: string;
|
first_name: string;
|
||||||
@ -27,6 +29,8 @@ interface HeaderProps {
|
|||||||
export default function Header({ title, user, onLogout }: HeaderProps) {
|
export default function Header({ title, user, onLogout }: HeaderProps) {
|
||||||
const [tokenSummary, setTokenSummary] = useState<TokenSummary | null>(null);
|
const [tokenSummary, setTokenSummary] = useState<TokenSummary | null>(null);
|
||||||
const [loadingTokens, setLoadingTokens] = useState(false);
|
const [loadingTokens, setLoadingTokens] = useState(false);
|
||||||
|
const [showPurchaseModal, setShowPurchaseModal] = useState(false);
|
||||||
|
const [showPaymentHistory, setShowPaymentHistory] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user && user.role === 'recruiter') {
|
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 (
|
return (
|
||||||
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700 px-6 py-4">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
@ -99,6 +111,25 @@ export default function Header({ title, user, onLogout }: HeaderProps) {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</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 */}
|
{/* Token Usage Progress */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<div className="w-16 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||||
@ -177,6 +208,19 @@ export default function Header({ title, user, onLogout }: HeaderProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Token Purchase Modal */}
|
||||||
|
<TokenPurchaseFlow
|
||||||
|
isOpen={showPurchaseModal}
|
||||||
|
onClose={() => setShowPurchaseModal(false)}
|
||||||
|
onSuccess={handlePurchaseSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Payment History Modal */}
|
||||||
|
<PaymentHistory
|
||||||
|
isOpen={showPaymentHistory}
|
||||||
|
onClose={() => setShowPaymentHistory(false)}
|
||||||
|
/>
|
||||||
</header>
|
</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 CreateJobModal } from './CreateJobModal';
|
||||||
export { default as ThemeToggle } from './ThemeToggle';
|
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
|
// Landing page components
|
||||||
export { default as AnimatedCounter } from './AnimatedCounter';
|
export { default as AnimatedCounter } from './AnimatedCounter';
|
||||||
export { default as FeatureCard } from './FeatureCard';
|
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