Initial commit: Telegram Management System
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:
你的用户名
2025-11-04 15:37:50 +08:00
commit 237c7802e5
3674 changed files with 525172 additions and 0 deletions

View 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

View 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"
}
}

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

View 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'
}
}
}
};

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

View File

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

View 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);

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

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

View 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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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);

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

View 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();

View 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();

View File

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