Initial commit: Telegram Management System
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
Full-stack web application for Telegram management - Frontend: Vue 3 + Vben Admin - Backend: NestJS - Features: User management, group broadcast, statistics 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
29
marketing-agent/services/billing-service/.env.example
Normal file
29
marketing-agent/services/billing-service/.env.example
Normal file
@@ -0,0 +1,29 @@
|
||||
# Environment
|
||||
NODE_ENV=development
|
||||
PORT=3010
|
||||
|
||||
# MongoDB
|
||||
MONGODB_URI=mongodb://localhost:27017/billing
|
||||
|
||||
# Stripe (Get these from https://dashboard.stripe.com/apikeys)
|
||||
STRIPE_SECRET_KEY=sk_test_...
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# JWT
|
||||
JWT_SECRET=your-secret-key-here
|
||||
|
||||
# Redis
|
||||
REDIS_URL=redis://localhost:6379
|
||||
|
||||
# Email
|
||||
EMAIL_PROVIDER=sendgrid
|
||||
EMAIL_API_KEY=SG...
|
||||
EMAIL_FROM=billing@company.com
|
||||
|
||||
# CORS
|
||||
CORS_ORIGIN=http://localhost:8080,http://localhost:3000
|
||||
|
||||
# Rate Limiting
|
||||
RATE_LIMIT_WINDOW_MS=900000
|
||||
RATE_LIMIT_MAX=100
|
||||
51
marketing-agent/services/billing-service/package.json
Normal file
51
marketing-agent/services/billing-service/package.json
Normal file
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "billing-service",
|
||||
"version": "1.0.0",
|
||||
"description": "Billing and payment service for Marketing Agent",
|
||||
"type": "module",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js",
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.3.1",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.18.2",
|
||||
"express-rate-limit": "^7.1.5",
|
||||
"express-validator": "^7.0.1",
|
||||
"helmet": "^7.1.0",
|
||||
"json2csv": "^6.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mongoose": "^8.0.3",
|
||||
"pdfkit": "^0.14.0",
|
||||
"redis": "^4.6.12",
|
||||
"stripe": "^14.10.0",
|
||||
"winston": "^3.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.11",
|
||||
"eslint": "^8.56.0",
|
||||
"jest": "^29.7.0",
|
||||
"nodemon": "^3.0.2",
|
||||
"supertest": "^6.3.3"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
"transform": {},
|
||||
"extensionsToTreatAsEsm": [".js"],
|
||||
"moduleNameMapper": {
|
||||
"^(\\.{1,2}/.*)\\.js$": "$1"
|
||||
},
|
||||
"testMatch": [
|
||||
"**/tests/**/*.test.js"
|
||||
]
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
}
|
||||
114
marketing-agent/services/billing-service/src/app.js
Normal file
114
marketing-agent/services/billing-service/src/app.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import mongoose from 'mongoose';
|
||||
import { config } from './config/index.js';
|
||||
import { logger, requestLogger, errorLogger } from './utils/logger.js';
|
||||
|
||||
// Import routes
|
||||
import subscriptionRoutes from './routes/subscriptions.js';
|
||||
import invoiceRoutes from './routes/invoices.js';
|
||||
import paymentMethodRoutes from './routes/payment-methods.js';
|
||||
import transactionRoutes from './routes/transactions.js';
|
||||
import webhookRoutes from './routes/webhooks.js';
|
||||
|
||||
// Create Express app
|
||||
const app = express();
|
||||
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors(config.cors));
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit(config.rateLimit);
|
||||
app.use('/api/', limiter);
|
||||
|
||||
// Body parsing middleware
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// Webhook routes (before body parsing for raw body)
|
||||
app.use('/webhooks', webhookRoutes);
|
||||
|
||||
// Request logging
|
||||
app.use(requestLogger);
|
||||
|
||||
// API routes
|
||||
app.use('/api/subscriptions', subscriptionRoutes);
|
||||
app.use('/api/invoices', invoiceRoutes);
|
||||
app.use('/api/payment-methods', paymentMethodRoutes);
|
||||
app.use('/api/transactions', transactionRoutes);
|
||||
|
||||
// Health check
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'healthy',
|
||||
service: 'billing-service',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: config.env
|
||||
});
|
||||
});
|
||||
|
||||
// Error handling
|
||||
app.use(errorLogger);
|
||||
|
||||
// 404 handler
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: 'Route not found'
|
||||
});
|
||||
});
|
||||
|
||||
// Global error handler
|
||||
app.use((err, req, res, next) => {
|
||||
const status = err.status || 500;
|
||||
const message = err.message || 'Internal server error';
|
||||
|
||||
res.status(status).json({
|
||||
success: false,
|
||||
error: message,
|
||||
...(config.env === 'development' && { stack: err.stack })
|
||||
});
|
||||
});
|
||||
|
||||
// Database connection
|
||||
export async function connectDB() {
|
||||
try {
|
||||
await mongoose.connect(config.mongodb.uri, {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true
|
||||
});
|
||||
logger.info('MongoDB connected successfully');
|
||||
} catch (error) {
|
||||
logger.error('MongoDB connection error:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
export function setupGracefulShutdown(server) {
|
||||
const shutdown = async (signal) => {
|
||||
logger.info(`${signal} received, starting graceful shutdown`);
|
||||
|
||||
server.close(() => {
|
||||
logger.info('HTTP server closed');
|
||||
});
|
||||
|
||||
try {
|
||||
await mongoose.connection.close();
|
||||
logger.info('MongoDB connection closed');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error during shutdown:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
}
|
||||
|
||||
export default app;
|
||||
94
marketing-agent/services/billing-service/src/config/index.js
Normal file
94
marketing-agent/services/billing-service/src/config/index.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
export const config = {
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
port: process.env.PORT || 3010,
|
||||
|
||||
mongodb: {
|
||||
uri: process.env.MONGODB_URI || 'mongodb://localhost:27017/billing'
|
||||
},
|
||||
|
||||
stripe: {
|
||||
secretKey: process.env.STRIPE_SECRET_KEY || 'sk_test_...',
|
||||
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY || 'pk_test_...',
|
||||
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET || 'whsec_...'
|
||||
},
|
||||
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'your-secret-key',
|
||||
expiresIn: '7d'
|
||||
},
|
||||
|
||||
redis: {
|
||||
url: process.env.REDIS_URL || 'redis://localhost:6379'
|
||||
},
|
||||
|
||||
email: {
|
||||
provider: process.env.EMAIL_PROVIDER || 'sendgrid',
|
||||
apiKey: process.env.EMAIL_API_KEY || '',
|
||||
from: process.env.EMAIL_FROM || 'billing@company.com'
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: process.env.CORS_ORIGIN?.split(',') || ['http://localhost:8080'],
|
||||
credentials: true
|
||||
},
|
||||
|
||||
rateLimit: {
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 100 // limit each IP to 100 requests per windowMs
|
||||
},
|
||||
|
||||
billing: {
|
||||
currency: 'USD',
|
||||
taxRate: 0, // Default tax rate percentage
|
||||
trialDays: 14,
|
||||
gracePeriodDays: 7,
|
||||
dunningAttempts: 3,
|
||||
dunningIntervalDays: 3
|
||||
},
|
||||
|
||||
plans: {
|
||||
free: {
|
||||
name: 'Free',
|
||||
price: 0,
|
||||
features: {
|
||||
users: 5,
|
||||
campaigns: 10,
|
||||
messagesPerMonth: 1000
|
||||
}
|
||||
},
|
||||
starter: {
|
||||
name: 'Starter',
|
||||
price: 29,
|
||||
stripePriceId: 'price_starter_monthly',
|
||||
features: {
|
||||
users: 20,
|
||||
campaigns: 50,
|
||||
messagesPerMonth: 10000
|
||||
}
|
||||
},
|
||||
professional: {
|
||||
name: 'Professional',
|
||||
price: 99,
|
||||
stripePriceId: 'price_professional_monthly',
|
||||
features: {
|
||||
users: 100,
|
||||
campaigns: 'unlimited',
|
||||
messagesPerMonth: 100000
|
||||
}
|
||||
},
|
||||
enterprise: {
|
||||
name: 'Enterprise',
|
||||
price: 299,
|
||||
stripePriceId: 'price_enterprise_monthly',
|
||||
features: {
|
||||
users: 'unlimited',
|
||||
campaigns: 'unlimited',
|
||||
messagesPerMonth: 'unlimited'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
112
marketing-agent/services/billing-service/src/middleware/auth.js
Normal file
112
marketing-agent/services/billing-service/src/middleware/auth.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Authenticate user
|
||||
export const authenticate = async (req, res, next) => {
|
||||
try {
|
||||
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||
|
||||
if (!token) {
|
||||
return res.status(401).json({
|
||||
success: false,
|
||||
error: 'Authentication required'
|
||||
});
|
||||
}
|
||||
|
||||
const decoded = jwt.verify(token, config.jwt.secret);
|
||||
req.user = decoded;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Authentication error:', error);
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: 'Invalid or expired token'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Require tenant context
|
||||
export const requireTenant = async (req, res, next) => {
|
||||
try {
|
||||
// Get tenant from various sources
|
||||
const tenantId = req.headers['x-tenant-id'] ||
|
||||
req.user?.tenantId ||
|
||||
req.query.tenantId;
|
||||
|
||||
if (!tenantId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Tenant context required'
|
||||
});
|
||||
}
|
||||
|
||||
// In production, would validate tenant exists and user has access
|
||||
req.tenantId = tenantId;
|
||||
|
||||
// Mock tenant data for now
|
||||
req.tenant = {
|
||||
id: tenantId,
|
||||
name: 'Test Tenant',
|
||||
billing: {
|
||||
customerId: 'cus_test123'
|
||||
},
|
||||
owner: {
|
||||
email: 'owner@example.com'
|
||||
}
|
||||
};
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Tenant context error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to establish tenant context'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Require admin role
|
||||
export const requireAdmin = async (req, res, next) => {
|
||||
try {
|
||||
if (req.user?.role !== 'admin' && req.user?.role !== 'superadmin') {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: 'Admin access required'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Admin check error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to verify admin access'
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Require specific permissions
|
||||
export const requirePermission = (permission) => {
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const userPermissions = req.user?.permissions || [];
|
||||
|
||||
if (!userPermissions.includes(permission)) {
|
||||
return res.status(403).json({
|
||||
success: false,
|
||||
error: `Permission required: ${permission}`
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
logger.error('Permission check error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to verify permissions'
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,104 @@
|
||||
import { validationResult } from 'express-validator';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// Validate request middleware
|
||||
export const validateRequest = (validations) => {
|
||||
return async (req, res, next) => {
|
||||
// Run all validations
|
||||
await Promise.all(validations.map(validation => validation.run(req)));
|
||||
|
||||
const errors = validationResult(req);
|
||||
if (errors.isEmpty()) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const extractedErrors = [];
|
||||
errors.array().map(err => extractedErrors.push({
|
||||
field: err.path,
|
||||
message: err.msg,
|
||||
value: err.value
|
||||
}));
|
||||
|
||||
logger.warn('Validation errors', {
|
||||
path: req.path,
|
||||
errors: extractedErrors
|
||||
});
|
||||
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Validation failed',
|
||||
errors: extractedErrors
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
// Custom validators
|
||||
export const validators = {
|
||||
// Validate MongoDB ObjectId
|
||||
isMongoId: (value) => {
|
||||
const mongoIdRegex = /^[0-9a-fA-F]{24}$/;
|
||||
return mongoIdRegex.test(value);
|
||||
},
|
||||
|
||||
// Validate currency code
|
||||
isCurrency: (value) => {
|
||||
const validCurrencies = ['USD', 'EUR', 'GBP', 'JPY', 'CAD', 'AUD', 'CNY'];
|
||||
return validCurrencies.includes(value.toUpperCase());
|
||||
},
|
||||
|
||||
// Validate credit card number (basic check)
|
||||
isCreditCard: (value) => {
|
||||
const cardRegex = /^[0-9]{13,19}$/;
|
||||
return cardRegex.test(value.replace(/\s/g, ''));
|
||||
},
|
||||
|
||||
// Validate phone number
|
||||
isPhoneNumber: (value) => {
|
||||
const phoneRegex = /^\+?[1-9]\d{1,14}$/;
|
||||
return phoneRegex.test(value);
|
||||
},
|
||||
|
||||
// Validate date range
|
||||
isValidDateRange: (startDate, endDate) => {
|
||||
const start = new Date(startDate);
|
||||
const end = new Date(endDate);
|
||||
return start <= end;
|
||||
},
|
||||
|
||||
// Validate billing cycle
|
||||
isBillingCycle: (value) => {
|
||||
const validCycles = ['monthly', 'quarterly', 'yearly'];
|
||||
return validCycles.includes(value);
|
||||
},
|
||||
|
||||
// Validate plan type
|
||||
isPlanType: (value) => {
|
||||
const validPlans = ['free', 'starter', 'professional', 'enterprise', 'custom'];
|
||||
return validPlans.includes(value);
|
||||
}
|
||||
};
|
||||
|
||||
// Sanitization helpers
|
||||
export const sanitize = {
|
||||
// Clean string input
|
||||
cleanString: (value) => {
|
||||
if (typeof value !== 'string') return value;
|
||||
return value.trim().replace(/[<>]/g, '');
|
||||
},
|
||||
|
||||
// Format currency amount
|
||||
formatAmount: (value) => {
|
||||
const amount = parseFloat(value);
|
||||
return Math.round(amount * 100) / 100;
|
||||
},
|
||||
|
||||
// Normalize email
|
||||
normalizeEmail: (email) => {
|
||||
return email.toLowerCase().trim();
|
||||
},
|
||||
|
||||
// Clean credit card number
|
||||
cleanCardNumber: (value) => {
|
||||
return value.replace(/\s/g, '').replace(/-/g, '');
|
||||
}
|
||||
};
|
||||
257
marketing-agent/services/billing-service/src/models/Invoice.js
Normal file
257
marketing-agent/services/billing-service/src/models/Invoice.js
Normal file
@@ -0,0 +1,257 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const invoiceSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// Invoice details
|
||||
invoiceNumber: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
subscriptionId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Subscription',
|
||||
required: true
|
||||
},
|
||||
customerId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// Billing period
|
||||
periodStart: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
periodEnd: {
|
||||
type: Date,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['draft', 'open', 'paid', 'void', 'uncollectible'],
|
||||
default: 'draft',
|
||||
index: true
|
||||
},
|
||||
|
||||
// Line items
|
||||
lineItems: [{
|
||||
description: String,
|
||||
quantity: Number,
|
||||
unitPrice: Number,
|
||||
amount: Number,
|
||||
currency: String,
|
||||
period: {
|
||||
start: Date,
|
||||
end: Date
|
||||
},
|
||||
proration: Boolean,
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['subscription', 'usage', 'one_time', 'discount']
|
||||
}
|
||||
}],
|
||||
|
||||
// Amounts
|
||||
subtotal: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
tax: {
|
||||
rate: Number,
|
||||
amount: Number
|
||||
},
|
||||
discount: {
|
||||
coupon: String,
|
||||
amount: Number
|
||||
},
|
||||
total: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'USD'
|
||||
},
|
||||
|
||||
// Payment
|
||||
amountPaid: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
amountDue: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
attemptCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
// Dates
|
||||
dueDate: Date,
|
||||
paidAt: Date,
|
||||
voidedAt: Date,
|
||||
|
||||
// Payment details
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
last4: String,
|
||||
brand: String
|
||||
},
|
||||
paymentIntent: String,
|
||||
|
||||
// Customer details (snapshot at invoice time)
|
||||
customer: {
|
||||
name: String,
|
||||
email: String,
|
||||
phone: String,
|
||||
address: {
|
||||
line1: String,
|
||||
line2: String,
|
||||
city: String,
|
||||
state: String,
|
||||
postalCode: String,
|
||||
country: String
|
||||
}
|
||||
},
|
||||
|
||||
// PDF
|
||||
pdfUrl: String,
|
||||
|
||||
// Metadata
|
||||
metadata: mongoose.Schema.Types.Mixed,
|
||||
|
||||
// Stripe specific
|
||||
stripeInvoiceId: String,
|
||||
stripePaymentIntentId: String
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
invoiceSchema.index({ tenantId: 1, status: 1 });
|
||||
invoiceSchema.index({ tenantId: 1, periodStart: -1 });
|
||||
invoiceSchema.index({ tenantId: 1, customerId: 1 });
|
||||
invoiceSchema.index({ tenantId: 1, invoiceNumber: 1 }, { unique: true });
|
||||
|
||||
// Virtual for is overdue
|
||||
invoiceSchema.virtual('isOverdue').get(function() {
|
||||
if (this.status !== 'open') return false;
|
||||
if (!this.dueDate) return false;
|
||||
return new Date() > this.dueDate;
|
||||
});
|
||||
|
||||
// Virtual for days overdue
|
||||
invoiceSchema.virtual('daysOverdue').get(function() {
|
||||
if (!this.isOverdue) return 0;
|
||||
const now = new Date();
|
||||
const due = new Date(this.dueDate);
|
||||
return Math.floor((now - due) / (1000 * 60 * 60 * 24));
|
||||
});
|
||||
|
||||
// Methods
|
||||
invoiceSchema.methods.addLineItem = function(item) {
|
||||
this.lineItems.push(item);
|
||||
this.calculateTotals();
|
||||
};
|
||||
|
||||
invoiceSchema.methods.calculateTotals = function() {
|
||||
// Calculate subtotal
|
||||
this.subtotal = this.lineItems.reduce((sum, item) => {
|
||||
if (item.type === 'discount') {
|
||||
return sum - Math.abs(item.amount);
|
||||
}
|
||||
return sum + item.amount;
|
||||
}, 0);
|
||||
|
||||
// Apply tax
|
||||
if (this.tax && this.tax.rate) {
|
||||
this.tax.amount = this.subtotal * (this.tax.rate / 100);
|
||||
}
|
||||
|
||||
// Calculate total
|
||||
this.total = this.subtotal + (this.tax?.amount || 0);
|
||||
this.amountDue = this.total - this.amountPaid;
|
||||
};
|
||||
|
||||
invoiceSchema.methods.markPaid = async function(paymentDetails) {
|
||||
this.status = 'paid';
|
||||
this.paidAt = new Date();
|
||||
this.amountPaid = this.total;
|
||||
this.amountDue = 0;
|
||||
|
||||
if (paymentDetails) {
|
||||
this.paymentMethod = paymentDetails.paymentMethod;
|
||||
this.paymentIntent = paymentDetails.paymentIntent;
|
||||
}
|
||||
|
||||
await this.save();
|
||||
};
|
||||
|
||||
invoiceSchema.methods.void = async function(reason) {
|
||||
this.status = 'void';
|
||||
this.voidedAt = new Date();
|
||||
if (reason) {
|
||||
this.metadata = this.metadata || {};
|
||||
this.metadata.voidReason = reason;
|
||||
}
|
||||
await this.save();
|
||||
};
|
||||
|
||||
invoiceSchema.methods.generatePDF = async function() {
|
||||
// This would integrate with a PDF generation service
|
||||
// For now, just return a placeholder
|
||||
this.pdfUrl = `/invoices/${this.invoiceNumber}.pdf`;
|
||||
await this.save();
|
||||
return this.pdfUrl;
|
||||
};
|
||||
|
||||
// Statics
|
||||
invoiceSchema.statics.generateInvoiceNumber = async function(tenantId) {
|
||||
const date = new Date();
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
|
||||
// Find the last invoice for this tenant in the current month
|
||||
const lastInvoice = await this.findOne({
|
||||
tenantId,
|
||||
invoiceNumber: new RegExp(`^INV-${year}${month}`)
|
||||
}).sort({ invoiceNumber: -1 });
|
||||
|
||||
let sequence = 1;
|
||||
if (lastInvoice) {
|
||||
const lastSequence = parseInt(lastInvoice.invoiceNumber.split('-').pop());
|
||||
sequence = lastSequence + 1;
|
||||
}
|
||||
|
||||
return `INV-${year}${month}-${String(sequence).padStart(4, '0')}`;
|
||||
};
|
||||
|
||||
invoiceSchema.statics.findUnpaid = function(tenantId) {
|
||||
return this.find({
|
||||
tenantId,
|
||||
status: 'open',
|
||||
amountDue: { $gt: 0 }
|
||||
}).sort({ dueDate: 1 });
|
||||
};
|
||||
|
||||
invoiceSchema.statics.findOverdue = function(tenantId) {
|
||||
return this.find({
|
||||
tenantId,
|
||||
status: 'open',
|
||||
dueDate: { $lt: new Date() }
|
||||
}).sort({ dueDate: 1 });
|
||||
};
|
||||
|
||||
export const Invoice = mongoose.model('Invoice', invoiceSchema);
|
||||
@@ -0,0 +1,220 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const paymentMethodSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// Customer
|
||||
customerId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// Payment method details
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['card', 'bank_account', 'paypal', 'alipay', 'wechat_pay'],
|
||||
required: true
|
||||
},
|
||||
|
||||
isDefault: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
// Card details
|
||||
card: {
|
||||
brand: String, // visa, mastercard, amex, etc.
|
||||
last4: String,
|
||||
expiryMonth: Number,
|
||||
expiryYear: Number,
|
||||
fingerprint: String,
|
||||
country: String,
|
||||
funding: String, // credit, debit, prepaid
|
||||
checks: {
|
||||
addressLine1Check: String,
|
||||
addressPostalCodeCheck: String,
|
||||
cvcCheck: String
|
||||
}
|
||||
},
|
||||
|
||||
// Bank account details
|
||||
bankAccount: {
|
||||
accountHolderName: String,
|
||||
accountHolderType: String, // individual, company
|
||||
bankName: String,
|
||||
last4: String,
|
||||
routingNumber: String,
|
||||
country: String,
|
||||
currency: String,
|
||||
status: String // new, validated, verified, verification_failed
|
||||
},
|
||||
|
||||
// PayPal details
|
||||
paypal: {
|
||||
email: String,
|
||||
payerId: String
|
||||
},
|
||||
|
||||
// Billing address
|
||||
billingAddress: {
|
||||
line1: String,
|
||||
line2: String,
|
||||
city: String,
|
||||
state: String,
|
||||
postalCode: String,
|
||||
country: String
|
||||
},
|
||||
|
||||
// Status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['active', 'inactive', 'expired', 'failed'],
|
||||
default: 'active'
|
||||
},
|
||||
|
||||
// Verification
|
||||
verified: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
verifiedAt: Date,
|
||||
|
||||
// Metadata
|
||||
metadata: mongoose.Schema.Types.Mixed,
|
||||
|
||||
// External IDs
|
||||
stripePaymentMethodId: String,
|
||||
paypalPaymentMethodId: String
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
paymentMethodSchema.index({ tenantId: 1, customerId: 1 });
|
||||
paymentMethodSchema.index({ tenantId: 1, isDefault: 1 });
|
||||
paymentMethodSchema.index({ tenantId: 1, status: 1 });
|
||||
|
||||
// Pre-save middleware to ensure only one default payment method per customer
|
||||
paymentMethodSchema.pre('save', async function(next) {
|
||||
if (this.isDefault) {
|
||||
// Remove default from other payment methods
|
||||
await this.constructor.updateMany(
|
||||
{
|
||||
tenantId: this.tenantId,
|
||||
customerId: this.customerId,
|
||||
_id: { $ne: this._id }
|
||||
},
|
||||
{ isDefault: false }
|
||||
);
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// Methods
|
||||
paymentMethodSchema.methods.makeDefault = async function() {
|
||||
// Remove default from other payment methods
|
||||
await this.constructor.updateMany(
|
||||
{
|
||||
tenantId: this.tenantId,
|
||||
customerId: this.customerId,
|
||||
_id: { $ne: this._id }
|
||||
},
|
||||
{ isDefault: false }
|
||||
);
|
||||
|
||||
this.isDefault = true;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
paymentMethodSchema.methods.deactivate = async function() {
|
||||
this.status = 'inactive';
|
||||
this.isDefault = false;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
paymentMethodSchema.methods.isExpired = function() {
|
||||
if (this.type !== 'card' || !this.card) return false;
|
||||
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1;
|
||||
|
||||
return this.card.expiryYear < currentYear ||
|
||||
(this.card.expiryYear === currentYear && this.card.expiryMonth < currentMonth);
|
||||
};
|
||||
|
||||
paymentMethodSchema.methods.maskDetails = function() {
|
||||
const masked = this.toObject();
|
||||
|
||||
if (masked.card) {
|
||||
// Only keep last4 and brand
|
||||
masked.card = {
|
||||
brand: masked.card.brand,
|
||||
last4: masked.card.last4,
|
||||
expiryMonth: masked.card.expiryMonth,
|
||||
expiryYear: masked.card.expiryYear
|
||||
};
|
||||
}
|
||||
|
||||
if (masked.bankAccount) {
|
||||
// Only keep last4 and bank name
|
||||
masked.bankAccount = {
|
||||
bankName: masked.bankAccount.bankName,
|
||||
last4: masked.bankAccount.last4
|
||||
};
|
||||
}
|
||||
|
||||
// Remove sensitive external IDs
|
||||
delete masked.stripePaymentMethodId;
|
||||
delete masked.paypalPaymentMethodId;
|
||||
|
||||
return masked;
|
||||
};
|
||||
|
||||
// Statics
|
||||
paymentMethodSchema.statics.findByCustomer = function(tenantId, customerId) {
|
||||
return this.find({
|
||||
tenantId,
|
||||
customerId,
|
||||
status: 'active'
|
||||
}).sort({ isDefault: -1, createdAt: -1 });
|
||||
};
|
||||
|
||||
paymentMethodSchema.statics.findDefault = function(tenantId, customerId) {
|
||||
return this.findOne({
|
||||
tenantId,
|
||||
customerId,
|
||||
isDefault: true,
|
||||
status: 'active'
|
||||
});
|
||||
};
|
||||
|
||||
paymentMethodSchema.statics.findExpiring = function(tenantId, months = 2) {
|
||||
const futureDate = new Date();
|
||||
futureDate.setMonth(futureDate.getMonth() + months);
|
||||
|
||||
const year = futureDate.getFullYear();
|
||||
const month = futureDate.getMonth() + 1;
|
||||
|
||||
return this.find({
|
||||
tenantId,
|
||||
type: 'card',
|
||||
status: 'active',
|
||||
$or: [
|
||||
{ 'card.expiryYear': { $lt: year } },
|
||||
{
|
||||
'card.expiryYear': year,
|
||||
'card.expiryMonth': { $lte: month }
|
||||
}
|
||||
]
|
||||
});
|
||||
};
|
||||
|
||||
export const PaymentMethod = mongoose.model('PaymentMethod', paymentMethodSchema);
|
||||
@@ -0,0 +1,184 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const subscriptionSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// Subscription details
|
||||
customerId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
subscriptionId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// Plan information
|
||||
plan: {
|
||||
type: String,
|
||||
enum: ['free', 'starter', 'professional', 'enterprise', 'custom'],
|
||||
required: true
|
||||
},
|
||||
planName: String,
|
||||
planPrice: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'USD'
|
||||
},
|
||||
|
||||
// Billing cycle
|
||||
billingCycle: {
|
||||
type: String,
|
||||
enum: ['monthly', 'yearly', 'custom'],
|
||||
default: 'monthly'
|
||||
},
|
||||
interval: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
|
||||
// Status
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['trialing', 'active', 'past_due', 'canceled', 'unpaid', 'incomplete'],
|
||||
default: 'active',
|
||||
index: true
|
||||
},
|
||||
|
||||
// Dates
|
||||
currentPeriodStart: Date,
|
||||
currentPeriodEnd: Date,
|
||||
trialStart: Date,
|
||||
trialEnd: Date,
|
||||
canceledAt: Date,
|
||||
endedAt: Date,
|
||||
|
||||
// Usage-based billing
|
||||
usage: [{
|
||||
metric: String,
|
||||
quantity: Number,
|
||||
unitPrice: Number,
|
||||
total: Number,
|
||||
period: Date
|
||||
}],
|
||||
|
||||
// Discounts
|
||||
discount: {
|
||||
coupon: String,
|
||||
percentOff: Number,
|
||||
amountOff: Number,
|
||||
duration: String, // forever, once, repeating
|
||||
durationInMonths: Number
|
||||
},
|
||||
|
||||
// Payment method
|
||||
paymentMethod: {
|
||||
type: String,
|
||||
last4: String,
|
||||
brand: String,
|
||||
expiryMonth: Number,
|
||||
expiryYear: Number
|
||||
},
|
||||
|
||||
// Metadata
|
||||
metadata: mongoose.Schema.Types.Mixed,
|
||||
|
||||
// Stripe specific
|
||||
stripeCustomerId: String,
|
||||
stripeSubscriptionId: String,
|
||||
stripePriceId: String
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
subscriptionSchema.index({ tenantId: 1, status: 1 });
|
||||
subscriptionSchema.index({ tenantId: 1, currentPeriodEnd: 1 });
|
||||
subscriptionSchema.index({ tenantId: 1, plan: 1 });
|
||||
|
||||
// Virtual for days until renewal
|
||||
subscriptionSchema.virtual('daysUntilRenewal').get(function() {
|
||||
if (!this.currentPeriodEnd) return null;
|
||||
const now = new Date();
|
||||
const end = new Date(this.currentPeriodEnd);
|
||||
const days = Math.ceil((end - now) / (1000 * 60 * 60 * 24));
|
||||
return days > 0 ? days : 0;
|
||||
});
|
||||
|
||||
// Methods
|
||||
subscriptionSchema.methods.isActive = function() {
|
||||
return ['active', 'trialing'].includes(this.status);
|
||||
};
|
||||
|
||||
subscriptionSchema.methods.isTrialing = function() {
|
||||
return this.status === 'trialing' && this.trialEnd && new Date() < this.trialEnd;
|
||||
};
|
||||
|
||||
subscriptionSchema.methods.cancel = async function(immediately = false) {
|
||||
this.status = 'canceled';
|
||||
this.canceledAt = new Date();
|
||||
|
||||
if (immediately) {
|
||||
this.endedAt = new Date();
|
||||
} else {
|
||||
this.endedAt = this.currentPeriodEnd;
|
||||
}
|
||||
|
||||
await this.save();
|
||||
};
|
||||
|
||||
subscriptionSchema.methods.recordUsage = async function(metric, quantity, unitPrice) {
|
||||
const usage = {
|
||||
metric,
|
||||
quantity,
|
||||
unitPrice,
|
||||
total: quantity * unitPrice,
|
||||
period: new Date()
|
||||
};
|
||||
|
||||
this.usage.push(usage);
|
||||
await this.save();
|
||||
|
||||
return usage;
|
||||
};
|
||||
|
||||
subscriptionSchema.methods.applyDiscount = async function(discount) {
|
||||
this.discount = discount;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
// Statics
|
||||
subscriptionSchema.statics.findByTenant = function(tenantId) {
|
||||
return this.find({ tenantId, status: { $in: ['active', 'trialing'] } });
|
||||
};
|
||||
|
||||
subscriptionSchema.statics.findExpiring = function(days = 7) {
|
||||
const future = new Date();
|
||||
future.setDate(future.getDate() + days);
|
||||
|
||||
return this.find({
|
||||
status: 'active',
|
||||
currentPeriodEnd: {
|
||||
$gte: new Date(),
|
||||
$lte: future
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
subscriptionSchema.statics.findPastDue = function() {
|
||||
return this.find({ status: 'past_due' });
|
||||
};
|
||||
|
||||
export const Subscription = mongoose.model('Subscription', subscriptionSchema);
|
||||
@@ -0,0 +1,239 @@
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
const transactionSchema = new mongoose.Schema({
|
||||
// Multi-tenant support
|
||||
tenantId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Tenant',
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
|
||||
// Transaction details
|
||||
transactionId: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true
|
||||
},
|
||||
|
||||
type: {
|
||||
type: String,
|
||||
enum: ['payment', 'refund', 'adjustment', 'payout'],
|
||||
required: true
|
||||
},
|
||||
|
||||
status: {
|
||||
type: String,
|
||||
enum: ['pending', 'processing', 'succeeded', 'failed', 'canceled'],
|
||||
default: 'pending',
|
||||
index: true
|
||||
},
|
||||
|
||||
// Related entities
|
||||
customerId: {
|
||||
type: String,
|
||||
required: true,
|
||||
index: true
|
||||
},
|
||||
subscriptionId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Subscription'
|
||||
},
|
||||
invoiceId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'Invoice'
|
||||
},
|
||||
paymentMethodId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: 'PaymentMethod'
|
||||
},
|
||||
|
||||
// Amounts
|
||||
amount: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
currency: {
|
||||
type: String,
|
||||
default: 'USD'
|
||||
},
|
||||
fee: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
net: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
|
||||
// Processing details
|
||||
processor: {
|
||||
type: String,
|
||||
enum: ['stripe', 'paypal', 'manual', 'bank_transfer'],
|
||||
required: true
|
||||
},
|
||||
processorTransactionId: String,
|
||||
processorResponse: mongoose.Schema.Types.Mixed,
|
||||
|
||||
// Error handling
|
||||
failureCode: String,
|
||||
failureMessage: String,
|
||||
retryCount: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
nextRetryAt: Date,
|
||||
|
||||
// Refund details (for refund transactions)
|
||||
refund: {
|
||||
originalTransactionId: String,
|
||||
reason: String,
|
||||
requestedBy: String
|
||||
},
|
||||
|
||||
// Description
|
||||
description: String,
|
||||
statementDescriptor: String,
|
||||
|
||||
// Metadata
|
||||
metadata: mongoose.Schema.Types.Mixed,
|
||||
|
||||
// Timestamps
|
||||
processedAt: Date,
|
||||
failedAt: Date
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
||||
// Indexes
|
||||
transactionSchema.index({ tenantId: 1, status: 1 });
|
||||
transactionSchema.index({ tenantId: 1, customerId: 1, createdAt: -1 });
|
||||
transactionSchema.index({ tenantId: 1, type: 1, status: 1 });
|
||||
transactionSchema.index({ tenantId: 1, invoiceId: 1 });
|
||||
|
||||
// Virtual for display amount
|
||||
transactionSchema.virtual('displayAmount').get(function() {
|
||||
const sign = this.type === 'refund' ? '-' : '';
|
||||
return `${sign}${(this.amount / 100).toFixed(2)} ${this.currency}`;
|
||||
});
|
||||
|
||||
// Methods
|
||||
transactionSchema.methods.markSuccessful = async function(processorResponse) {
|
||||
this.status = 'succeeded';
|
||||
this.processedAt = new Date();
|
||||
this.processorResponse = processorResponse;
|
||||
await this.save();
|
||||
};
|
||||
|
||||
transactionSchema.methods.markFailed = async function(error) {
|
||||
this.status = 'failed';
|
||||
this.failedAt = new Date();
|
||||
this.failureCode = error.code;
|
||||
this.failureMessage = error.message;
|
||||
this.retryCount += 1;
|
||||
|
||||
// Set next retry time (exponential backoff)
|
||||
if (this.retryCount < 3) {
|
||||
const delayMinutes = Math.pow(2, this.retryCount) * 15; // 15, 30, 60 minutes
|
||||
this.nextRetryAt = new Date(Date.now() + delayMinutes * 60 * 1000);
|
||||
}
|
||||
|
||||
await this.save();
|
||||
};
|
||||
|
||||
transactionSchema.methods.canRetry = function() {
|
||||
return this.status === 'failed' &&
|
||||
this.retryCount < 3 &&
|
||||
this.nextRetryAt &&
|
||||
new Date() >= this.nextRetryAt;
|
||||
};
|
||||
|
||||
transactionSchema.methods.createRefund = async function(amount, reason, requestedBy) {
|
||||
if (this.type !== 'payment' || this.status !== 'succeeded') {
|
||||
throw new Error('Can only refund successful payments');
|
||||
}
|
||||
|
||||
const refundTransaction = new this.constructor({
|
||||
tenantId: this.tenantId,
|
||||
transactionId: `rfnd_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
type: 'refund',
|
||||
customerId: this.customerId,
|
||||
subscriptionId: this.subscriptionId,
|
||||
invoiceId: this.invoiceId,
|
||||
paymentMethodId: this.paymentMethodId,
|
||||
amount: amount || this.amount,
|
||||
currency: this.currency,
|
||||
fee: 0,
|
||||
net: -(amount || this.amount),
|
||||
processor: this.processor,
|
||||
refund: {
|
||||
originalTransactionId: this.transactionId,
|
||||
reason,
|
||||
requestedBy
|
||||
},
|
||||
description: `Refund for ${this.transactionId}`
|
||||
});
|
||||
|
||||
await refundTransaction.save();
|
||||
return refundTransaction;
|
||||
};
|
||||
|
||||
// Statics
|
||||
transactionSchema.statics.generateTransactionId = function(type = 'txn') {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substr(2, 9);
|
||||
return `${type}_${timestamp}_${random}`;
|
||||
};
|
||||
|
||||
transactionSchema.statics.findByCustomer = function(tenantId, customerId, options = {}) {
|
||||
const query = { tenantId, customerId };
|
||||
|
||||
if (options.type) query.type = options.type;
|
||||
if (options.status) query.status = options.status;
|
||||
if (options.startDate || options.endDate) {
|
||||
query.createdAt = {};
|
||||
if (options.startDate) query.createdAt.$gte = options.startDate;
|
||||
if (options.endDate) query.createdAt.$lte = options.endDate;
|
||||
}
|
||||
|
||||
return this.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(options.limit || 100);
|
||||
};
|
||||
|
||||
transactionSchema.statics.findPendingRetries = function() {
|
||||
return this.find({
|
||||
status: 'failed',
|
||||
retryCount: { $lt: 3 },
|
||||
nextRetryAt: { $lte: new Date() }
|
||||
});
|
||||
};
|
||||
|
||||
transactionSchema.statics.calculateRevenue = async function(tenantId, startDate, endDate) {
|
||||
const result = await this.aggregate([
|
||||
{
|
||||
$match: {
|
||||
tenantId: mongoose.Types.ObjectId(tenantId),
|
||||
type: 'payment',
|
||||
status: 'succeeded',
|
||||
processedAt: {
|
||||
$gte: startDate,
|
||||
$lte: endDate
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$currency',
|
||||
totalAmount: { $sum: '$amount' },
|
||||
totalFees: { $sum: '$fee' },
|
||||
totalNet: { $sum: '$net' },
|
||||
count: { $sum: 1 }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const Transaction = mongoose.model('Transaction', transactionSchema);
|
||||
353
marketing-agent/services/billing-service/src/routes/invoices.js
Normal file
353
marketing-agent/services/billing-service/src/routes/invoices.js
Normal file
@@ -0,0 +1,353 @@
|
||||
import express from 'express';
|
||||
import { invoiceService } from '../services/invoiceService.js';
|
||||
import { authenticate, requireTenant } from '../middleware/auth.js';
|
||||
import { validateRequest } from '../middleware/validation.js';
|
||||
import { body, param, query } from 'express-validator';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create invoice
|
||||
router.post('/',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
body('subscriptionId').optional().isMongoId(),
|
||||
body('lineItems').isArray(),
|
||||
body('lineItems.*.description').notEmpty(),
|
||||
body('lineItems.*.quantity').isInt({ min: 1 }),
|
||||
body('lineItems.*.unitPrice').isFloat({ min: 0 }),
|
||||
body('dueDate').optional().isISO8601()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoice = await invoiceService.createInvoice(
|
||||
req.tenantId,
|
||||
{
|
||||
...req.body,
|
||||
customerId: req.tenant.billing?.customerId,
|
||||
customer: {
|
||||
name: req.tenant.name,
|
||||
email: req.tenant.owner.email,
|
||||
address: req.tenant.billing?.billingAddress
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
invoice
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Create invoice error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create invoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get invoices
|
||||
router.get('/',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
query('status').optional().isIn(['draft', 'open', 'paid', 'void', 'uncollectible']),
|
||||
query('startDate').optional().isISO8601(),
|
||||
query('endDate').optional().isISO8601(),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 })
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoices = await invoiceService.getInvoices(
|
||||
req.tenantId,
|
||||
req.query
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
invoices
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get invoices error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get invoices'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get unpaid invoices
|
||||
router.get('/unpaid',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoices = await invoiceService.getUnpaidInvoices(req.tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
invoices,
|
||||
totalDue: invoices.reduce((sum, inv) => sum + inv.amountDue, 0)
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get unpaid invoices error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get unpaid invoices'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get overdue invoices
|
||||
router.get('/overdue',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoices = await invoiceService.getOverdueInvoices(req.tenantId);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
invoices,
|
||||
totalOverdue: invoices.reduce((sum, inv) => sum + inv.amountDue, 0)
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get overdue invoices error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get overdue invoices'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get invoice by ID
|
||||
router.get('/:id',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoice = await invoiceService.getInvoice(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
if (!invoice) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invoice not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
invoice
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get invoice error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get invoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update invoice (draft only)
|
||||
router.patch('/:id',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('lineItems').optional().isArray(),
|
||||
body('dueDate').optional().isISO8601()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoice = await invoiceService.updateInvoice(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
req.body
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
invoice
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Update invoice error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update invoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Finalize invoice
|
||||
router.post('/:id/finalize',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoice = await invoiceService.finalizeInvoice(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
invoice,
|
||||
message: 'Invoice finalized and sent'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Finalize invoice error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to finalize invoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Pay invoice
|
||||
router.post('/:id/pay',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('paymentMethodId').optional().isString()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoice = await invoiceService.payInvoice(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
{
|
||||
paymentMethodId: req.body.paymentMethodId
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
invoice,
|
||||
message: 'Invoice paid successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Pay invoice error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to pay invoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Void invoice
|
||||
router.post('/:id/void',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('reason').notEmpty()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoice = await invoiceService.voidInvoice(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
req.body.reason
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
invoice,
|
||||
message: 'Invoice voided'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Void invoice error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to void invoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Download invoice PDF
|
||||
router.get('/:id/pdf',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const invoice = await invoiceService.getInvoice(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
if (!invoice) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Invoice not found'
|
||||
});
|
||||
}
|
||||
|
||||
// Generate PDF if not exists
|
||||
if (!invoice.pdfUrl) {
|
||||
await invoiceService.generatePDF(invoice);
|
||||
}
|
||||
|
||||
// In production, redirect to cloud storage URL
|
||||
res.redirect(invoice.pdfUrl);
|
||||
} catch (error) {
|
||||
logger.error('Download invoice PDF error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to download invoice'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Send invoice reminder
|
||||
router.post('/:id/remind',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
await invoiceService.sendInvoiceReminder(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Invoice reminder sent'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Send reminder error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to send reminder'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,222 @@
|
||||
import express from 'express';
|
||||
import { paymentMethodService } from '../services/paymentMethodService.js';
|
||||
import { authenticate, requireTenant } from '../middleware/auth.js';
|
||||
import { validateRequest } from '../middleware/validation.js';
|
||||
import { body, param, query } from 'express-validator';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Add payment method
|
||||
router.post('/',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
body('type').isIn(['card', 'bank_account']),
|
||||
body('token').optional().isString(),
|
||||
body('card').optional().isObject(),
|
||||
body('bankAccount').optional().isObject()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const paymentMethod = await paymentMethodService.addPaymentMethod(
|
||||
req.tenantId,
|
||||
{
|
||||
...req.body,
|
||||
customerId: req.tenant.billing?.customerId
|
||||
}
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
paymentMethod
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Add payment method error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to add payment method'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get payment methods
|
||||
router.get('/',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const paymentMethods = await paymentMethodService.getPaymentMethods(
|
||||
req.tenantId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
paymentMethods
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get payment methods error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get payment methods'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get payment method by ID
|
||||
router.get('/:id',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const paymentMethod = await paymentMethodService.getPaymentMethod(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
if (!paymentMethod) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Payment method not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
paymentMethod
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get payment method error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get payment method'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update payment method
|
||||
router.patch('/:id',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('billingAddress').optional().isObject(),
|
||||
body('metadata').optional().isObject()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const paymentMethod = await paymentMethodService.updatePaymentMethod(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
req.body
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
paymentMethod
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Update payment method error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update payment method'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Set default payment method
|
||||
router.post('/:id/default',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const paymentMethod = await paymentMethodService.setDefaultPaymentMethod(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
paymentMethod,
|
||||
message: 'Payment method set as default'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Set default payment method error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to set default payment method'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Remove payment method
|
||||
router.delete('/:id',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
await paymentMethodService.removePaymentMethod(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Payment method removed'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Remove payment method error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to remove payment method'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Verify payment method
|
||||
router.post('/:id/verify',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('amounts').optional().isArray()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const paymentMethod = await paymentMethodService.verifyPaymentMethod(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
req.body.amounts
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
paymentMethod,
|
||||
message: 'Payment method verified'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Verify payment method error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to verify payment method'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,296 @@
|
||||
import express from 'express';
|
||||
import { subscriptionService } from '../services/subscriptionService.js';
|
||||
import { authenticate, requireTenant } from '../middleware/auth.js';
|
||||
import { validateRequest } from '../middleware/validation.js';
|
||||
import { body, param, query } from 'express-validator';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Create subscription
|
||||
router.post('/',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
body('plan').isIn(['free', 'starter', 'professional', 'enterprise']),
|
||||
body('billingCycle').optional().isIn(['monthly', 'yearly']),
|
||||
body('paymentMethodId').optional().isString()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const subscription = await subscriptionService.createSubscription(
|
||||
req.tenantId,
|
||||
{
|
||||
...req.body,
|
||||
customerId: req.tenant.billing?.customerId
|
||||
}
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
subscription
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Create subscription error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create subscription'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get tenant subscriptions
|
||||
router.get('/',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const subscriptions = await subscriptionService.getSubscriptionsByTenant(
|
||||
req.tenantId
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
subscriptions
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get subscriptions error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get subscriptions'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get subscription by ID
|
||||
router.get('/:id',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const subscription = await subscriptionService.getSubscription(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
if (!subscription) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Subscription not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
subscription
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get subscription error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get subscription'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Update subscription
|
||||
router.patch('/:id',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('plan').optional().isIn(['free', 'starter', 'professional', 'enterprise']),
|
||||
body('quantity').optional().isInt({ min: 1 })
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const subscription = await subscriptionService.updateSubscription(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
req.body
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
subscription
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Update subscription error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to update subscription'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Cancel subscription
|
||||
router.post('/:id/cancel',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('immediately').optional().isBoolean(),
|
||||
body('reason').optional().isString()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const subscription = await subscriptionService.cancelSubscription(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
{
|
||||
immediately: req.body.immediately,
|
||||
reason: req.body.reason
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
subscription,
|
||||
message: req.body.immediately ?
|
||||
'Subscription canceled immediately' :
|
||||
'Subscription will be canceled at the end of the billing period'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Cancel subscription error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to cancel subscription'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Reactivate subscription
|
||||
router.post('/:id/reactivate',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const subscription = await subscriptionService.reactivateSubscription(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
subscription,
|
||||
message: 'Subscription reactivated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Reactivate subscription error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to reactivate subscription'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Record usage
|
||||
router.post('/:id/usage',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('metric').notEmpty(),
|
||||
body('quantity').isInt({ min: 1 })
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const usage = await subscriptionService.recordUsage(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
req.body.metric,
|
||||
req.body.quantity
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
usage
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Record usage error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to record usage'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Apply discount
|
||||
router.post('/:id/discount',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('couponCode').notEmpty()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const subscription = await subscriptionService.applyDiscount(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
req.body.couponCode
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
subscription,
|
||||
message: 'Discount applied successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Apply discount error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to apply discount'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get subscription usage
|
||||
router.get('/:id/usage',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
query('startDate').optional().isISO8601(),
|
||||
query('endDate').optional().isISO8601()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const usage = await subscriptionService.getSubscriptionUsage(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
{
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
usage
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get usage error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get usage'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,222 @@
|
||||
import express from 'express';
|
||||
import { transactionService } from '../services/transactionService.js';
|
||||
import { authenticate, requireTenant, requireAdmin } from '../middleware/auth.js';
|
||||
import { validateRequest } from '../middleware/validation.js';
|
||||
import { body, param, query } from 'express-validator';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Get transactions
|
||||
router.get('/',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
query('type').optional().isIn(['payment', 'refund', 'adjustment', 'fee']),
|
||||
query('status').optional().isIn(['pending', 'processing', 'succeeded', 'failed', 'canceled']),
|
||||
query('startDate').optional().isISO8601(),
|
||||
query('endDate').optional().isISO8601(),
|
||||
query('limit').optional().isInt({ min: 1, max: 100 }),
|
||||
query('offset').optional().isInt({ min: 0 })
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const transactions = await transactionService.getTransactions(
|
||||
req.tenantId,
|
||||
req.query
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
transactions
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get transactions error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get transactions'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get transaction by ID
|
||||
router.get('/:id',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const transaction = await transactionService.getTransaction(
|
||||
req.tenantId,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
if (!transaction) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Transaction not found'
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
transaction
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get transaction error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get transaction'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Create refund
|
||||
router.post('/:id/refund',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('id').isMongoId(),
|
||||
body('amount').optional().isFloat({ min: 0 }),
|
||||
body('reason').notEmpty(),
|
||||
body('metadata').optional().isObject()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const refund = await transactionService.createRefund(
|
||||
req.tenantId,
|
||||
req.params.id,
|
||||
{
|
||||
...req.body,
|
||||
initiatedBy: req.user.id
|
||||
}
|
||||
);
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
refund,
|
||||
message: 'Refund initiated successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Create refund error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create refund'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Get transaction summary
|
||||
router.get('/summary/:period',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('period').isIn(['daily', 'weekly', 'monthly', 'yearly']),
|
||||
query('startDate').optional().isISO8601(),
|
||||
query('endDate').optional().isISO8601()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const summary = await transactionService.getTransactionSummary(
|
||||
req.tenantId,
|
||||
req.params.period,
|
||||
{
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate
|
||||
}
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
summary
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get transaction summary error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to get transaction summary'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Export transactions
|
||||
router.get('/export/:format',
|
||||
authenticate,
|
||||
requireTenant,
|
||||
validateRequest([
|
||||
param('format').isIn(['csv', 'pdf', 'excel']),
|
||||
query('startDate').optional().isISO8601(),
|
||||
query('endDate').optional().isISO8601()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { filename, data } = await transactionService.exportTransactions(
|
||||
req.tenantId,
|
||||
req.params.format,
|
||||
{
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate
|
||||
}
|
||||
);
|
||||
|
||||
// Set appropriate headers based on format
|
||||
const contentType = {
|
||||
csv: 'text/csv',
|
||||
pdf: 'application/pdf',
|
||||
excel: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
};
|
||||
|
||||
res.setHeader('Content-Type', contentType[req.params.format]);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.send(data);
|
||||
} catch (error) {
|
||||
logger.error('Export transactions error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to export transactions'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Admin: Create manual adjustment
|
||||
router.post('/adjustment',
|
||||
authenticate,
|
||||
requireAdmin,
|
||||
validateRequest([
|
||||
body('tenantId').isMongoId(),
|
||||
body('type').isIn(['credit', 'debit']),
|
||||
body('amount').isFloat({ min: 0 }),
|
||||
body('currency').optional().isString(),
|
||||
body('reason').notEmpty(),
|
||||
body('metadata').optional().isObject()
|
||||
]),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const adjustment = await transactionService.createAdjustment({
|
||||
...req.body,
|
||||
createdBy: req.user.id
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
adjustment,
|
||||
message: 'Adjustment created successfully'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Create adjustment error:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to create adjustment'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
142
marketing-agent/services/billing-service/src/routes/webhooks.js
Normal file
142
marketing-agent/services/billing-service/src/routes/webhooks.js
Normal file
@@ -0,0 +1,142 @@
|
||||
import express from 'express';
|
||||
import { stripeService } from '../services/stripeService.js';
|
||||
import { webhookService } from '../services/webhookService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Stripe webhook endpoint
|
||||
router.post('/stripe',
|
||||
express.raw({ type: 'application/json' }),
|
||||
async (req, res) => {
|
||||
const signature = req.headers['stripe-signature'];
|
||||
|
||||
try {
|
||||
// Construct and verify webhook event
|
||||
const event = stripeService.constructWebhookEvent(
|
||||
req.body,
|
||||
signature
|
||||
);
|
||||
|
||||
logger.info('Webhook event received', {
|
||||
type: event.type,
|
||||
id: event.id
|
||||
});
|
||||
|
||||
// Process the event
|
||||
await webhookService.processStripeEvent(event);
|
||||
|
||||
// Acknowledge receipt of the event
|
||||
res.json({ received: true });
|
||||
} catch (error) {
|
||||
logger.error('Webhook error:', error);
|
||||
res.status(400).json({
|
||||
error: 'Webhook error',
|
||||
message: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Generic webhook endpoint for other services
|
||||
router.post('/:service/:type',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { service, type } = req.params;
|
||||
|
||||
// Verify webhook signature based on service
|
||||
const isValid = await webhookService.verifyWebhook(
|
||||
service,
|
||||
req.headers,
|
||||
req.body
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
return res.status(401).json({
|
||||
error: 'Invalid webhook signature'
|
||||
});
|
||||
}
|
||||
|
||||
// Process the webhook
|
||||
await webhookService.processWebhook(service, type, req.body);
|
||||
|
||||
res.json({ received: true });
|
||||
} catch (error) {
|
||||
logger.error('Webhook processing error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to process webhook'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Webhook test endpoint (for development)
|
||||
router.post('/test/:eventType',
|
||||
async (req, res) => {
|
||||
try {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return res.status(403).json({
|
||||
error: 'Test endpoint only available in development'
|
||||
});
|
||||
}
|
||||
|
||||
// Simulate webhook event
|
||||
await webhookService.simulateEvent(req.params.eventType, req.body);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Test event processed'
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Test webhook error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to process test event'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Webhook logs endpoint
|
||||
router.get('/logs',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const logs = await webhookService.getWebhookLogs({
|
||||
limit: req.query.limit || 100,
|
||||
offset: req.query.offset || 0,
|
||||
service: req.query.service,
|
||||
status: req.query.status
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
logs
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Get webhook logs error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get webhook logs'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Retry failed webhook
|
||||
router.post('/logs/:id/retry',
|
||||
async (req, res) => {
|
||||
try {
|
||||
const result = await webhookService.retryWebhook(req.params.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
result
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Retry webhook error:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to retry webhook'
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
75
marketing-agent/services/billing-service/src/server.js
Normal file
75
marketing-agent/services/billing-service/src/server.js
Normal file
@@ -0,0 +1,75 @@
|
||||
import app, { connectDB, setupGracefulShutdown } from './app.js';
|
||||
import { config } from './config/index.js';
|
||||
import { logger } from './utils/logger.js';
|
||||
import { subscriptionService } from './services/subscriptionService.js';
|
||||
import { invoiceService } from './services/invoiceService.js';
|
||||
import { paymentMethodService } from './services/paymentMethodService.js';
|
||||
import { transactionService } from './services/transactionService.js';
|
||||
|
||||
// Start server
|
||||
async function startServer() {
|
||||
try {
|
||||
// Connect to database
|
||||
await connectDB();
|
||||
|
||||
// Start HTTP server
|
||||
const server = app.listen(config.port, () => {
|
||||
logger.info(`Billing service running on port ${config.port}`);
|
||||
logger.info(`Environment: ${config.env}`);
|
||||
});
|
||||
|
||||
// Setup graceful shutdown
|
||||
setupGracefulShutdown(server);
|
||||
|
||||
// Start background jobs
|
||||
startBackgroundJobs();
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Background jobs
|
||||
function startBackgroundJobs() {
|
||||
// Check expiring subscriptions daily
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await subscriptionService.checkExpiringSubscriptions();
|
||||
} catch (error) {
|
||||
logger.error('Error checking expiring subscriptions:', error);
|
||||
}
|
||||
}, 24 * 60 * 60 * 1000); // Daily
|
||||
|
||||
// Check overdue invoices
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await invoiceService.checkOverdueInvoices();
|
||||
} catch (error) {
|
||||
logger.error('Error checking overdue invoices:', error);
|
||||
}
|
||||
}, 6 * 60 * 60 * 1000); // Every 6 hours
|
||||
|
||||
// Check expiring payment methods
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await paymentMethodService.sendExpirationReminders();
|
||||
} catch (error) {
|
||||
logger.error('Error checking expiring payment methods:', error);
|
||||
}
|
||||
}, 24 * 60 * 60 * 1000); // Daily
|
||||
|
||||
// Process failed transactions
|
||||
setInterval(async () => {
|
||||
try {
|
||||
await transactionService.processFailedTransactions();
|
||||
} catch (error) {
|
||||
logger.error('Error processing failed transactions:', error);
|
||||
}
|
||||
}, 60 * 60 * 1000); // Hourly
|
||||
|
||||
logger.info('Background jobs started');
|
||||
}
|
||||
|
||||
// Start the server
|
||||
startServer();
|
||||
@@ -0,0 +1,401 @@
|
||||
import { Invoice } from '../models/Invoice.js';
|
||||
import { Subscription } from '../models/Subscription.js';
|
||||
import { Transaction } from '../models/Transaction.js';
|
||||
import { stripeService } from './stripeService.js';
|
||||
import { pdfService } from './pdfService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { sendEmail } from '../utils/email.js';
|
||||
|
||||
class InvoiceService {
|
||||
// Create invoice
|
||||
async createInvoice(tenantId, data) {
|
||||
try {
|
||||
// Generate invoice number
|
||||
const invoiceNumber = await Invoice.generateInvoiceNumber(tenantId);
|
||||
|
||||
// Create invoice
|
||||
const invoice = new Invoice({
|
||||
tenantId,
|
||||
invoiceNumber,
|
||||
subscriptionId: data.subscriptionId,
|
||||
customerId: data.customerId,
|
||||
periodStart: data.periodStart,
|
||||
periodEnd: data.periodEnd,
|
||||
status: 'draft',
|
||||
lineItems: data.lineItems || [],
|
||||
subtotal: 0,
|
||||
total: 0,
|
||||
amountDue: 0,
|
||||
currency: data.currency || 'USD',
|
||||
dueDate: data.dueDate || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
||||
customer: data.customer,
|
||||
metadata: data.metadata
|
||||
});
|
||||
|
||||
// Calculate totals
|
||||
invoice.calculateTotals();
|
||||
|
||||
await invoice.save();
|
||||
|
||||
logger.info('Invoice created', {
|
||||
tenantId,
|
||||
invoiceId: invoice.id,
|
||||
invoiceNumber
|
||||
});
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create invoice', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create invoice from Stripe invoice
|
||||
async createFromStripeInvoice(tenantId, stripeInvoice) {
|
||||
try {
|
||||
const invoice = new Invoice({
|
||||
tenantId,
|
||||
invoiceNumber: stripeInvoice.number || await Invoice.generateInvoiceNumber(tenantId),
|
||||
subscriptionId: stripeInvoice.metadata?.subscriptionId,
|
||||
customerId: stripeInvoice.customer,
|
||||
periodStart: new Date(stripeInvoice.period_start * 1000),
|
||||
periodEnd: new Date(stripeInvoice.period_end * 1000),
|
||||
status: this.mapStripeStatus(stripeInvoice.status),
|
||||
lineItems: stripeInvoice.lines.data.map(line => ({
|
||||
description: line.description,
|
||||
quantity: line.quantity,
|
||||
unitPrice: line.unit_amount / 100,
|
||||
amount: line.amount / 100,
|
||||
currency: line.currency,
|
||||
period: {
|
||||
start: new Date(line.period.start * 1000),
|
||||
end: new Date(line.period.end * 1000)
|
||||
},
|
||||
proration: line.proration,
|
||||
type: line.type === 'subscription' ? 'subscription' : 'one_time'
|
||||
})),
|
||||
subtotal: stripeInvoice.subtotal / 100,
|
||||
tax: stripeInvoice.tax ? {
|
||||
rate: stripeInvoice.tax_percent,
|
||||
amount: stripeInvoice.tax / 100
|
||||
} : null,
|
||||
total: stripeInvoice.total / 100,
|
||||
currency: stripeInvoice.currency.toUpperCase(),
|
||||
amountPaid: stripeInvoice.amount_paid / 100,
|
||||
amountDue: stripeInvoice.amount_due / 100,
|
||||
dueDate: stripeInvoice.due_date ? new Date(stripeInvoice.due_date * 1000) : null,
|
||||
paidAt: stripeInvoice.status_transitions?.paid_at ?
|
||||
new Date(stripeInvoice.status_transitions.paid_at * 1000) : null,
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
stripePaymentIntentId: stripeInvoice.payment_intent
|
||||
});
|
||||
|
||||
await invoice.save();
|
||||
|
||||
// Create transaction if paid
|
||||
if (invoice.status === 'paid') {
|
||||
await this.createTransaction(invoice, stripeInvoice);
|
||||
}
|
||||
|
||||
logger.info('Invoice created from Stripe', {
|
||||
invoiceId: invoice.id,
|
||||
stripeInvoiceId: stripeInvoice.id
|
||||
});
|
||||
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create invoice from Stripe', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Finalize invoice
|
||||
async finalizeInvoice(tenantId, invoiceId) {
|
||||
try {
|
||||
const invoice = await Invoice.findOne({
|
||||
_id: invoiceId,
|
||||
tenantId,
|
||||
status: 'draft'
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error('Invoice not found or already finalized');
|
||||
}
|
||||
|
||||
// Finalize in Stripe if connected
|
||||
if (invoice.stripeInvoiceId) {
|
||||
await stripeService.finalizeInvoice(invoice.stripeInvoiceId);
|
||||
}
|
||||
|
||||
// Update status
|
||||
invoice.status = 'open';
|
||||
await invoice.save();
|
||||
|
||||
// Generate PDF
|
||||
await this.generatePDF(invoice);
|
||||
|
||||
// Send invoice email
|
||||
await this.sendInvoiceEmail(invoice);
|
||||
|
||||
logger.info('Invoice finalized', { invoiceId });
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to finalize invoice', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Pay invoice
|
||||
async payInvoice(tenantId, invoiceId, paymentDetails) {
|
||||
try {
|
||||
const invoice = await Invoice.findOne({
|
||||
_id: invoiceId,
|
||||
tenantId,
|
||||
status: 'open'
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error('Invoice not found or not payable');
|
||||
}
|
||||
|
||||
// Process payment in Stripe if connected
|
||||
if (invoice.stripeInvoiceId && paymentDetails.paymentMethodId) {
|
||||
await stripeService.payInvoice(invoice.stripeInvoiceId);
|
||||
}
|
||||
|
||||
// Mark as paid
|
||||
await invoice.markPaid(paymentDetails);
|
||||
|
||||
// Create transaction
|
||||
await this.createTransaction(invoice, paymentDetails);
|
||||
|
||||
// Send receipt
|
||||
await this.sendReceiptEmail(invoice);
|
||||
|
||||
logger.info('Invoice paid', { invoiceId });
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to pay invoice', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Void invoice
|
||||
async voidInvoice(tenantId, invoiceId, reason) {
|
||||
try {
|
||||
const invoice = await Invoice.findOne({
|
||||
_id: invoiceId,
|
||||
tenantId,
|
||||
status: { $in: ['draft', 'open'] }
|
||||
});
|
||||
|
||||
if (!invoice) {
|
||||
throw new Error('Invoice not found or not voidable');
|
||||
}
|
||||
|
||||
// Void in Stripe if connected
|
||||
if (invoice.stripeInvoiceId) {
|
||||
await stripeService.voidInvoice(invoice.stripeInvoiceId);
|
||||
}
|
||||
|
||||
// Mark as void
|
||||
await invoice.void(reason);
|
||||
|
||||
logger.info('Invoice voided', { invoiceId, reason });
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to void invoice', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PDF
|
||||
async generatePDF(invoice) {
|
||||
try {
|
||||
// Populate related data
|
||||
await invoice.populate('subscriptionId');
|
||||
|
||||
// Generate PDF
|
||||
const pdfBuffer = await pdfService.generateInvoicePDF(invoice);
|
||||
|
||||
// Save PDF (in production, upload to cloud storage)
|
||||
const pdfPath = `/invoices/${invoice.invoiceNumber}.pdf`;
|
||||
invoice.pdfUrl = pdfPath;
|
||||
await invoice.save();
|
||||
|
||||
logger.info('Invoice PDF generated', {
|
||||
invoiceId: invoice.id,
|
||||
pdfPath
|
||||
});
|
||||
|
||||
return pdfPath;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate invoice PDF', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get invoices
|
||||
async getInvoices(tenantId, filters = {}) {
|
||||
try {
|
||||
const query = { tenantId };
|
||||
|
||||
if (filters.status) query.status = filters.status;
|
||||
if (filters.customerId) query.customerId = filters.customerId;
|
||||
if (filters.subscriptionId) query.subscriptionId = filters.subscriptionId;
|
||||
|
||||
if (filters.startDate || filters.endDate) {
|
||||
query.periodStart = {};
|
||||
if (filters.startDate) query.periodStart.$gte = filters.startDate;
|
||||
if (filters.endDate) query.periodStart.$lte = filters.endDate;
|
||||
}
|
||||
|
||||
const invoices = await Invoice.find(query)
|
||||
.sort({ createdAt: -1 })
|
||||
.limit(filters.limit || 100);
|
||||
|
||||
return invoices;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get invoices', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get unpaid invoices
|
||||
async getUnpaidInvoices(tenantId) {
|
||||
try {
|
||||
const invoices = await Invoice.findUnpaid(tenantId);
|
||||
return invoices;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get unpaid invoices', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get overdue invoices
|
||||
async getOverdueInvoices(tenantId) {
|
||||
try {
|
||||
const invoices = await Invoice.findOverdue(tenantId);
|
||||
return invoices;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get overdue invoices', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check and handle overdue invoices
|
||||
async checkOverdueInvoices() {
|
||||
try {
|
||||
const overdueInvoices = await Invoice.find({
|
||||
status: 'open',
|
||||
dueDate: { $lt: new Date() }
|
||||
}).populate('tenantId');
|
||||
|
||||
for (const invoice of overdueInvoices) {
|
||||
// Send reminder based on days overdue
|
||||
if (invoice.daysOverdue === 1) {
|
||||
await this.sendFirstReminder(invoice);
|
||||
} else if (invoice.daysOverdue === 7) {
|
||||
await this.sendSecondReminder(invoice);
|
||||
} else if (invoice.daysOverdue === 14) {
|
||||
await this.sendFinalNotice(invoice);
|
||||
} else if (invoice.daysOverdue === 30) {
|
||||
// Suspend tenant account
|
||||
await this.suspendTenantForNonPayment(invoice);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Checked overdue invoices', {
|
||||
count: overdueInvoices.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to check overdue invoices', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
mapStripeStatus(stripeStatus) {
|
||||
const statusMap = {
|
||||
'draft': 'draft',
|
||||
'open': 'open',
|
||||
'paid': 'paid',
|
||||
'void': 'void',
|
||||
'uncollectible': 'uncollectible'
|
||||
};
|
||||
return statusMap[stripeStatus] || 'draft';
|
||||
}
|
||||
|
||||
async createTransaction(invoice, paymentDetails) {
|
||||
const transaction = new Transaction({
|
||||
tenantId: invoice.tenantId,
|
||||
transactionId: Transaction.generateTransactionId('pmt'),
|
||||
type: 'payment',
|
||||
status: 'succeeded',
|
||||
customerId: invoice.customerId,
|
||||
subscriptionId: invoice.subscriptionId,
|
||||
invoiceId: invoice._id,
|
||||
amount: invoice.total * 100, // Convert to cents
|
||||
currency: invoice.currency,
|
||||
fee: 0, // Would come from payment processor
|
||||
net: invoice.total * 100,
|
||||
processor: 'stripe',
|
||||
processorTransactionId: paymentDetails.payment_intent || paymentDetails.stripePaymentIntentId,
|
||||
description: `Payment for invoice ${invoice.invoiceNumber}`,
|
||||
processedAt: new Date()
|
||||
});
|
||||
|
||||
await transaction.save();
|
||||
return transaction;
|
||||
}
|
||||
|
||||
async suspendTenantForNonPayment(invoice) {
|
||||
// Update tenant status
|
||||
const tenant = invoice.tenantId;
|
||||
tenant.status = 'suspended';
|
||||
tenant.suspendedAt = new Date();
|
||||
if (!tenant.metadata) tenant.metadata = {};
|
||||
tenant.metadata.suspensionReason = `Overdue invoice ${invoice.invoiceNumber}`;
|
||||
await tenant.save();
|
||||
|
||||
// Send suspension notification
|
||||
await sendEmail({
|
||||
to: invoice.customer.email,
|
||||
subject: 'Account Suspended - Payment Required',
|
||||
template: 'accountSuspended',
|
||||
data: {
|
||||
tenantName: tenant.name,
|
||||
invoiceNumber: invoice.invoiceNumber,
|
||||
amountDue: invoice.amountDue,
|
||||
daysOverdue: invoice.daysOverdue
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Tenant suspended for non-payment', {
|
||||
tenantId: tenant.id,
|
||||
invoiceId: invoice.id
|
||||
});
|
||||
}
|
||||
|
||||
// Email notifications
|
||||
async sendInvoiceEmail(invoice) {
|
||||
logger.info('Invoice email sent', { invoiceId: invoice.id });
|
||||
}
|
||||
|
||||
async sendReceiptEmail(invoice) {
|
||||
logger.info('Receipt email sent', { invoiceId: invoice.id });
|
||||
}
|
||||
|
||||
async sendFirstReminder(invoice) {
|
||||
logger.info('First reminder sent', { invoiceId: invoice.id });
|
||||
}
|
||||
|
||||
async sendSecondReminder(invoice) {
|
||||
logger.info('Second reminder sent', { invoiceId: invoice.id });
|
||||
}
|
||||
|
||||
async sendFinalNotice(invoice) {
|
||||
logger.info('Final notice sent', { invoiceId: invoice.id });
|
||||
}
|
||||
}
|
||||
|
||||
export const invoiceService = new InvoiceService();
|
||||
@@ -0,0 +1,319 @@
|
||||
import { PaymentMethod } from '../models/PaymentMethod.js';
|
||||
import { stripeService } from './stripeService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
class PaymentMethodService {
|
||||
// Add payment method
|
||||
async addPaymentMethod(tenantId, data) {
|
||||
try {
|
||||
let stripePaymentMethodId;
|
||||
|
||||
// Create or attach payment method in Stripe
|
||||
if (data.token) {
|
||||
// Token from Stripe.js
|
||||
const paymentMethod = await stripeService.createPaymentMethod({
|
||||
type: data.type,
|
||||
card: { token: data.token }
|
||||
});
|
||||
stripePaymentMethodId = paymentMethod.id;
|
||||
} else if (data.stripePaymentMethodId) {
|
||||
stripePaymentMethodId = data.stripePaymentMethodId;
|
||||
}
|
||||
|
||||
// Attach to customer if provided
|
||||
if (stripePaymentMethodId && data.customerId) {
|
||||
await stripeService.attachPaymentMethod(
|
||||
stripePaymentMethodId,
|
||||
data.customerId
|
||||
);
|
||||
}
|
||||
|
||||
// Create local record
|
||||
const paymentMethod = new PaymentMethod({
|
||||
tenantId,
|
||||
customerId: data.customerId,
|
||||
type: data.type,
|
||||
card: data.card ? {
|
||||
brand: data.card.brand,
|
||||
last4: data.card.last4,
|
||||
expMonth: data.card.expMonth,
|
||||
expYear: data.card.expYear,
|
||||
fingerprint: data.card.fingerprint || this.generateFingerprint(data.card)
|
||||
} : null,
|
||||
bankAccount: data.bankAccount ? {
|
||||
bankName: data.bankAccount.bankName,
|
||||
accountType: data.bankAccount.accountType,
|
||||
last4: data.bankAccount.last4,
|
||||
routingNumber: data.bankAccount.routingNumber
|
||||
} : null,
|
||||
billingAddress: data.billingAddress,
|
||||
stripePaymentMethodId,
|
||||
metadata: data.metadata
|
||||
});
|
||||
|
||||
await paymentMethod.save();
|
||||
|
||||
logger.info('Payment method added', {
|
||||
tenantId,
|
||||
paymentMethodId: paymentMethod.id,
|
||||
type: data.type
|
||||
});
|
||||
|
||||
return paymentMethod;
|
||||
} catch (error) {
|
||||
logger.error('Failed to add payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get payment methods
|
||||
async getPaymentMethods(tenantId, customerId = null) {
|
||||
try {
|
||||
const query = { tenantId, status: 'active' };
|
||||
if (customerId) query.customerId = customerId;
|
||||
|
||||
const paymentMethods = await PaymentMethod.find(query)
|
||||
.sort({ isDefault: -1, createdAt: -1 });
|
||||
|
||||
return paymentMethods;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get payment methods', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get payment method by ID
|
||||
async getPaymentMethod(tenantId, paymentMethodId) {
|
||||
try {
|
||||
const paymentMethod = await PaymentMethod.findOne({
|
||||
_id: paymentMethodId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
return paymentMethod;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update payment method
|
||||
async updatePaymentMethod(tenantId, paymentMethodId, updates) {
|
||||
try {
|
||||
const paymentMethod = await PaymentMethod.findOne({
|
||||
_id: paymentMethodId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
if (!paymentMethod) {
|
||||
throw new Error('Payment method not found');
|
||||
}
|
||||
|
||||
// Update in Stripe if applicable
|
||||
if (paymentMethod.stripePaymentMethodId && updates.billingAddress) {
|
||||
await stripeService.updatePaymentMethod(
|
||||
paymentMethod.stripePaymentMethodId,
|
||||
{
|
||||
billing_details: {
|
||||
address: updates.billingAddress
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Update local record
|
||||
if (updates.billingAddress) paymentMethod.billingAddress = updates.billingAddress;
|
||||
if (updates.metadata) paymentMethod.metadata = { ...paymentMethod.metadata, ...updates.metadata };
|
||||
|
||||
await paymentMethod.save();
|
||||
|
||||
logger.info('Payment method updated', { paymentMethodId });
|
||||
return paymentMethod;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Set default payment method
|
||||
async setDefaultPaymentMethod(tenantId, paymentMethodId) {
|
||||
try {
|
||||
// Remove current default
|
||||
await PaymentMethod.updateMany(
|
||||
{ tenantId, isDefault: true },
|
||||
{ isDefault: false }
|
||||
);
|
||||
|
||||
// Set new default
|
||||
const paymentMethod = await PaymentMethod.findOneAndUpdate(
|
||||
{ _id: paymentMethodId, tenantId },
|
||||
{ isDefault: true },
|
||||
{ new: true }
|
||||
);
|
||||
|
||||
if (!paymentMethod) {
|
||||
throw new Error('Payment method not found');
|
||||
}
|
||||
|
||||
// Update in Stripe
|
||||
if (paymentMethod.stripePaymentMethodId && paymentMethod.customerId) {
|
||||
await stripeService.setDefaultPaymentMethod(
|
||||
paymentMethod.customerId,
|
||||
paymentMethod.stripePaymentMethodId
|
||||
);
|
||||
}
|
||||
|
||||
logger.info('Default payment method set', { paymentMethodId });
|
||||
return paymentMethod;
|
||||
} catch (error) {
|
||||
logger.error('Failed to set default payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove payment method
|
||||
async removePaymentMethod(tenantId, paymentMethodId) {
|
||||
try {
|
||||
const paymentMethod = await PaymentMethod.findOne({
|
||||
_id: paymentMethodId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
if (!paymentMethod) {
|
||||
throw new Error('Payment method not found');
|
||||
}
|
||||
|
||||
if (paymentMethod.isDefault) {
|
||||
throw new Error('Cannot remove default payment method');
|
||||
}
|
||||
|
||||
// Detach from Stripe
|
||||
if (paymentMethod.stripePaymentMethodId) {
|
||||
await stripeService.detachPaymentMethod(paymentMethod.stripePaymentMethodId);
|
||||
}
|
||||
|
||||
// Soft delete
|
||||
paymentMethod.status = 'removed';
|
||||
paymentMethod.removedAt = new Date();
|
||||
await paymentMethod.save();
|
||||
|
||||
logger.info('Payment method removed', { paymentMethodId });
|
||||
} catch (error) {
|
||||
logger.error('Failed to remove payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify bank account
|
||||
async verifyPaymentMethod(tenantId, paymentMethodId, amounts) {
|
||||
try {
|
||||
const paymentMethod = await PaymentMethod.findOne({
|
||||
_id: paymentMethodId,
|
||||
tenantId,
|
||||
type: 'bank_account',
|
||||
'verification.status': 'pending'
|
||||
});
|
||||
|
||||
if (!paymentMethod) {
|
||||
throw new Error('Payment method not found or not verifiable');
|
||||
}
|
||||
|
||||
// Check verification attempts
|
||||
if (paymentMethod.verification.attempts >= 3) {
|
||||
throw new Error('Maximum verification attempts exceeded');
|
||||
}
|
||||
|
||||
// Verify amounts
|
||||
const isValid = amounts.every((amount, index) =>
|
||||
amount === paymentMethod.verification.amounts[index]
|
||||
);
|
||||
|
||||
paymentMethod.verification.attempts += 1;
|
||||
|
||||
if (isValid) {
|
||||
paymentMethod.verification.status = 'verified';
|
||||
paymentMethod.verification.verifiedAt = new Date();
|
||||
paymentMethod.status = 'verified';
|
||||
} else if (paymentMethod.verification.attempts >= 3) {
|
||||
paymentMethod.verification.status = 'failed';
|
||||
paymentMethod.status = 'failed';
|
||||
}
|
||||
|
||||
await paymentMethod.save();
|
||||
|
||||
logger.info('Payment method verification attempt', {
|
||||
paymentMethodId,
|
||||
isValid,
|
||||
attempts: paymentMethod.verification.attempts
|
||||
});
|
||||
|
||||
return paymentMethod;
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate card fingerprint
|
||||
generateFingerprint(card) {
|
||||
const data = `${card.number}-${card.expMonth}-${card.expYear}`;
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
// Check for duplicate cards
|
||||
async checkDuplicateCard(tenantId, fingerprint) {
|
||||
const existing = await PaymentMethod.findOne({
|
||||
tenantId,
|
||||
'card.fingerprint': fingerprint,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
return existing;
|
||||
}
|
||||
|
||||
// Get expiring cards
|
||||
async getExpiringCards(daysAhead = 30) {
|
||||
const expirationDate = new Date();
|
||||
expirationDate.setDate(expirationDate.getDate() + daysAhead);
|
||||
|
||||
const currentYear = new Date().getFullYear();
|
||||
const currentMonth = new Date().getMonth() + 1;
|
||||
const targetYear = expirationDate.getFullYear();
|
||||
const targetMonth = expirationDate.getMonth() + 1;
|
||||
|
||||
const expiringCards = await PaymentMethod.find({
|
||||
type: 'card',
|
||||
status: 'active',
|
||||
$or: [
|
||||
{ 'card.expYear': currentYear, 'card.expMonth': { $lte: targetMonth } },
|
||||
{ 'card.expYear': { $lt: targetYear, $gt: currentYear } }
|
||||
]
|
||||
}).populate('tenantId');
|
||||
|
||||
return expiringCards;
|
||||
}
|
||||
|
||||
// Send expiration reminders
|
||||
async sendExpirationReminders() {
|
||||
try {
|
||||
const expiringCards = await this.getExpiringCards(30);
|
||||
|
||||
for (const card of expiringCards) {
|
||||
// Send reminder email
|
||||
logger.info('Card expiration reminder sent', {
|
||||
paymentMethodId: card.id,
|
||||
expiresIn: `${card.card.expMonth}/${card.card.expYear}`
|
||||
});
|
||||
}
|
||||
|
||||
logger.info('Expiration reminders processed', {
|
||||
count: expiringCards.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to send expiration reminders', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const paymentMethodService = new PaymentMethodService();
|
||||
@@ -0,0 +1,343 @@
|
||||
import Stripe from 'stripe';
|
||||
import { config } from '../config/index.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
class StripeService {
|
||||
constructor() {
|
||||
this.stripe = new Stripe(config.stripe.secretKey, {
|
||||
apiVersion: '2023-10-16'
|
||||
});
|
||||
}
|
||||
|
||||
// Customer management
|
||||
async createCustomer(data) {
|
||||
try {
|
||||
const customer = await this.stripe.customers.create({
|
||||
email: data.email,
|
||||
name: data.name,
|
||||
phone: data.phone,
|
||||
metadata: {
|
||||
tenantId: data.tenantId,
|
||||
userId: data.userId
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Stripe customer created', { customerId: customer.id });
|
||||
return customer;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Stripe customer', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCustomer(customerId, data) {
|
||||
try {
|
||||
const customer = await this.stripe.customers.update(customerId, data);
|
||||
return customer;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update Stripe customer', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteCustomer(customerId) {
|
||||
try {
|
||||
const deleted = await this.stripe.customers.del(customerId);
|
||||
return deleted;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete Stripe customer', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Payment methods
|
||||
async attachPaymentMethod(paymentMethodId, customerId) {
|
||||
try {
|
||||
const paymentMethod = await this.stripe.paymentMethods.attach(
|
||||
paymentMethodId,
|
||||
{ customer: customerId }
|
||||
);
|
||||
return paymentMethod;
|
||||
} catch (error) {
|
||||
logger.error('Failed to attach payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async detachPaymentMethod(paymentMethodId) {
|
||||
try {
|
||||
const paymentMethod = await this.stripe.paymentMethods.detach(paymentMethodId);
|
||||
return paymentMethod;
|
||||
} catch (error) {
|
||||
logger.error('Failed to detach payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async setDefaultPaymentMethod(customerId, paymentMethodId) {
|
||||
try {
|
||||
await this.stripe.customers.update(customerId, {
|
||||
invoice_settings: {
|
||||
default_payment_method: paymentMethodId
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to set default payment method', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Subscriptions
|
||||
async createSubscription(data) {
|
||||
try {
|
||||
const subscription = await this.stripe.subscriptions.create({
|
||||
customer: data.customerId,
|
||||
items: [{ price: data.priceId }],
|
||||
trial_period_days: data.trialDays,
|
||||
metadata: {
|
||||
tenantId: data.tenantId,
|
||||
plan: data.plan
|
||||
},
|
||||
expand: ['latest_invoice.payment_intent']
|
||||
});
|
||||
|
||||
logger.info('Stripe subscription created', { subscriptionId: subscription.id });
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create Stripe subscription', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateSubscription(subscriptionId, data) {
|
||||
try {
|
||||
const subscription = await this.stripe.subscriptions.update(subscriptionId, data);
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update Stripe subscription', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSubscription(subscriptionId, immediately = false) {
|
||||
try {
|
||||
if (immediately) {
|
||||
const subscription = await this.stripe.subscriptions.del(subscriptionId);
|
||||
return subscription;
|
||||
} else {
|
||||
const subscription = await this.stripe.subscriptions.update(subscriptionId, {
|
||||
cancel_at_period_end: true
|
||||
});
|
||||
return subscription;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel Stripe subscription', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Usage-based billing
|
||||
async recordUsage(subscriptionItemId, quantity, timestamp) {
|
||||
try {
|
||||
const usageRecord = await this.stripe.subscriptionItems.createUsageRecord(
|
||||
subscriptionItemId,
|
||||
{
|
||||
quantity,
|
||||
timestamp: timestamp || Math.floor(Date.now() / 1000),
|
||||
action: 'increment'
|
||||
}
|
||||
);
|
||||
return usageRecord;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record usage', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Invoices
|
||||
async createInvoice(customerId) {
|
||||
try {
|
||||
const invoice = await this.stripe.invoices.create({
|
||||
customer: customerId,
|
||||
auto_advance: true
|
||||
});
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create invoice', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async finalizeInvoice(invoiceId) {
|
||||
try {
|
||||
const invoice = await this.stripe.invoices.finalizeInvoice(invoiceId);
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to finalize invoice', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async payInvoice(invoiceId) {
|
||||
try {
|
||||
const invoice = await this.stripe.invoices.pay(invoiceId);
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to pay invoice', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async voidInvoice(invoiceId) {
|
||||
try {
|
||||
const invoice = await this.stripe.invoices.voidInvoice(invoiceId);
|
||||
return invoice;
|
||||
} catch (error) {
|
||||
logger.error('Failed to void invoice', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Payment intents
|
||||
async createPaymentIntent(data) {
|
||||
try {
|
||||
const paymentIntent = await this.stripe.paymentIntents.create({
|
||||
amount: data.amount,
|
||||
currency: data.currency || 'usd',
|
||||
customer: data.customerId,
|
||||
payment_method: data.paymentMethodId,
|
||||
confirm: data.confirm || false,
|
||||
metadata: data.metadata
|
||||
});
|
||||
return paymentIntent;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create payment intent', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async confirmPaymentIntent(paymentIntentId) {
|
||||
try {
|
||||
const paymentIntent = await this.stripe.paymentIntents.confirm(paymentIntentId);
|
||||
return paymentIntent;
|
||||
} catch (error) {
|
||||
logger.error('Failed to confirm payment intent', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Refunds
|
||||
async createRefund(data) {
|
||||
try {
|
||||
const refund = await this.stripe.refunds.create({
|
||||
payment_intent: data.paymentIntentId,
|
||||
amount: data.amount,
|
||||
reason: data.reason,
|
||||
metadata: data.metadata
|
||||
});
|
||||
return refund;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create refund', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Webhooks
|
||||
constructWebhookEvent(payload, signature) {
|
||||
try {
|
||||
return this.stripe.webhooks.constructEvent(
|
||||
payload,
|
||||
signature,
|
||||
config.stripe.webhookSecret
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to construct webhook event', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Prices and products
|
||||
async createProduct(data) {
|
||||
try {
|
||||
const product = await this.stripe.products.create({
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
metadata: data.metadata
|
||||
});
|
||||
return product;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create product', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async createPrice(data) {
|
||||
try {
|
||||
const price = await this.stripe.prices.create({
|
||||
product: data.productId,
|
||||
unit_amount: data.amount,
|
||||
currency: data.currency || 'usd',
|
||||
recurring: data.recurring ? {
|
||||
interval: data.interval || 'month',
|
||||
interval_count: data.intervalCount || 1
|
||||
} : undefined,
|
||||
metadata: data.metadata
|
||||
});
|
||||
return price;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create price', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Coupons
|
||||
async createCoupon(data) {
|
||||
try {
|
||||
const coupon = await this.stripe.coupons.create({
|
||||
percent_off: data.percentOff,
|
||||
amount_off: data.amountOff,
|
||||
currency: data.currency,
|
||||
duration: data.duration,
|
||||
duration_in_months: data.durationInMonths,
|
||||
max_redemptions: data.maxRedemptions,
|
||||
metadata: data.metadata
|
||||
});
|
||||
return coupon;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create coupon', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Reports
|
||||
async getBalanceTransactions(options = {}) {
|
||||
try {
|
||||
const transactions = await this.stripe.balanceTransactions.list({
|
||||
limit: options.limit || 100,
|
||||
starting_after: options.startingAfter,
|
||||
ending_before: options.endingBefore,
|
||||
created: options.created
|
||||
});
|
||||
return transactions;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get balance transactions', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getCharges(options = {}) {
|
||||
try {
|
||||
const charges = await this.stripe.charges.list({
|
||||
limit: options.limit || 100,
|
||||
customer: options.customerId,
|
||||
created: options.created
|
||||
});
|
||||
return charges;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get charges', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const stripeService = new StripeService();
|
||||
@@ -0,0 +1,381 @@
|
||||
import { Subscription } from '../models/Subscription.js';
|
||||
import { Invoice } from '../models/Invoice.js';
|
||||
import { Transaction } from '../models/Transaction.js';
|
||||
import { stripeService } from './stripeService.js';
|
||||
import { invoiceService } from './invoiceService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { sendEmail } from '../utils/email.js';
|
||||
|
||||
class SubscriptionService {
|
||||
// Create subscription
|
||||
async createSubscription(tenantId, data) {
|
||||
try {
|
||||
// Create Stripe subscription
|
||||
const stripeSubscription = await stripeService.createSubscription({
|
||||
customerId: data.stripeCustomerId,
|
||||
priceId: data.stripePriceId,
|
||||
trialDays: data.trialDays,
|
||||
tenantId,
|
||||
plan: data.plan
|
||||
});
|
||||
|
||||
// Create local subscription record
|
||||
const subscription = new Subscription({
|
||||
tenantId,
|
||||
customerId: data.customerId,
|
||||
subscriptionId: stripeSubscription.id,
|
||||
plan: data.plan,
|
||||
planName: data.planName,
|
||||
planPrice: data.planPrice,
|
||||
currency: data.currency || 'USD',
|
||||
billingCycle: data.billingCycle || 'monthly',
|
||||
interval: data.interval || 1,
|
||||
status: stripeSubscription.status,
|
||||
currentPeriodStart: new Date(stripeSubscription.current_period_start * 1000),
|
||||
currentPeriodEnd: new Date(stripeSubscription.current_period_end * 1000),
|
||||
trialStart: stripeSubscription.trial_start ? new Date(stripeSubscription.trial_start * 1000) : null,
|
||||
trialEnd: stripeSubscription.trial_end ? new Date(stripeSubscription.trial_end * 1000) : null,
|
||||
stripeCustomerId: data.stripeCustomerId,
|
||||
stripeSubscriptionId: stripeSubscription.id,
|
||||
stripePriceId: data.stripePriceId,
|
||||
metadata: data.metadata
|
||||
});
|
||||
|
||||
await subscription.save();
|
||||
|
||||
// Send welcome email
|
||||
await this.sendWelcomeEmail(subscription);
|
||||
|
||||
logger.info('Subscription created', {
|
||||
tenantId,
|
||||
subscriptionId: subscription.id,
|
||||
plan: data.plan
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create subscription', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Update subscription
|
||||
async updateSubscription(tenantId, subscriptionId, updates) {
|
||||
try {
|
||||
const subscription = await Subscription.findOne({
|
||||
_id: subscriptionId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error('Subscription not found');
|
||||
}
|
||||
|
||||
// Update Stripe subscription if needed
|
||||
if (updates.plan || updates.quantity) {
|
||||
const stripeUpdates = {};
|
||||
|
||||
if (updates.plan) {
|
||||
stripeUpdates.items = [{
|
||||
id: subscription.stripeSubscriptionId,
|
||||
price: updates.stripePriceId
|
||||
}];
|
||||
}
|
||||
|
||||
await stripeService.updateSubscription(
|
||||
subscription.stripeSubscriptionId,
|
||||
stripeUpdates
|
||||
);
|
||||
}
|
||||
|
||||
// Update local record
|
||||
Object.assign(subscription, updates);
|
||||
await subscription.save();
|
||||
|
||||
logger.info('Subscription updated', { subscriptionId });
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
logger.error('Failed to update subscription', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel subscription
|
||||
async cancelSubscription(tenantId, subscriptionId, options = {}) {
|
||||
try {
|
||||
const subscription = await Subscription.findOne({
|
||||
_id: subscriptionId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error('Subscription not found');
|
||||
}
|
||||
|
||||
// Cancel in Stripe
|
||||
await stripeService.cancelSubscription(
|
||||
subscription.stripeSubscriptionId,
|
||||
options.immediately
|
||||
);
|
||||
|
||||
// Update local record
|
||||
await subscription.cancel(options.immediately);
|
||||
|
||||
// Send cancellation email
|
||||
await this.sendCancellationEmail(subscription);
|
||||
|
||||
logger.info('Subscription canceled', {
|
||||
subscriptionId,
|
||||
immediately: options.immediately
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
logger.error('Failed to cancel subscription', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Reactivate subscription
|
||||
async reactivateSubscription(tenantId, subscriptionId) {
|
||||
try {
|
||||
const subscription = await Subscription.findOne({
|
||||
_id: subscriptionId,
|
||||
tenantId,
|
||||
status: 'canceled'
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error('Subscription not found or not canceled');
|
||||
}
|
||||
|
||||
// Reactivate in Stripe
|
||||
const stripeSubscription = await stripeService.updateSubscription(
|
||||
subscription.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: false }
|
||||
);
|
||||
|
||||
// Update local record
|
||||
subscription.status = 'active';
|
||||
subscription.canceledAt = null;
|
||||
subscription.endedAt = null;
|
||||
await subscription.save();
|
||||
|
||||
logger.info('Subscription reactivated', { subscriptionId });
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
logger.error('Failed to reactivate subscription', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Record usage for metered billing
|
||||
async recordUsage(tenantId, subscriptionId, metric, quantity) {
|
||||
try {
|
||||
const subscription = await Subscription.findOne({
|
||||
_id: subscriptionId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error('Subscription not found');
|
||||
}
|
||||
|
||||
// Record usage in Stripe if configured
|
||||
if (subscription.metadata?.stripeUsageItemId) {
|
||||
await stripeService.recordUsage(
|
||||
subscription.metadata.stripeUsageItemId,
|
||||
quantity
|
||||
);
|
||||
}
|
||||
|
||||
// Record usage locally
|
||||
const usage = await subscription.recordUsage(metric, quantity, 0.01); // $0.01 per unit
|
||||
|
||||
logger.info('Usage recorded', {
|
||||
subscriptionId,
|
||||
metric,
|
||||
quantity
|
||||
});
|
||||
|
||||
return usage;
|
||||
} catch (error) {
|
||||
logger.error('Failed to record usage', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply discount
|
||||
async applyDiscount(tenantId, subscriptionId, couponCode) {
|
||||
try {
|
||||
const subscription = await Subscription.findOne({
|
||||
_id: subscriptionId,
|
||||
tenantId
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
throw new Error('Subscription not found');
|
||||
}
|
||||
|
||||
// Apply coupon in Stripe
|
||||
await stripeService.updateSubscription(
|
||||
subscription.stripeSubscriptionId,
|
||||
{ coupon: couponCode }
|
||||
);
|
||||
|
||||
// Update local record
|
||||
await subscription.applyDiscount({
|
||||
coupon: couponCode,
|
||||
// Additional discount details would come from Stripe
|
||||
});
|
||||
|
||||
logger.info('Discount applied', {
|
||||
subscriptionId,
|
||||
couponCode
|
||||
});
|
||||
|
||||
return subscription;
|
||||
} catch (error) {
|
||||
logger.error('Failed to apply discount', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get subscription by tenant
|
||||
async getSubscriptionsByTenant(tenantId) {
|
||||
try {
|
||||
const subscriptions = await Subscription.findByTenant(tenantId);
|
||||
return subscriptions;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get subscriptions', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check and handle expiring subscriptions
|
||||
async checkExpiringSubscriptions() {
|
||||
try {
|
||||
const expiringSubscriptions = await Subscription.findExpiring(7); // 7 days
|
||||
|
||||
for (const subscription of expiringSubscriptions) {
|
||||
await this.sendRenewalReminder(subscription);
|
||||
}
|
||||
|
||||
logger.info('Checked expiring subscriptions', {
|
||||
count: expiringSubscriptions.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to check expiring subscriptions', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle subscription webhook events
|
||||
async handleWebhookEvent(event) {
|
||||
try {
|
||||
const subscription = await Subscription.findOne({
|
||||
stripeSubscriptionId: event.data.object.id
|
||||
});
|
||||
|
||||
if (!subscription) {
|
||||
logger.warn('Subscription not found for webhook event', {
|
||||
eventType: event.type,
|
||||
subscriptionId: event.data.object.id
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case 'customer.subscription.updated':
|
||||
await this.handleSubscriptionUpdated(subscription, event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.deleted':
|
||||
await this.handleSubscriptionDeleted(subscription, event.data.object);
|
||||
break;
|
||||
|
||||
case 'customer.subscription.trial_will_end':
|
||||
await this.handleTrialWillEnd(subscription, event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_succeeded':
|
||||
await this.handlePaymentSucceeded(subscription, event.data.object);
|
||||
break;
|
||||
|
||||
case 'invoice.payment_failed':
|
||||
await this.handlePaymentFailed(subscription, event.data.object);
|
||||
break;
|
||||
}
|
||||
|
||||
logger.info('Webhook event handled', {
|
||||
eventType: event.type,
|
||||
subscriptionId: subscription.id
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to handle webhook event', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Private methods
|
||||
async handleSubscriptionUpdated(subscription, stripeSubscription) {
|
||||
subscription.status = stripeSubscription.status;
|
||||
subscription.currentPeriodStart = new Date(stripeSubscription.current_period_start * 1000);
|
||||
subscription.currentPeriodEnd = new Date(stripeSubscription.current_period_end * 1000);
|
||||
await subscription.save();
|
||||
}
|
||||
|
||||
async handleSubscriptionDeleted(subscription, stripeSubscription) {
|
||||
subscription.status = 'canceled';
|
||||
subscription.endedAt = new Date();
|
||||
await subscription.save();
|
||||
}
|
||||
|
||||
async handleTrialWillEnd(subscription, stripeSubscription) {
|
||||
await this.sendTrialEndingEmail(subscription);
|
||||
}
|
||||
|
||||
async handlePaymentSucceeded(subscription, invoice) {
|
||||
// Create invoice record
|
||||
await invoiceService.createFromStripeInvoice(subscription.tenantId, invoice);
|
||||
|
||||
// Update subscription dates
|
||||
subscription.currentPeriodStart = new Date(invoice.period_start * 1000);
|
||||
subscription.currentPeriodEnd = new Date(invoice.period_end * 1000);
|
||||
await subscription.save();
|
||||
}
|
||||
|
||||
async handlePaymentFailed(subscription, invoice) {
|
||||
subscription.status = 'past_due';
|
||||
await subscription.save();
|
||||
|
||||
await this.sendPaymentFailedEmail(subscription);
|
||||
}
|
||||
|
||||
// Email notifications
|
||||
async sendWelcomeEmail(subscription) {
|
||||
// Implementation would depend on email service
|
||||
logger.info('Welcome email sent', { subscriptionId: subscription.id });
|
||||
}
|
||||
|
||||
async sendCancellationEmail(subscription) {
|
||||
// Implementation would depend on email service
|
||||
logger.info('Cancellation email sent', { subscriptionId: subscription.id });
|
||||
}
|
||||
|
||||
async sendRenewalReminder(subscription) {
|
||||
// Implementation would depend on email service
|
||||
logger.info('Renewal reminder sent', { subscriptionId: subscription.id });
|
||||
}
|
||||
|
||||
async sendTrialEndingEmail(subscription) {
|
||||
// Implementation would depend on email service
|
||||
logger.info('Trial ending email sent', { subscriptionId: subscription.id });
|
||||
}
|
||||
|
||||
async sendPaymentFailedEmail(subscription) {
|
||||
// Implementation would depend on email service
|
||||
logger.info('Payment failed email sent', { subscriptionId: subscription.id });
|
||||
}
|
||||
}
|
||||
|
||||
export const subscriptionService = new SubscriptionService();
|
||||
@@ -0,0 +1,381 @@
|
||||
import { Transaction } from '../models/Transaction.js';
|
||||
import { stripeService } from './stripeService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { generateReport } from '../utils/reports.js';
|
||||
|
||||
class TransactionService {
|
||||
// Get transactions
|
||||
async getTransactions(tenantId, filters = {}) {
|
||||
try {
|
||||
const query = { tenantId };
|
||||
|
||||
if (filters.type) query.type = filters.type;
|
||||
if (filters.status) query.status = filters.status;
|
||||
if (filters.customerId) query.customerId = filters.customerId;
|
||||
if (filters.subscriptionId) query.subscriptionId = filters.subscriptionId;
|
||||
|
||||
if (filters.startDate || filters.endDate) {
|
||||
query.processedAt = {};
|
||||
if (filters.startDate) query.processedAt.$gte = new Date(filters.startDate);
|
||||
if (filters.endDate) query.processedAt.$lte = new Date(filters.endDate);
|
||||
}
|
||||
|
||||
const limit = parseInt(filters.limit) || 100;
|
||||
const offset = parseInt(filters.offset) || 0;
|
||||
|
||||
const transactions = await Transaction.find(query)
|
||||
.sort({ processedAt: -1 })
|
||||
.limit(limit)
|
||||
.skip(offset)
|
||||
.populate('invoiceId', 'invoiceNumber')
|
||||
.populate('subscriptionId', 'plan planName');
|
||||
|
||||
const total = await Transaction.countDocuments(query);
|
||||
|
||||
return {
|
||||
transactions,
|
||||
total,
|
||||
limit,
|
||||
offset,
|
||||
hasMore: offset + transactions.length < total
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get transactions', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get transaction by ID
|
||||
async getTransaction(tenantId, transactionId) {
|
||||
try {
|
||||
const transaction = await Transaction.findOne({
|
||||
_id: transactionId,
|
||||
tenantId
|
||||
})
|
||||
.populate('invoiceId')
|
||||
.populate('subscriptionId')
|
||||
.populate('refundedTransactionId');
|
||||
|
||||
return transaction;
|
||||
} catch (error) {
|
||||
logger.error('Failed to get transaction', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create refund
|
||||
async createRefund(tenantId, transactionId, refundData) {
|
||||
try {
|
||||
// Get original transaction
|
||||
const originalTransaction = await Transaction.findOne({
|
||||
_id: transactionId,
|
||||
tenantId,
|
||||
type: 'payment',
|
||||
status: 'succeeded'
|
||||
});
|
||||
|
||||
if (!originalTransaction) {
|
||||
throw new Error('Transaction not found or not refundable');
|
||||
}
|
||||
|
||||
// Check if already refunded
|
||||
if (originalTransaction.refundedAmount >= originalTransaction.amount) {
|
||||
throw new Error('Transaction already fully refunded');
|
||||
}
|
||||
|
||||
// Calculate refund amount
|
||||
const refundAmount = refundData.amount ||
|
||||
(originalTransaction.amount - originalTransaction.refundedAmount);
|
||||
|
||||
if (refundAmount > originalTransaction.amount - originalTransaction.refundedAmount) {
|
||||
throw new Error('Refund amount exceeds available amount');
|
||||
}
|
||||
|
||||
// Create refund in Stripe
|
||||
const stripeRefund = await stripeService.createRefund({
|
||||
paymentIntentId: originalTransaction.processorTransactionId,
|
||||
amount: refundAmount,
|
||||
reason: refundData.reason,
|
||||
metadata: {
|
||||
tenantId,
|
||||
transactionId,
|
||||
initiatedBy: refundData.initiatedBy
|
||||
}
|
||||
});
|
||||
|
||||
// Create refund transaction
|
||||
const refundTransaction = new Transaction({
|
||||
tenantId,
|
||||
transactionId: Transaction.generateTransactionId('rfd'),
|
||||
type: 'refund',
|
||||
status: 'succeeded',
|
||||
customerId: originalTransaction.customerId,
|
||||
subscriptionId: originalTransaction.subscriptionId,
|
||||
invoiceId: originalTransaction.invoiceId,
|
||||
amount: refundAmount,
|
||||
currency: originalTransaction.currency,
|
||||
fee: 0, // Refund fees would be calculated
|
||||
net: -refundAmount,
|
||||
processor: 'stripe',
|
||||
processorTransactionId: stripeRefund.id,
|
||||
refundedTransactionId: originalTransaction._id,
|
||||
description: `Refund for ${originalTransaction.transactionId}: ${refundData.reason}`,
|
||||
metadata: {
|
||||
reason: refundData.reason,
|
||||
initiatedBy: refundData.initiatedBy,
|
||||
...refundData.metadata
|
||||
},
|
||||
processedAt: new Date()
|
||||
});
|
||||
|
||||
await refundTransaction.save();
|
||||
|
||||
// Update original transaction
|
||||
originalTransaction.refundedAmount += refundAmount;
|
||||
originalTransaction.refunds.push({
|
||||
refundId: refundTransaction._id,
|
||||
amount: refundAmount,
|
||||
reason: refundData.reason,
|
||||
createdAt: new Date()
|
||||
});
|
||||
await originalTransaction.save();
|
||||
|
||||
logger.info('Refund created', {
|
||||
refundId: refundTransaction.id,
|
||||
originalTransactionId: transactionId,
|
||||
amount: refundAmount
|
||||
});
|
||||
|
||||
return refundTransaction;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create refund', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Create adjustment
|
||||
async createAdjustment(data) {
|
||||
try {
|
||||
const transaction = new Transaction({
|
||||
tenantId: data.tenantId,
|
||||
transactionId: Transaction.generateTransactionId('adj'),
|
||||
type: 'adjustment',
|
||||
status: 'succeeded',
|
||||
customerId: data.customerId,
|
||||
amount: data.type === 'credit' ? data.amount : -data.amount,
|
||||
currency: data.currency || 'USD',
|
||||
fee: 0,
|
||||
net: data.type === 'credit' ? data.amount : -data.amount,
|
||||
processor: 'manual',
|
||||
description: `Manual adjustment: ${data.reason}`,
|
||||
metadata: {
|
||||
adjustmentType: data.type,
|
||||
reason: data.reason,
|
||||
createdBy: data.createdBy,
|
||||
...data.metadata
|
||||
},
|
||||
processedAt: new Date()
|
||||
});
|
||||
|
||||
await transaction.save();
|
||||
|
||||
logger.info('Adjustment created', {
|
||||
transactionId: transaction.id,
|
||||
type: data.type,
|
||||
amount: data.amount
|
||||
});
|
||||
|
||||
return transaction;
|
||||
} catch (error) {
|
||||
logger.error('Failed to create adjustment', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Get transaction summary
|
||||
async getTransactionSummary(tenantId, period, dateRange = {}) {
|
||||
try {
|
||||
const query = { tenantId, status: 'succeeded' };
|
||||
|
||||
// Apply date range
|
||||
if (dateRange.startDate || dateRange.endDate) {
|
||||
query.processedAt = {};
|
||||
if (dateRange.startDate) query.processedAt.$gte = new Date(dateRange.startDate);
|
||||
if (dateRange.endDate) query.processedAt.$lte = new Date(dateRange.endDate);
|
||||
} else {
|
||||
// Default to current period
|
||||
const now = new Date();
|
||||
switch (period) {
|
||||
case 'daily':
|
||||
query.processedAt = {
|
||||
$gte: new Date(now.setHours(0, 0, 0, 0)),
|
||||
$lte: new Date(now.setHours(23, 59, 59, 999))
|
||||
};
|
||||
break;
|
||||
case 'weekly':
|
||||
const weekStart = new Date(now.setDate(now.getDate() - now.getDay()));
|
||||
query.processedAt = { $gte: weekStart };
|
||||
break;
|
||||
case 'monthly':
|
||||
query.processedAt = {
|
||||
$gte: new Date(now.getFullYear(), now.getMonth(), 1),
|
||||
$lte: new Date(now.getFullYear(), now.getMonth() + 1, 0)
|
||||
};
|
||||
break;
|
||||
case 'yearly':
|
||||
query.processedAt = {
|
||||
$gte: new Date(now.getFullYear(), 0, 1),
|
||||
$lte: new Date(now.getFullYear(), 11, 31)
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Aggregate transaction data
|
||||
const summary = await Transaction.aggregate([
|
||||
{ $match: query },
|
||||
{
|
||||
$group: {
|
||||
_id: '$type',
|
||||
count: { $sum: 1 },
|
||||
totalAmount: { $sum: '$amount' },
|
||||
totalFees: { $sum: '$fee' },
|
||||
totalNet: { $sum: '$net' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// Get period comparison
|
||||
const previousPeriodQuery = { ...query };
|
||||
const currentStart = query.processedAt.$gte;
|
||||
const currentEnd = query.processedAt.$lte || new Date();
|
||||
const periodDuration = currentEnd - currentStart;
|
||||
|
||||
previousPeriodQuery.processedAt = {
|
||||
$gte: new Date(currentStart - periodDuration),
|
||||
$lt: currentStart
|
||||
};
|
||||
|
||||
const previousSummary = await Transaction.aggregate([
|
||||
{ $match: previousPeriodQuery },
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
totalAmount: { $sum: '$amount' },
|
||||
totalNet: { $sum: '$net' }
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
// Calculate changes
|
||||
const currentTotal = summary.reduce((sum, item) => sum + item.totalNet, 0);
|
||||
const previousTotal = previousSummary[0]?.totalNet || 0;
|
||||
const change = previousTotal ? ((currentTotal - previousTotal) / previousTotal) * 100 : 0;
|
||||
|
||||
return {
|
||||
period,
|
||||
dateRange: {
|
||||
start: query.processedAt.$gte,
|
||||
end: query.processedAt.$lte || new Date()
|
||||
},
|
||||
summary: summary.reduce((acc, item) => {
|
||||
acc[item._id] = {
|
||||
count: item.count,
|
||||
amount: item.totalAmount,
|
||||
fees: item.totalFees,
|
||||
net: item.totalNet
|
||||
};
|
||||
return acc;
|
||||
}, {}),
|
||||
totals: {
|
||||
amount: summary.reduce((sum, item) => sum + item.totalAmount, 0),
|
||||
fees: summary.reduce((sum, item) => sum + item.totalFees, 0),
|
||||
net: currentTotal,
|
||||
count: summary.reduce((sum, item) => sum + item.count, 0)
|
||||
},
|
||||
comparison: {
|
||||
previousPeriod: previousTotal,
|
||||
change: change.toFixed(2),
|
||||
trend: change > 0 ? 'up' : change < 0 ? 'down' : 'stable'
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to get transaction summary', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Export transactions
|
||||
async exportTransactions(tenantId, format, dateRange = {}) {
|
||||
try {
|
||||
const filters = { ...dateRange };
|
||||
const { transactions } = await this.getTransactions(tenantId, filters);
|
||||
|
||||
const filename = `transactions_${tenantId}_${new Date().toISOString().split('T')[0]}.${format}`;
|
||||
|
||||
let data;
|
||||
switch (format) {
|
||||
case 'csv':
|
||||
data = await generateReport.csv(transactions);
|
||||
break;
|
||||
case 'pdf':
|
||||
data = await generateReport.pdf(transactions, {
|
||||
title: 'Transaction Report',
|
||||
tenantId
|
||||
});
|
||||
break;
|
||||
case 'excel':
|
||||
data = await generateReport.excel(transactions, {
|
||||
sheetName: 'Transactions'
|
||||
});
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unsupported export format');
|
||||
}
|
||||
|
||||
return { filename, data };
|
||||
} catch (error) {
|
||||
logger.error('Failed to export transactions', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Process failed transactions
|
||||
async processFailedTransactions() {
|
||||
try {
|
||||
const failedTransactions = await Transaction.find({
|
||||
status: 'failed',
|
||||
retryCount: { $lt: 3 },
|
||||
nextRetryAt: { $lte: new Date() }
|
||||
});
|
||||
|
||||
for (const transaction of failedTransactions) {
|
||||
try {
|
||||
// Retry logic based on transaction type
|
||||
logger.info('Retrying failed transaction', {
|
||||
transactionId: transaction.id,
|
||||
retryCount: transaction.retryCount
|
||||
});
|
||||
|
||||
// Update retry count and next retry time
|
||||
transaction.retryCount += 1;
|
||||
transaction.nextRetryAt = new Date(Date.now() + Math.pow(2, transaction.retryCount) * 60 * 60 * 1000);
|
||||
await transaction.save();
|
||||
} catch (error) {
|
||||
logger.error('Failed to retry transaction', {
|
||||
transactionId: transaction.id,
|
||||
error
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
logger.info('Processed failed transactions', {
|
||||
count: failedTransactions.length
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to process failed transactions', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const transactionService = new TransactionService();
|
||||
@@ -0,0 +1,295 @@
|
||||
import { subscriptionService } from './subscriptionService.js';
|
||||
import { invoiceService } from './invoiceService.js';
|
||||
import { paymentMethodService } from './paymentMethodService.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import crypto from 'crypto';
|
||||
|
||||
class WebhookService {
|
||||
constructor() {
|
||||
this.webhookHandlers = {
|
||||
// Subscription events
|
||||
'customer.subscription.created': this.handleSubscriptionCreated.bind(this),
|
||||
'customer.subscription.updated': this.handleSubscriptionUpdated.bind(this),
|
||||
'customer.subscription.deleted': this.handleSubscriptionDeleted.bind(this),
|
||||
'customer.subscription.trial_will_end': this.handleTrialWillEnd.bind(this),
|
||||
|
||||
// Invoice events
|
||||
'invoice.created': this.handleInvoiceCreated.bind(this),
|
||||
'invoice.finalized': this.handleInvoiceFinalized.bind(this),
|
||||
'invoice.payment_succeeded': this.handleInvoicePaymentSucceeded.bind(this),
|
||||
'invoice.payment_failed': this.handleInvoicePaymentFailed.bind(this),
|
||||
|
||||
// Payment method events
|
||||
'payment_method.attached': this.handlePaymentMethodAttached.bind(this),
|
||||
'payment_method.detached': this.handlePaymentMethodDetached.bind(this),
|
||||
'payment_method.updated': this.handlePaymentMethodUpdated.bind(this),
|
||||
|
||||
// Customer events
|
||||
'customer.updated': this.handleCustomerUpdated.bind(this),
|
||||
'customer.deleted': this.handleCustomerDeleted.bind(this)
|
||||
};
|
||||
|
||||
this.webhookLogs = [];
|
||||
}
|
||||
|
||||
// Process Stripe webhook event
|
||||
async processStripeEvent(event) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// Log webhook event
|
||||
const logEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
service: 'stripe',
|
||||
eventType: event.type,
|
||||
eventId: event.id,
|
||||
receivedAt: new Date(),
|
||||
status: 'processing',
|
||||
data: event.data
|
||||
};
|
||||
|
||||
this.webhookLogs.push(logEntry);
|
||||
|
||||
// Get handler for event type
|
||||
const handler = this.webhookHandlers[event.type];
|
||||
|
||||
if (handler) {
|
||||
await handler(event);
|
||||
logEntry.status = 'success';
|
||||
logEntry.processingTime = Date.now() - startTime;
|
||||
|
||||
logger.info('Webhook event processed', {
|
||||
type: event.type,
|
||||
id: event.id,
|
||||
processingTime: logEntry.processingTime
|
||||
});
|
||||
} else {
|
||||
logEntry.status = 'ignored';
|
||||
logEntry.reason = 'No handler for event type';
|
||||
|
||||
logger.info('Webhook event ignored', {
|
||||
type: event.type,
|
||||
id: event.id
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Webhook processing error', {
|
||||
type: event.type,
|
||||
id: event.id,
|
||||
error: error.message
|
||||
});
|
||||
|
||||
if (this.webhookLogs.length > 0) {
|
||||
const logEntry = this.webhookLogs[this.webhookLogs.length - 1];
|
||||
logEntry.status = 'failed';
|
||||
logEntry.error = error.message;
|
||||
logEntry.processingTime = Date.now() - startTime;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Subscription event handlers
|
||||
async handleSubscriptionCreated(event) {
|
||||
const subscription = event.data.object;
|
||||
logger.info('Subscription created webhook', { subscriptionId: subscription.id });
|
||||
// Subscription creation is handled during API call
|
||||
}
|
||||
|
||||
async handleSubscriptionUpdated(event) {
|
||||
const subscription = event.data.object;
|
||||
await subscriptionService.handleWebhookEvent(event);
|
||||
}
|
||||
|
||||
async handleSubscriptionDeleted(event) {
|
||||
const subscription = event.data.object;
|
||||
await subscriptionService.handleWebhookEvent(event);
|
||||
}
|
||||
|
||||
async handleTrialWillEnd(event) {
|
||||
const subscription = event.data.object;
|
||||
await subscriptionService.handleWebhookEvent(event);
|
||||
}
|
||||
|
||||
// Invoice event handlers
|
||||
async handleInvoiceCreated(event) {
|
||||
const invoice = event.data.object;
|
||||
logger.info('Invoice created webhook', { invoiceId: invoice.id });
|
||||
}
|
||||
|
||||
async handleInvoiceFinalized(event) {
|
||||
const invoice = event.data.object;
|
||||
logger.info('Invoice finalized webhook', { invoiceId: invoice.id });
|
||||
}
|
||||
|
||||
async handleInvoicePaymentSucceeded(event) {
|
||||
const invoice = event.data.object;
|
||||
await subscriptionService.handleWebhookEvent(event);
|
||||
}
|
||||
|
||||
async handleInvoicePaymentFailed(event) {
|
||||
const invoice = event.data.object;
|
||||
await subscriptionService.handleWebhookEvent(event);
|
||||
}
|
||||
|
||||
// Payment method event handlers
|
||||
async handlePaymentMethodAttached(event) {
|
||||
const paymentMethod = event.data.object;
|
||||
logger.info('Payment method attached webhook', { paymentMethodId: paymentMethod.id });
|
||||
}
|
||||
|
||||
async handlePaymentMethodDetached(event) {
|
||||
const paymentMethod = event.data.object;
|
||||
logger.info('Payment method detached webhook', { paymentMethodId: paymentMethod.id });
|
||||
}
|
||||
|
||||
async handlePaymentMethodUpdated(event) {
|
||||
const paymentMethod = event.data.object;
|
||||
logger.info('Payment method updated webhook', { paymentMethodId: paymentMethod.id });
|
||||
}
|
||||
|
||||
// Customer event handlers
|
||||
async handleCustomerUpdated(event) {
|
||||
const customer = event.data.object;
|
||||
logger.info('Customer updated webhook', { customerId: customer.id });
|
||||
}
|
||||
|
||||
async handleCustomerDeleted(event) {
|
||||
const customer = event.data.object;
|
||||
logger.info('Customer deleted webhook', { customerId: customer.id });
|
||||
}
|
||||
|
||||
// Generic webhook processing
|
||||
async processWebhook(service, type, data) {
|
||||
const logEntry = {
|
||||
id: crypto.randomUUID(),
|
||||
service,
|
||||
eventType: type,
|
||||
receivedAt: new Date(),
|
||||
status: 'processing',
|
||||
data
|
||||
};
|
||||
|
||||
this.webhookLogs.push(logEntry);
|
||||
|
||||
try {
|
||||
// Process based on service
|
||||
switch (service) {
|
||||
case 'paypal':
|
||||
await this.processPayPalWebhook(type, data);
|
||||
break;
|
||||
case 'custom':
|
||||
await this.processCustomWebhook(type, data);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unknown webhook service: ${service}`);
|
||||
}
|
||||
|
||||
logEntry.status = 'success';
|
||||
} catch (error) {
|
||||
logEntry.status = 'failed';
|
||||
logEntry.error = error.message;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify webhook signature
|
||||
async verifyWebhook(service, headers, body) {
|
||||
switch (service) {
|
||||
case 'stripe':
|
||||
// Stripe verification is handled by constructWebhookEvent
|
||||
return true;
|
||||
case 'paypal':
|
||||
return this.verifyPayPalWebhook(headers, body);
|
||||
default:
|
||||
// Custom verification logic
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate webhook event (for testing)
|
||||
async simulateEvent(eventType, data) {
|
||||
const event = {
|
||||
id: `test_${crypto.randomUUID()}`,
|
||||
type: eventType,
|
||||
data: {
|
||||
object: data
|
||||
},
|
||||
created: Math.floor(Date.now() / 1000)
|
||||
};
|
||||
|
||||
await this.processStripeEvent(event);
|
||||
}
|
||||
|
||||
// Get webhook logs
|
||||
async getWebhookLogs(filters = {}) {
|
||||
let logs = [...this.webhookLogs];
|
||||
|
||||
if (filters.service) {
|
||||
logs = logs.filter(log => log.service === filters.service);
|
||||
}
|
||||
|
||||
if (filters.status) {
|
||||
logs = logs.filter(log => log.status === filters.status);
|
||||
}
|
||||
|
||||
// Sort by most recent first
|
||||
logs.sort((a, b) => b.receivedAt - a.receivedAt);
|
||||
|
||||
// Apply pagination
|
||||
const limit = filters.limit || 100;
|
||||
const offset = filters.offset || 0;
|
||||
|
||||
return {
|
||||
logs: logs.slice(offset, offset + limit),
|
||||
total: logs.length,
|
||||
limit,
|
||||
offset
|
||||
};
|
||||
}
|
||||
|
||||
// Retry failed webhook
|
||||
async retryWebhook(logId) {
|
||||
const log = this.webhookLogs.find(l => l.id === logId);
|
||||
|
||||
if (!log) {
|
||||
throw new Error('Webhook log not found');
|
||||
}
|
||||
|
||||
if (log.status !== 'failed') {
|
||||
throw new Error('Can only retry failed webhooks');
|
||||
}
|
||||
|
||||
// Re-process the webhook
|
||||
const event = {
|
||||
id: log.eventId || log.id,
|
||||
type: log.eventType,
|
||||
data: log.data
|
||||
};
|
||||
|
||||
await this.processStripeEvent(event);
|
||||
|
||||
return { success: true, message: 'Webhook retried successfully' };
|
||||
}
|
||||
|
||||
// PayPal webhook verification (example)
|
||||
verifyPayPalWebhook(headers, body) {
|
||||
// Implement PayPal webhook verification
|
||||
return true;
|
||||
}
|
||||
|
||||
// Process PayPal webhook (example)
|
||||
async processPayPalWebhook(type, data) {
|
||||
logger.info('Processing PayPal webhook', { type });
|
||||
// Implement PayPal webhook processing
|
||||
}
|
||||
|
||||
// Process custom webhook
|
||||
async processCustomWebhook(type, data) {
|
||||
logger.info('Processing custom webhook', { type });
|
||||
// Implement custom webhook processing
|
||||
}
|
||||
}
|
||||
|
||||
export const webhookService = new WebhookService();
|
||||
143
marketing-agent/services/billing-service/src/utils/email.js
Normal file
143
marketing-agent/services/billing-service/src/utils/email.js
Normal file
@@ -0,0 +1,143 @@
|
||||
import { logger } from './logger.js';
|
||||
|
||||
// Email service interface
|
||||
class EmailService {
|
||||
constructor() {
|
||||
// In production, this would integrate with SendGrid, AWS SES, etc.
|
||||
this.templates = {
|
||||
accountSuspended: {
|
||||
subject: 'Account Suspended - Payment Required',
|
||||
html: (data) => `
|
||||
<h2>Account Suspended</h2>
|
||||
<p>Dear ${data.tenantName},</p>
|
||||
<p>Your account has been suspended due to an overdue invoice.</p>
|
||||
<p>Invoice: ${data.invoiceNumber}</p>
|
||||
<p>Amount Due: $${data.amountDue}</p>
|
||||
<p>Days Overdue: ${data.daysOverdue}</p>
|
||||
<p>Please make payment immediately to restore your account.</p>
|
||||
`
|
||||
},
|
||||
invoiceCreated: {
|
||||
subject: 'New Invoice - {{invoiceNumber}}',
|
||||
html: (data) => `
|
||||
<h2>New Invoice</h2>
|
||||
<p>Invoice Number: ${data.invoiceNumber}</p>
|
||||
<p>Amount: $${data.amount}</p>
|
||||
<p>Due Date: ${data.dueDate}</p>
|
||||
<p><a href="${data.invoiceUrl}">View Invoice</a></p>
|
||||
`
|
||||
},
|
||||
paymentSuccessful: {
|
||||
subject: 'Payment Received - Thank You',
|
||||
html: (data) => `
|
||||
<h2>Payment Received</h2>
|
||||
<p>Thank you for your payment!</p>
|
||||
<p>Amount: $${data.amount}</p>
|
||||
<p>Invoice: ${data.invoiceNumber}</p>
|
||||
<p>Transaction ID: ${data.transactionId}</p>
|
||||
`
|
||||
},
|
||||
paymentFailed: {
|
||||
subject: 'Payment Failed - Action Required',
|
||||
html: (data) => `
|
||||
<h2>Payment Failed</h2>
|
||||
<p>We were unable to process your payment.</p>
|
||||
<p>Amount: $${data.amount}</p>
|
||||
<p>Reason: ${data.reason}</p>
|
||||
<p>Please update your payment method to avoid service interruption.</p>
|
||||
`
|
||||
},
|
||||
subscriptionCreated: {
|
||||
subject: 'Welcome to {{planName}}!',
|
||||
html: (data) => `
|
||||
<h2>Welcome!</h2>
|
||||
<p>Thank you for subscribing to ${data.planName}.</p>
|
||||
<p>Plan: ${data.planName}</p>
|
||||
<p>Price: $${data.price}/${data.interval}</p>
|
||||
<p>Next Billing Date: ${data.nextBillingDate}</p>
|
||||
`
|
||||
},
|
||||
subscriptionCanceled: {
|
||||
subject: 'Subscription Canceled',
|
||||
html: (data) => `
|
||||
<h2>Subscription Canceled</h2>
|
||||
<p>Your subscription has been canceled.</p>
|
||||
<p>Your access will continue until: ${data.endDate}</p>
|
||||
<p>We're sorry to see you go!</p>
|
||||
`
|
||||
},
|
||||
trialEnding: {
|
||||
subject: 'Your Trial is Ending Soon',
|
||||
html: (data) => `
|
||||
<h2>Trial Ending</h2>
|
||||
<p>Your trial period ends in ${data.daysRemaining} days.</p>
|
||||
<p>Upgrade now to continue enjoying our services!</p>
|
||||
<p><a href="${data.upgradeUrl}">Upgrade Now</a></p>
|
||||
`
|
||||
},
|
||||
cardExpiring: {
|
||||
subject: 'Payment Method Expiring Soon',
|
||||
html: (data) => `
|
||||
<h2>Payment Method Expiring</h2>
|
||||
<p>Your ${data.cardBrand} ending in ${data.last4} expires ${data.expMonth}/${data.expYear}.</p>
|
||||
<p>Please update your payment method to avoid service interruption.</p>
|
||||
<p><a href="${data.updateUrl}">Update Payment Method</a></p>
|
||||
`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async send(options) {
|
||||
try {
|
||||
const { to, subject, template, data } = options;
|
||||
|
||||
// Get template
|
||||
const emailTemplate = this.templates[template];
|
||||
if (!emailTemplate) {
|
||||
throw new Error(`Email template not found: ${template}`);
|
||||
}
|
||||
|
||||
// Process subject
|
||||
const processedSubject = subject || emailTemplate.subject.replace(/\{\{(\w+)\}\}/g, (match, key) => data[key] || match);
|
||||
|
||||
// Generate HTML
|
||||
const html = emailTemplate.html(data);
|
||||
|
||||
// In production, send via email service
|
||||
logger.info('Email sent', {
|
||||
to,
|
||||
subject: processedSubject,
|
||||
template
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
messageId: `mock-${Date.now()}`
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Failed to send email', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async sendBatch(emails) {
|
||||
const results = [];
|
||||
|
||||
for (const email of emails) {
|
||||
try {
|
||||
const result = await this.send(email);
|
||||
results.push({ ...email, ...result });
|
||||
} catch (error) {
|
||||
results.push({ ...email, success: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const emailService = new EmailService();
|
||||
|
||||
// Convenience function
|
||||
export const sendEmail = (options) => emailService.send(options);
|
||||
81
marketing-agent/services/billing-service/src/utils/logger.js
Normal file
81
marketing-agent/services/billing-service/src/utils/logger.js
Normal file
@@ -0,0 +1,81 @@
|
||||
import winston from 'winston';
|
||||
import path from 'path';
|
||||
|
||||
// Create logger instance
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
winston.format.splat(),
|
||||
winston.format.json()
|
||||
),
|
||||
defaultMeta: { service: 'billing-service' },
|
||||
transports: [
|
||||
// Console transport
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
winston.format.simple()
|
||||
)
|
||||
}),
|
||||
// File transport for errors
|
||||
new winston.transports.File({
|
||||
filename: path.join('logs', 'billing-error.log'),
|
||||
level: 'error',
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
}),
|
||||
// File transport for all logs
|
||||
new winston.transports.File({
|
||||
filename: path.join('logs', 'billing-combined.log'),
|
||||
maxsize: 5242880, // 5MB
|
||||
maxFiles: 5
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Create logs directory if it doesn't exist
|
||||
import fs from 'fs';
|
||||
const logsDir = path.join(process.cwd(), 'logs');
|
||||
if (!fs.existsSync(logsDir)) {
|
||||
fs.mkdirSync(logsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Add request logging middleware
|
||||
export const requestLogger = (req, res, next) => {
|
||||
const start = Date.now();
|
||||
|
||||
res.on('finish', () => {
|
||||
const duration = Date.now() - start;
|
||||
|
||||
logger.info('HTTP Request', {
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
status: res.statusCode,
|
||||
duration: `${duration}ms`,
|
||||
ip: req.ip,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
});
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
// Error logging middleware
|
||||
export const errorLogger = (err, req, res, next) => {
|
||||
logger.error('Unhandled error', {
|
||||
error: err.message,
|
||||
stack: err.stack,
|
||||
method: req.method,
|
||||
url: req.url,
|
||||
body: req.body,
|
||||
ip: req.ip
|
||||
});
|
||||
|
||||
next(err);
|
||||
};
|
||||
|
||||
export { logger };
|
||||
242
marketing-agent/services/billing-service/src/utils/pdfService.js
Normal file
242
marketing-agent/services/billing-service/src/utils/pdfService.js
Normal file
@@ -0,0 +1,242 @@
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
class PDFService {
|
||||
// Generate invoice PDF
|
||||
async generateInvoicePDF(invoice) {
|
||||
try {
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
const chunks = [];
|
||||
|
||||
doc.on('data', chunks.push.bind(chunks));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
doc.on('end', () => {
|
||||
const pdfBuffer = Buffer.concat(chunks);
|
||||
resolve(pdfBuffer);
|
||||
});
|
||||
|
||||
doc.on('error', reject);
|
||||
|
||||
// Header
|
||||
this.addHeader(doc, invoice);
|
||||
|
||||
// Invoice details
|
||||
this.addInvoiceDetails(doc, invoice);
|
||||
|
||||
// Customer details
|
||||
this.addCustomerDetails(doc, invoice);
|
||||
|
||||
// Line items
|
||||
this.addLineItems(doc, invoice);
|
||||
|
||||
// Totals
|
||||
this.addTotals(doc, invoice);
|
||||
|
||||
// Footer
|
||||
this.addFooter(doc, invoice);
|
||||
|
||||
doc.end();
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate invoice PDF', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
addHeader(doc, invoice) {
|
||||
// Company logo and info
|
||||
doc.fontSize(20).text('INVOICE', 50, 50);
|
||||
doc.fontSize(10).text('Your Company Name', 50, 80);
|
||||
doc.text('123 Business Street', 50, 95);
|
||||
doc.text('City, State 12345', 50, 110);
|
||||
doc.text('Phone: (555) 123-4567', 50, 125);
|
||||
doc.text('Email: billing@company.com', 50, 140);
|
||||
|
||||
// Invoice status badge
|
||||
const statusColors = {
|
||||
draft: '#808080',
|
||||
open: '#FFA500',
|
||||
paid: '#008000',
|
||||
void: '#FF0000',
|
||||
uncollectible: '#800080'
|
||||
};
|
||||
|
||||
doc.fontSize(12)
|
||||
.fillColor(statusColors[invoice.status] || '#000000')
|
||||
.text(invoice.status.toUpperCase(), 450, 50, { align: 'right' });
|
||||
|
||||
doc.fillColor('#000000');
|
||||
doc.moveDown(2);
|
||||
}
|
||||
|
||||
addInvoiceDetails(doc, invoice) {
|
||||
const startY = 180;
|
||||
|
||||
doc.fontSize(10);
|
||||
doc.text('Invoice Number:', 350, startY);
|
||||
doc.font('Helvetica-Bold').text(invoice.invoiceNumber, 450, startY);
|
||||
|
||||
doc.font('Helvetica').text('Invoice Date:', 350, startY + 15);
|
||||
doc.text(new Date(invoice.createdAt).toLocaleDateString(), 450, startY + 15);
|
||||
|
||||
doc.text('Due Date:', 350, startY + 30);
|
||||
doc.font('Helvetica-Bold').text(new Date(invoice.dueDate).toLocaleDateString(), 450, startY + 30);
|
||||
|
||||
doc.font('Helvetica');
|
||||
}
|
||||
|
||||
addCustomerDetails(doc, invoice) {
|
||||
const startY = 180;
|
||||
|
||||
doc.fontSize(12).font('Helvetica-Bold').text('Bill To:', 50, startY);
|
||||
doc.fontSize(10).font('Helvetica');
|
||||
|
||||
if (invoice.customer) {
|
||||
doc.text(invoice.customer.name || 'Customer', 50, startY + 20);
|
||||
if (invoice.customer.address) {
|
||||
doc.text(invoice.customer.address.line1 || '', 50, startY + 35);
|
||||
if (invoice.customer.address.line2) {
|
||||
doc.text(invoice.customer.address.line2, 50, startY + 50);
|
||||
}
|
||||
doc.text(
|
||||
`${invoice.customer.address.city || ''}, ${invoice.customer.address.state || ''} ${invoice.customer.address.postal || ''}`,
|
||||
50, startY + 65
|
||||
);
|
||||
}
|
||||
if (invoice.customer.email) {
|
||||
doc.text(invoice.customer.email, 50, startY + 80);
|
||||
}
|
||||
}
|
||||
|
||||
doc.moveDown(3);
|
||||
}
|
||||
|
||||
addLineItems(doc, invoice) {
|
||||
const tableTop = 300;
|
||||
const itemCodeX = 50;
|
||||
const descriptionX = 150;
|
||||
const quantityX = 350;
|
||||
const priceX = 400;
|
||||
const amountX = 480;
|
||||
|
||||
// Table header
|
||||
doc.fontSize(10).font('Helvetica-Bold');
|
||||
doc.text('Item', itemCodeX, tableTop);
|
||||
doc.text('Description', descriptionX, tableTop);
|
||||
doc.text('Qty', quantityX, tableTop);
|
||||
doc.text('Price', priceX, tableTop);
|
||||
doc.text('Amount', amountX, tableTop);
|
||||
|
||||
// Draw line under header
|
||||
doc.moveTo(50, tableTop + 15)
|
||||
.lineTo(550, tableTop + 15)
|
||||
.stroke();
|
||||
|
||||
// Line items
|
||||
let y = tableTop + 30;
|
||||
doc.font('Helvetica');
|
||||
|
||||
invoice.lineItems.forEach((item, index) => {
|
||||
doc.text(index + 1, itemCodeX, y);
|
||||
doc.text(item.description || 'Service', descriptionX, y, { width: 180 });
|
||||
doc.text(item.quantity || 1, quantityX, y);
|
||||
doc.text(`$${(item.unitPrice || 0).toFixed(2)}`, priceX, y);
|
||||
doc.text(`$${(item.amount || 0).toFixed(2)}`, amountX, y);
|
||||
|
||||
y += 20;
|
||||
|
||||
// Add new page if needed
|
||||
if (y > 650) {
|
||||
doc.addPage();
|
||||
y = 50;
|
||||
}
|
||||
});
|
||||
|
||||
return y;
|
||||
}
|
||||
|
||||
addTotals(doc, invoice) {
|
||||
const startX = 350;
|
||||
let y = 500;
|
||||
|
||||
// Draw line above totals
|
||||
doc.moveTo(startX, y)
|
||||
.lineTo(550, y)
|
||||
.stroke();
|
||||
|
||||
y += 15;
|
||||
|
||||
// Subtotal
|
||||
doc.fontSize(10).font('Helvetica');
|
||||
doc.text('Subtotal:', startX, y);
|
||||
doc.text(`$${(invoice.subtotal || 0).toFixed(2)}`, 480, y);
|
||||
|
||||
// Tax
|
||||
if (invoice.tax && invoice.tax.amount > 0) {
|
||||
y += 15;
|
||||
doc.text(`Tax (${invoice.tax.rate}%):`, startX, y);
|
||||
doc.text(`$${invoice.tax.amount.toFixed(2)}`, 480, y);
|
||||
}
|
||||
|
||||
// Discount
|
||||
if (invoice.discount && invoice.discount.amount > 0) {
|
||||
y += 15;
|
||||
doc.text('Discount:', startX, y);
|
||||
doc.text(`-$${invoice.discount.amount.toFixed(2)}`, 480, y);
|
||||
}
|
||||
|
||||
// Total
|
||||
y += 20;
|
||||
doc.fontSize(12).font('Helvetica-Bold');
|
||||
doc.text('Total:', startX, y);
|
||||
doc.text(`$${(invoice.total || 0).toFixed(2)}`, 480, y);
|
||||
|
||||
// Amount paid
|
||||
if (invoice.amountPaid > 0) {
|
||||
y += 15;
|
||||
doc.fontSize(10).font('Helvetica');
|
||||
doc.text('Amount Paid:', startX, y);
|
||||
doc.text(`$${invoice.amountPaid.toFixed(2)}`, 480, y);
|
||||
}
|
||||
|
||||
// Amount due
|
||||
y += 20;
|
||||
doc.fontSize(14).font('Helvetica-Bold');
|
||||
doc.text('Amount Due:', startX, y);
|
||||
doc.text(`$${(invoice.amountDue || 0).toFixed(2)}`, 480, y);
|
||||
}
|
||||
|
||||
addFooter(doc, invoice) {
|
||||
const footerY = 700;
|
||||
|
||||
doc.fontSize(8).font('Helvetica').fillColor('#666666');
|
||||
|
||||
// Payment terms
|
||||
doc.text('Payment Terms: Net 30 days', 50, footerY);
|
||||
|
||||
// Thank you message
|
||||
doc.text('Thank you for your business!', 50, footerY + 15, { align: 'center', width: 500 });
|
||||
|
||||
// Page number
|
||||
doc.text(`Page 1 of 1`, 50, footerY + 30, { align: 'center', width: 500 });
|
||||
}
|
||||
|
||||
// Generate receipt PDF
|
||||
async generateReceiptPDF(transaction) {
|
||||
// Similar implementation for receipts
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
// ... implementation
|
||||
return Buffer.from('Receipt PDF content', 'utf-8');
|
||||
}
|
||||
|
||||
// Generate statement PDF
|
||||
async generateStatementPDF(transactions, period) {
|
||||
// Similar implementation for statements
|
||||
const doc = new PDFDocument({ margin: 50 });
|
||||
// ... implementation
|
||||
return Buffer.from('Statement PDF content', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
export const pdfService = new PDFService();
|
||||
196
marketing-agent/services/billing-service/src/utils/reports.js
Normal file
196
marketing-agent/services/billing-service/src/utils/reports.js
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Parser } from 'json2csv';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
class ReportGenerator {
|
||||
// Generate CSV report
|
||||
async csv(data, options = {}) {
|
||||
try {
|
||||
const fields = options.fields || Object.keys(data[0] || {});
|
||||
const opts = {
|
||||
fields,
|
||||
...options
|
||||
};
|
||||
|
||||
const parser = new Parser(opts);
|
||||
const csv = parser.parse(data);
|
||||
|
||||
return Buffer.from(csv, 'utf-8');
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate CSV', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate PDF report
|
||||
async pdf(data, options = {}) {
|
||||
try {
|
||||
const doc = new PDFDocument();
|
||||
const chunks = [];
|
||||
|
||||
doc.on('data', chunks.push.bind(chunks));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
doc.on('end', () => {
|
||||
const pdfBuffer = Buffer.concat(chunks);
|
||||
resolve(pdfBuffer);
|
||||
});
|
||||
|
||||
doc.on('error', reject);
|
||||
|
||||
// Add title
|
||||
doc.fontSize(20).text(options.title || 'Report', 50, 50);
|
||||
doc.moveDown();
|
||||
|
||||
// Add metadata
|
||||
if (options.tenantId) {
|
||||
doc.fontSize(12).text(`Tenant: ${options.tenantId}`);
|
||||
}
|
||||
doc.text(`Generated: ${new Date().toLocaleString()}`);
|
||||
doc.moveDown();
|
||||
|
||||
// Add data table
|
||||
if (data.length > 0) {
|
||||
const headers = Object.keys(data[0]);
|
||||
let y = doc.y;
|
||||
|
||||
// Headers
|
||||
doc.fontSize(10).font('Helvetica-Bold');
|
||||
headers.forEach((header, i) => {
|
||||
doc.text(header, 50 + (i * 100), y, { width: 90, align: 'left' });
|
||||
});
|
||||
|
||||
doc.moveDown();
|
||||
y = doc.y;
|
||||
|
||||
// Data rows
|
||||
doc.font('Helvetica');
|
||||
data.forEach((row, rowIndex) => {
|
||||
if (rowIndex > 0 && rowIndex % 20 === 0) {
|
||||
doc.addPage();
|
||||
y = 50;
|
||||
}
|
||||
|
||||
headers.forEach((header, i) => {
|
||||
const value = row[header] || '';
|
||||
doc.text(String(value), 50 + (i * 100), y, { width: 90, align: 'left' });
|
||||
});
|
||||
|
||||
y += 20;
|
||||
});
|
||||
}
|
||||
|
||||
doc.end();
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate PDF', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Excel report
|
||||
async excel(data, options = {}) {
|
||||
try {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet(options.sheetName || 'Data');
|
||||
|
||||
// Add headers
|
||||
if (data.length > 0) {
|
||||
const headers = Object.keys(data[0]);
|
||||
worksheet.columns = headers.map(header => ({
|
||||
header: header,
|
||||
key: header,
|
||||
width: 15
|
||||
}));
|
||||
|
||||
// Add data
|
||||
worksheet.addRows(data);
|
||||
|
||||
// Style headers
|
||||
worksheet.getRow(1).font = { bold: true };
|
||||
worksheet.getRow(1).fill = {
|
||||
type: 'pattern',
|
||||
pattern: 'solid',
|
||||
fgColor: { argb: 'FFE0E0E0' }
|
||||
};
|
||||
|
||||
// Add filters
|
||||
worksheet.autoFilter = {
|
||||
from: 'A1',
|
||||
to: `${String.fromCharCode(65 + headers.length - 1)}1`
|
||||
};
|
||||
}
|
||||
|
||||
// Add metadata sheet
|
||||
if (options.metadata) {
|
||||
const metaSheet = workbook.addWorksheet('Metadata');
|
||||
metaSheet.addRow(['Generated', new Date().toISOString()]);
|
||||
metaSheet.addRow(['Records', data.length]);
|
||||
Object.entries(options.metadata).forEach(([key, value]) => {
|
||||
metaSheet.addRow([key, value]);
|
||||
});
|
||||
}
|
||||
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
return buffer;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate Excel', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate summary statistics
|
||||
async summary(data, groupBy, aggregations) {
|
||||
try {
|
||||
const summary = {};
|
||||
|
||||
// Group data
|
||||
data.forEach(item => {
|
||||
const key = item[groupBy];
|
||||
if (!summary[key]) {
|
||||
summary[key] = {
|
||||
count: 0,
|
||||
items: []
|
||||
};
|
||||
}
|
||||
summary[key].count++;
|
||||
summary[key].items.push(item);
|
||||
});
|
||||
|
||||
// Apply aggregations
|
||||
Object.entries(summary).forEach(([key, group]) => {
|
||||
aggregations.forEach(agg => {
|
||||
if (agg.type === 'sum') {
|
||||
group[`${agg.field}_sum`] = group.items.reduce((sum, item) =>
|
||||
sum + (parseFloat(item[agg.field]) || 0), 0
|
||||
);
|
||||
} else if (agg.type === 'avg') {
|
||||
const sum = group.items.reduce((sum, item) =>
|
||||
sum + (parseFloat(item[agg.field]) || 0), 0
|
||||
);
|
||||
group[`${agg.field}_avg`] = sum / group.count;
|
||||
} else if (agg.type === 'min') {
|
||||
group[`${agg.field}_min`] = Math.min(...group.items.map(item =>
|
||||
parseFloat(item[agg.field]) || 0
|
||||
));
|
||||
} else if (agg.type === 'max') {
|
||||
group[`${agg.field}_max`] = Math.max(...group.items.map(item =>
|
||||
parseFloat(item[agg.field]) || 0
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// Remove items array to save memory
|
||||
delete group.items;
|
||||
});
|
||||
|
||||
return summary;
|
||||
} catch (error) {
|
||||
logger.error('Failed to generate summary', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const generateReport = new ReportGenerator();
|
||||
@@ -0,0 +1,101 @@
|
||||
import request from 'supertest';
|
||||
import app from '../src/app.js';
|
||||
import { Subscription } from '../src/models/Subscription.js';
|
||||
import mongoose from 'mongoose';
|
||||
|
||||
describe('Subscription API', () => {
|
||||
beforeAll(async () => {
|
||||
// Connect to test database
|
||||
await mongoose.connect('mongodb://localhost:27017/billing-test', {
|
||||
useNewUrlParser: true,
|
||||
useUnifiedTopology: true
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Clean up and close connection
|
||||
await mongoose.connection.dropDatabase();
|
||||
await mongoose.connection.close();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up data after each test
|
||||
await Subscription.deleteMany({});
|
||||
});
|
||||
|
||||
describe('POST /api/subscriptions', () => {
|
||||
it('should create a new subscription', async () => {
|
||||
const subscriptionData = {
|
||||
plan: 'starter',
|
||||
billingCycle: 'monthly'
|
||||
};
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/subscriptions')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('x-tenant-id', 'test-tenant')
|
||||
.send(subscriptionData);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.subscription).toBeDefined();
|
||||
expect(response.body.subscription.plan).toBe('starter');
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
const response = await request(app)
|
||||
.post('/api/subscriptions')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('x-tenant-id', 'test-tenant')
|
||||
.send({});
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.body.success).toBe(false);
|
||||
expect(response.body.errors).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/subscriptions', () => {
|
||||
it('should return tenant subscriptions', async () => {
|
||||
// Create test subscription
|
||||
const subscription = new Subscription({
|
||||
tenantId: 'test-tenant',
|
||||
plan: 'professional',
|
||||
status: 'active'
|
||||
});
|
||||
await subscription.save();
|
||||
|
||||
const response = await request(app)
|
||||
.get('/api/subscriptions')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('x-tenant-id', 'test-tenant');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.subscriptions).toHaveLength(1);
|
||||
expect(response.body.subscriptions[0].plan).toBe('professional');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/subscriptions/:id/cancel', () => {
|
||||
it('should cancel a subscription', async () => {
|
||||
// Create test subscription
|
||||
const subscription = new Subscription({
|
||||
tenantId: 'test-tenant',
|
||||
plan: 'starter',
|
||||
status: 'active'
|
||||
});
|
||||
await subscription.save();
|
||||
|
||||
const response = await request(app)
|
||||
.post(`/api/subscriptions/${subscription._id}/cancel`)
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.set('x-tenant-id', 'test-tenant')
|
||||
.send({ reason: 'No longer needed' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.success).toBe(true);
|
||||
expect(response.body.subscription.status).toBe('canceled');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user