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