PHP 8.2.30
Preview: invoiceController.js Size: 25.39 KB
//home/byroehnu/easepay.easetack.com/controllers/invoiceController.js

const { prisma } = require('../config/prisma');
const QRCode = require('qrcode');
const PDFDocument = require('pdfkit');
const fs = require('fs');
const path = require('path');

const invoiceController = {
  
  // Get next invoice number
  getNextInvoiceNumber: async (req, res) => {
    try {
      const userId = req.user.id;
      const businessId = req.user.businessId;

      let profile;
      if (businessId) {
        profile = await prisma.businessProfile.findUnique({
          where: { businessId }
        });
      }

      // If no profile exists, create default one
      if (!profile && businessId) {
        profile = await prisma.businessProfile.create({
          data: {
            businessId,
            nextInvoiceNumber: 1
          }
        });
      }

      const nextNumber = profile ? profile.nextInvoiceNumber : 1;
      const prefix = profile?.invoicePrefix || 'INV';

      res.json({
        success: true,
        data: {
          nextInvoiceNumber: `${prefix}-${String(nextNumber).padStart(5, '0')}`
        }
      });
    } catch (error) {
      console.error('Error getting next invoice number:', error);
      res.status(500).json({
        success: false,
        message: 'Error getting next invoice number',
        error: error.message
      });
    }
  },

  // Create new invoice
  createInvoice: async (req, res) => {
    try {
      const userId = req.user.id;
      const businessId = req.user.businessId;
      
      const {
        customerName,
        customerEmail,
        customerPhone,
        customerAddress,
        invoiceNumber,
        invoiceDate,
        dueDate,
        paymentMethod,
        items,
        subtotal,
        discount,
        discountType = 'FIXED',
        vatRate,
        vatAmount,
        total,
        notes,
        currency = 'NGN'
      } = req.body;

      // Validate required fields
      if (!customerName || !items || items.length === 0 || !total) {
        return res.status(400).json({
          success: false,
          message: 'Missing required fields: customerName, items, and total are required'
        });
      }

      // Create invoice
      const invoice = await prisma.invoice.create({
        data: {
          invoiceNumber,
          customerName,
          customerEmail,
          customerPhone,
          customerAddress,
          businessId,
          userId,
          invoiceDate: invoiceDate ? new Date(invoiceDate) : new Date(),
          dueDate: dueDate ? new Date(dueDate) : null,
          paymentMethod,
          subtotal: parseFloat(subtotal),
          discount: parseFloat(discount) || 0,
          discountType,
          vatRate: parseFloat(vatRate) || 0,
          vatAmount: parseFloat(vatAmount) || 0,
          total: parseFloat(total),
          notes,
          currency,
          status: 'CREATED',
          items: {
            create: items.map(item => ({
              itemName: item.name,
              description: item.description,
              quantity: parseFloat(item.quantity),
              unitPrice: parseFloat(item.unitPrice),
              total: parseFloat(item.total),
              inventoryItemId: item.inventoryItemId || null
            }))
          }
        },
        include: {
          items: true,
          business: true
        }
      });

      // Generate QR Code
      const shareUrl = `${process.env.FRONTEND_URL}/invoice/${invoice.id}`;
      const qrCodeData = await QRCode.toDataURL(shareUrl);
      
      // Update invoice with QR code
      const updatedInvoice = await prisma.invoice.update({
        where: { id: invoice.id },
        data: {
          qrCodeUrl: shareUrl,
          qrCodeData,
          shareUrl,
          downloadUrl: `${process.env.API_URL}/api/invoices/${invoice.id}/pdf`
        }
      });

      // Update next invoice number
      if (businessId) {
        const profile = await prisma.businessProfile.findUnique({
          where: { businessId }
        });
        
        if (profile) {
          await prisma.businessProfile.update({
            where: { businessId },
            data: {
              nextInvoiceNumber: profile.nextInvoiceNumber + 1
            }
          });
        }
      }

      // Update inventory if items are linked
      for (const item of items) {
        if (item.inventoryItemId) {
          await prisma.inventoryItem.update({
            where: { id: item.inventoryItemId },
            data: {
              stock: {
                decrement: parseFloat(item.quantity)
              }
            }
          });
        }
      }

      res.status(201).json({
        success: true,
        message: 'Invoice created successfully',
        data: {
          id: updatedInvoice.id,
          invoiceNumber: updatedInvoice.invoiceNumber,
          status: updatedInvoice.status,
          qrCode: updatedInvoice.qrCodeData,
          downloadUrl: updatedInvoice.downloadUrl,
          shareUrl: updatedInvoice.shareUrl,
          total: updatedInvoice.total,
          createdAt: updatedInvoice.createdAt
        }
      });

    } catch (error) {
      console.error('Error creating invoice:', error);
      res.status(500).json({
        success: false,
        message: 'Error creating invoice',
        error: error.message
      });
    }
  },

  // Get all invoices
  getAllInvoices: async (req, res) => {
    try {
      const userId = req.user.id;
      const businessId = req.user.businessId;
      const { page = 1, limit = 10, status, startDate, endDate } = req.query;

      const whereClause = {
        userId,
        ...(businessId && { businessId }),
        ...(status && { status }),
        ...(startDate && endDate && {
          invoiceDate: {
            gte: new Date(startDate),
            lte: new Date(endDate)
          }
        })
      };

      const invoices = await prisma.invoice.findMany({
        where: whereClause,
        include: {
          items: true,
          business: true
        },
        orderBy: { createdAt: 'desc' },
        skip: (parseInt(page) - 1) * parseInt(limit),
        take: parseInt(limit)
      });

      const totalInvoices = await prisma.invoice.count({ where: whereClause });

      res.json({
        success: true,
        data: {
          invoices,
          pagination: {
            currentPage: parseInt(page),
            totalPages: Math.ceil(totalInvoices / parseInt(limit)),
            totalInvoices,
            limit: parseInt(limit)
          }
        }
      });

    } catch (error) {
      console.error('Error getting invoices:', error);
      res.status(500).json({
        success: false,
        message: 'Error getting invoices',
        error: error.message
      });
    }
  },

  // Get single invoice
  getInvoice: async (req, res) => {
    try {
      const { id } = req.params;
      const userId = req.user?.id; // Optional for public invoice view

      const invoice = await prisma.invoice.findFirst({
        where: {
          id,
          ...(userId && { userId })
        },
        include: {
          items: true,
          business: {
            include: {
              profile: true
            }
          },
          payments: true
        }
      });

      if (!invoice) {
        return res.status(404).json({
          success: false,
          message: 'Invoice not found'
        });
      }

      res.json({
        success: true,
        data: invoice
      });

    } catch (error) {
      console.error('Error getting invoice:', error);
      res.status(500).json({
        success: false,
        message: 'Error getting invoice',
        error: error.message
      });
    }
  },

  // Update invoice
  updateInvoice: async (req, res) => {
    try {
      const { id } = req.params;
      const userId = req.user.id;
      const updateData = req.body;

      // Check if invoice exists and belongs to user
      const existingInvoice = await prisma.invoice.findFirst({
        where: { id, userId }
      });

      if (!existingInvoice) {
        return res.status(404).json({
          success: false,
          message: 'Invoice not found'
        });
      }

      // Prevent updating paid invoices
      if (existingInvoice.status === 'PAID') {
        return res.status(400).json({
          success: false,
          message: 'Cannot update paid invoice'
        });
      }

      const updatedInvoice = await prisma.invoice.update({
        where: { id },
        data: updateData,
        include: {
          items: true,
          business: true
        }
      });

      res.json({
        success: true,
        message: 'Invoice updated successfully',
        data: updatedInvoice
      });

    } catch (error) {
      console.error('Error updating invoice:', error);
      res.status(500).json({
        success: false,
        message: 'Error updating invoice',
        error: error.message
      });
    }
  },

  // Delete invoice
  deleteInvoice: async (req, res) => {
    try {
      const { id } = req.params;
      const userId = req.user.id;

      const invoice = await prisma.invoice.findFirst({
        where: { id, userId }
      });

      if (!invoice) {
        return res.status(404).json({
          success: false,
          message: 'Invoice not found'
        });
      }

      await prisma.invoice.delete({
        where: { id }
      });

      res.json({
        success: true,
        message: 'Invoice deleted successfully'
      });

    } catch (error) {
      console.error('Error deleting invoice:', error);
      res.status(500).json({
        success: false,
        message: 'Error deleting invoice',
        error: error.message
      });
    }
  },

  // Generate PDF (Legacy method - kept for backward compatibility)
  generateInvoice: async (req, res) => {
    try {
      const {
        senderDetails,
        clientDetails,
        items,
        invoiceNumber,
        date,
        dueDate,
        tax,
        total,
        notes
      } = req.body;

      const doc = new PDFDocument({ margin: 50, size: 'A4' });

      // Stream the PDF to the response
      res.setHeader('Content-Type', 'application/pdf');
      res.setHeader('Content-Disposition', `attachment; filename=invoice-${invoiceNumber || 'draft'}.pdf`);
      
      doc.pipe(res);

      // --- Header Section ---
      generateHeader(doc, senderDetails);
      
      // --- Client & Invoice Info ---
      generateCustomerInformation(doc, clientDetails, invoiceNumber, date, dueDate);

      // --- Items Table ---
      generateInvoiceTable(doc, items, tax, total);

      // --- Footer ---
      generateFooter(doc, notes);

      doc.end();

    } catch (error) {
      console.error('Error generating invoice:', error);
      if (!res.headersSent) {
        res.status(500).json({ error: 'Failed to generate invoice' });
      }
    }
  },

  // Generate PDF for specific invoice
  generatePDF: async (req, res) => {
    try {
      const { id } = req.params;
      const userId = req.user?.id;

      const invoice = await prisma.invoice.findFirst({
        where: {
          id,
          ...(userId && { userId })
        },
        include: {
          items: true,
          business: {
            include: {
              profile: true
            }
          }
        }
      });

      if (!invoice) {
        return res.status(404).json({
          success: false,
          message: 'Invoice not found'
        });
      }

      // Create PDF
      const doc = new PDFDocument();
      let filename = `invoice-${invoice.invoiceNumber}.pdf`;
      
      res.setHeader('Content-Type', 'application/pdf');
      res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);

      doc.pipe(res);

      // PDF Content
      doc.fontSize(20).text('INVOICE', 50, 50);
      doc.fontSize(12).text(`Invoice #: ${invoice.invoiceNumber}`, 50, 80);
      doc.text(`Date: ${invoice.invoiceDate.toDateString()}`, 50, 100);
      
      // Business details
      if (invoice.business?.profile) {
        const profile = invoice.business.profile;
        doc.text(`From: ${profile.registrationName || invoice.business.name}`, 50, 140);
        if (profile.email) doc.text(`Email: ${profile.email}`, 50, 160);
        if (profile.phone) doc.text(`Phone: ${profile.phone}`, 50, 180);
        if (profile.address) doc.text(`Address: ${profile.address}`, 50, 200);
      }

      // Customer details
      doc.text(`Bill To: ${invoice.customerName}`, 300, 140);
      if (invoice.customerEmail) doc.text(`Email: ${invoice.customerEmail}`, 300, 160);
      if (invoice.customerPhone) doc.text(`Phone: ${invoice.customerPhone}`, 300, 180);

      // Items table
      let yPosition = 240;
      doc.text('Description', 50, yPosition);
      doc.text('Qty', 200, yPosition);
      doc.text('Price', 250, yPosition);
      doc.text('Total', 300, yPosition);
      
      yPosition += 20;
      invoice.items.forEach(item => {
        doc.text(item.itemName, 50, yPosition);
        doc.text(item.quantity.toString(), 200, yPosition);
        doc.text(`${invoice.currency} ${item.unitPrice}`, 250, yPosition);
        doc.text(`${invoice.currency} ${item.total}`, 300, yPosition);
        yPosition += 20;
      });

      // Totals
      yPosition += 20;
      doc.text(`Subtotal: ${invoice.currency} ${invoice.subtotal}`, 300, yPosition);
      if (invoice.discount > 0) {
        yPosition += 20;
        doc.text(`Discount: ${invoice.currency} ${invoice.discount}`, 300, yPosition);
      }
      if (invoice.vatAmount > 0) {
        yPosition += 20;
        doc.text(`VAT (${invoice.vatRate}%): ${invoice.currency} ${invoice.vatAmount}`, 300, yPosition);
      }
      yPosition += 20;
      doc.fontSize(14).text(`Total: ${invoice.currency} ${invoice.total}`, 300, yPosition);

      // Notes
      if (invoice.notes) {
        yPosition += 40;
        doc.fontSize(12).text('Notes:', 50, yPosition);
        doc.text(invoice.notes, 50, yPosition + 20);
      }

      doc.end();

    } catch (error) {
      console.error('Error generating PDF:', error);
      res.status(500).json({
        success: false,
        message: 'Error generating PDF',
        error: error.message
      });
    }
  },

  // Send invoice via email
  sendInvoiceEmail: async (req, res) => {
    try {
      const { id } = req.params;
      const { recipientEmail, message } = req.body;
      const userId = req.user.id;

      const invoice = await prisma.invoice.findFirst({
        where: { id, userId },
        include: {
          business: {
            include: {
              profile: true
            }
          }
        }
      });

      if (!invoice) {
        return res.status(404).json({
          success: false,
          message: 'Invoice not found'
        });
      }

      // Here you would integrate with your email service
      // For now, just update invoice status
      await prisma.invoice.update({
        where: { id },
        data: { status: 'SENT' }
      });

      res.json({
        success: true,
        message: 'Invoice sent successfully',
        data: {
          emailSent: true,
          sentAt: new Date()
        }
      });

    } catch (error) {
      console.error('Error sending invoice email:', error);
      res.status(500).json({
        success: false,
        message: 'Error sending invoice email',
        error: error.message
      });
    }
  },

  // Get QR code for invoice
  getQRCode: async (req, res) => {
    try {
      const { id } = req.params;
      const userId = req.user?.id;

      const invoice = await prisma.invoice.findFirst({
        where: {
          id,
          ...(userId && { userId })
        }
      });

      if (!invoice) {
        return res.status(404).json({
          success: false,
          message: 'Invoice not found'
        });
      }

      const qrData = {
        qrCodeUrl: invoice.qrCodeUrl,
        qrCodeData: invoice.qrCodeData,
        paymentUrl: invoice.shareUrl
      };

      res.json({
        success: true,
        data: qrData
      });

    } catch (error) {
      console.error('Error getting QR code:', error);
      res.status(500).json({
        success: false,
        message: 'Error getting QR code',
        error: error.message
      });
    }
  },

  // Get invoice analytics
  getInvoiceAnalytics: async (req, res) => {
    try {
      const userId = req.user.id;
      const businessId = req.user.businessId;
      const { period = 'month' } = req.query;

      let dateFilter = {};
      const now = new Date();

      switch (period) {
        case 'week':
          dateFilter = {
            gte: new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
          };
          break;
        case 'month':
          dateFilter = {
            gte: new Date(now.getFullYear(), now.getMonth(), 1)
          };
          break;
        case 'year':
          dateFilter = {
            gte: new Date(now.getFullYear(), 0, 1)
          };
          break;
      }

      const whereClause = {
        userId,
        ...(businessId && { businessId }),
        ...(Object.keys(dateFilter).length && { invoiceDate: dateFilter })
      };

      const analytics = await prisma.invoice.aggregate({
        where: whereClause,
        _count: { id: true },
        _sum: { total: true, paidAmount: true }
      });

      const statusBreakdown = await prisma.invoice.groupBy({
        by: ['status'],
        where: whereClause,
        _count: { status: true },
        _sum: { total: true }
      });

      res.json({
        success: true,
        data: {
          totalInvoices: analytics._count.id,
          totalValue: analytics._sum.total || 0,
          totalPaid: analytics._sum.paidAmount || 0,
          statusBreakdown,
          period
        }
      });

    } catch (error) {
      console.error('Error getting invoice analytics:', error);
      res.status(500).json({
        success: false,
        message: 'Error getting invoice analytics',
        error: error.message
      });
    }
  }
};

function generateHeader(doc, sender) {
  // doc.image('logo.png', 50, 45, { width: 50 })
  //    .fillColor('#444444')
  //    .fontSize(20)
  //    .text(sender.name || 'Company Name', 110, 57)
  //    .fontSize(10)
  //    .text(sender.address || '', 200, 65, { align: 'right' })
  //    .text(sender.city || '', 200, 80, { align: 'right' })
  //    .moveDown();

  doc
    .fillColor('#444444')
    .fontSize(20)
    .text(sender?.name || 'EasePay', 50, 57)
    .fontSize(10)
    .text(sender?.name || 'EasePay Inc.', 200, 50, { align: 'right' })
    .text(sender?.address || '123 Business Rd', 200, 65, { align: 'right' })
    .text(`${sender?.city || 'City'}, ${sender?.country || 'Country'}`, 200, 80, { align: 'right' })
    .moveDown();
    
  // Draw a line under header
  generateHr(doc, 100);
}

function generateCustomerInformation(doc, client, invoiceNumber, date, dueDate) {
  doc
    .fillColor('#444444')
    .fontSize(20)
    .text('Invoice', 50, 160);

  generateHr(doc, 185);

  const customerInformationTop = 200;

  doc
    .fontSize(10)
    .text('Invoice Number:', 50, customerInformationTop)
    .font('Helvetica-Bold')
    .text(invoiceNumber || 'INV-001', 150, customerInformationTop)
    .font('Helvetica')
    .text('Invoice Date:', 50, customerInformationTop + 15)
    .text(date || new Date().toISOString().split('T')[0], 150, customerInformationTop + 15)
    .text('Balance Due:', 50, customerInformationTop + 30)
    .text(formatCurrency(0), 150, customerInformationTop + 30) // Assuming paid or partial logic later

    .font('Helvetica-Bold')
    .text(client?.name || 'Client Name', 300, customerInformationTop)
    .font('Helvetica')
    .text(client?.address || 'Client Address', 300, customerInformationTop + 15)
    .text(
      `${client?.city || 'City'}, ${client?.country || 'Country'}`,
      300,
      customerInformationTop + 30
    )
    .moveDown();

  generateHr(doc, 252);
}

function generateInvoiceTable(doc, items, tax, total) {
  let i;
  const invoiceTableTop = 330;

  doc.font('Helvetica-Bold');
  generateTableRow(
    doc,
    invoiceTableTop,
    'Item',
    'Description',
    'Unit Cost',
    'Quantity',
    'Line Total'
  );
  generateHr(doc, invoiceTableTop + 20);
  doc.font('Helvetica');

  let position = 0;
  // Fallback if no items
  const invoiceItems = items && items.length > 0 ? items : [
      { name: 'Service A', description: 'Consulting', amount: 100, quantity: 2 },
      { name: 'Service B', description: 'Development', amount: 50, quantity: 5 }
  ];

  for (i = 0; i < invoiceItems.length; i++) {
    const item = invoiceItems[i];
    position = invoiceTableTop + (i + 1) * 30;
    generateTableRow(
      doc,
      position,
      item.name,
      item.description,
      formatCurrency(item.amount),
      item.quantity,
      formatCurrency(item.amount * item.quantity)
    );

    generateHr(doc, position + 20);
  }

  const subtotalPosition = invoiceTableTop + (invoiceItems.length + 1) * 30;
  generateTableRow(
    doc,
    subtotalPosition,
    '',
    '',
    'Subtotal',
    '',
    formatCurrency(total || 0) // Should calculate sum if total not provided
  );
}

// Helper functions for legacy PDF generation
function generateHeader(doc, sender) {
  doc
    .fillColor('#444444')
    .fontSize(20)
    .text(sender?.name || 'Your Company', 50, 57)
    .fontSize(10)
    .text(sender?.name || 'Your Company', 200, 50, { align: 'right' })
    .text(sender?.address || '123 Main Street', 200, 65, { align: 'right' })
    .text(`${sender?.city || 'New York'}, ${sender?.state || 'NY'} ${sender?.country || 'USA'}`, 200, 80, { align: 'right' })
    .moveDown();
}

function generateCustomerInformation(doc, customer, invoiceNumber, date, dueDate) {
  doc
    .fillColor('#444444')
    .fontSize(20)
    .text('Invoice', 50, 160);

  generateHr(doc, 185);

  const customerInformationTop = 200;

  doc
    .fontSize(10)
    .text('Invoice Number:', 50, customerInformationTop)
    .font('Helvetica-Bold')
    .text(invoiceNumber || 'INV-001', 150, customerInformationTop)
    .font('Helvetica')
    .text('Invoice Date:', 50, customerInformationTop + 15)
    .text(date || new Date().toLocaleDateString(), 150, customerInformationTop + 15)
    .text('Due Date:', 50, customerInformationTop + 30)
    .text(dueDate || 'N/A', 150, customerInformationTop + 30)

    .font('Helvetica-Bold')
    .text('Bill To:', 300, customerInformationTop)
    .font('Helvetica')
    .text(customer?.name || 'Customer Name', 300, customerInformationTop + 15)
    .text(customer?.address || 'Customer Address', 300, customerInformationTop + 30)
    .text(`${customer?.city || 'City'}, ${customer?.state || 'State'} ${customer?.country || 'Country'}`, 300, customerInformationTop + 45)
    .moveDown();

  generateHr(doc, 252);
}

function generateInvoiceTable(doc, items, tax, total) {
  let i;
  const invoiceTableTop = 330;

  doc.font('Helvetica-Bold');
  generateTableRow(
    doc,
    invoiceTableTop,
    'Item',
    'Description',
    'Unit Price',
    'Quantity',
    'Line Total'
  );
  generateHr(doc, invoiceTableTop + 20);
  doc.font('Helvetica');

  for (i = 0; i < (items?.length || 0); i++) {
    const item = items[i];
    const position = invoiceTableTop + (i + 1) * 30;
    generateTableRow(
      doc,
      position,
      item?.name || 'Item',
      item?.description || 'Description',
      formatCurrency(item?.rate || 0),
      item?.quantity || 1,
      formatCurrency((item?.rate || 0) * (item?.quantity || 1))
    );

    generateHr(doc, position + 20);
  }

  const subtotalPosition = invoiceTableTop + (i + 1) * 30;
  generateTableRow(
    doc,
    subtotalPosition,
    '',
    '',
    'Subtotal',
    '',
    formatCurrency(total || 0)
  );
  
  const taxPosition = subtotalPosition + 20;
  generateTableRow(
    doc,
    taxPosition,
    '',
    '',
    'Tax',
    '',
    formatCurrency(tax || 0)
  );

  const duePosition = taxPosition + 25;
  doc.font('Helvetica-Bold');
  generateTableRow(
    doc,
    duePosition,
    '',
    '',
    'Total Due',
    '',
    formatCurrency((total || 0) + (tax || 0))
  );
  doc.font('Helvetica');
}

function generateFooter(doc, notes) {
  doc
    .fontSize(10)
    .text(
      notes || 'Payment is due within 15 days. Thank you for your business.',
      50,
      780,
      { align: 'center', width: 500 }
    );
}

function generateTableRow(
  doc,
  y,
  item,
  description,
  unitCost,
  quantity,
  lineTotal
) {
  doc
    .fontSize(10)
    .text(item, 50, y)
    .text(description, 150, y)
    .text(unitCost, 280, y, { width: 90, align: 'right' })
    .text(quantity, 370, y, { width: 90, align: 'right' })
    .text(lineTotal, 0, y, { align: 'right' });
}

function generateHr(doc, y) {
  doc
    .strokeColor('#aaaaaa')
    .lineWidth(1)
    .moveTo(50, y)
    .lineTo(550, y)
    .stroke();
}

function formatCurrency(cents) {
  return "₦" + (cents).toFixed(2);
}

module.exports = invoiceController;

Directory Contents

Dirs: 1 × Files: 17

Name Size Perms Modified Actions
- drwxr-xr-x 2026-03-20 23:25:48
Edit Download
127 B lr--r--r-- 2026-03-14 01:49:22
Edit Download
15.34 KB lrw-r--r-- 2026-02-19 22:06:47
Edit Download
30.77 KB lrw-r--r-- 2026-03-04 06:33:18
Edit Download
62.98 KB lrw-r--r-- 2026-03-01 23:46:52
Edit Download
10.79 KB lrw-r--r-- 2026-02-07 16:28:16
Edit Download
14.17 KB lrw-r--r-- 2026-02-27 04:02:38
Edit Download
6.32 KB lrw-r--r-- 2026-02-06 17:10:19
Edit Download
15.13 KB lrw-r--r-- 2026-03-01 05:05:51
Edit Download
8.50 KB lrw-r--r-- 2026-03-01 05:05:52
Edit Download
12.15 KB lrw-r--r-- 2026-02-27 04:02:38
Edit Download
25.39 KB lrw-r--r-- 2026-03-01 05:05:51
Edit Download
17.61 KB lrw-r--r-- 2026-03-01 05:05:51
Edit Download
6.45 KB lrw-r--r-- 2026-03-01 05:05:51
Edit Download
2.33 KB lrw-r--r-- 2026-03-01 05:05:51
Edit Download
12.78 KB lrw-r--r-- 2026-02-21 08:13:30
Edit Download
9.96 KB lrw-r--r-- 2026-03-01 05:05:51
Edit Download
8.51 KB lrw-r--r-- 2026-02-20 19:31:28
Edit Download

If ZipArchive is unavailable, a .tar will be created (no compression).