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>
257 lines
5.3 KiB
JavaScript
257 lines
5.3 KiB
JavaScript
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); |