// Finance screen — revenue, invoices, multi-currency, PDF export. (() => { const { Card, StatCard, Badge, Tabs } = window.TechyFuelOSDesignSystem_be0222; const IS = { paid: { tone: 'success', label: 'Paid' }, sent: { tone: 'info', label: 'Sent' }, overdue: { tone: 'danger', label: 'Overdue' }, draft: { tone: 'neutral', label: 'Draft' }, cancelled: { tone: 'neutral', label: 'Cancelled' }, }; const EXPENSE_CATEGORIES = { salary: { tone: 'violet', label: 'Salary' }, tools: { tone: 'info', label: 'Tools' }, ads: { tone: 'warning', label: 'Ads' }, freelance:{ tone: 'brand', label: 'Freelance' }, office: { tone: 'teal', label: 'Office' }, other: { tone: 'neutral', label: 'Other' }, }; const CURRENCIES = [ { code: 'USD', symbol: '$', name: 'US Dollar' }, { code: 'EUR', symbol: '€', name: 'Euro' }, { code: 'GBP', symbol: '£', name: 'British Pound' }, { code: 'AED', symbol: 'AED', name: 'UAE Dirham' }, { code: 'SAR', symbol: 'SAR', name: 'Saudi Riyal' }, { code: 'OMR', symbol: 'OMR', name: 'Omani Rial' }, { code: 'PKR', symbol: '₨', name: 'Pakistani Rupee' }, { code: 'CAD', symbol: 'CA$', name: 'Canadian Dollar' }, { code: 'AUD', symbol: 'A$', name: 'Australian Dollar' }, ]; function addRecurrenceInterval(dateStr, interval) { const d = new Date((dateStr || new Date().toISOString().slice(0, 10)) + 'T00:00:00Z'); if (interval === 'weekly') d.setUTCDate(d.getUTCDate() + 7); else if (interval === 'quarterly') d.setUTCMonth(d.getUTCMonth() + 3); else d.setUTCMonth(d.getUTCMonth() + 1); // 'monthly' default return d.toISOString().slice(0, 10); } function getCurrencySymbol(code) { return (CURRENCIES.find(c => c.code === code) || CURRENCIES[0]).symbol; } function fmtAmt(n, currency) { if (!n && n !== 0) return (getCurrencySymbol(currency || 'PKR')) + '0'; const sym = getCurrencySymbol(currency || 'PKR'); const num = Number(n).toLocaleString('en', { minimumFractionDigits: 0, maximumFractionDigits: 2 }); return sym + num; } // Convert an amount between any two supported currencies using USD-based // rates ({ PKR: 278.5, SAR: 3.75, ... } == 1 USD in that currency). Returns // null when a required rate hasn't loaded yet, so callers can show a // friendly "—" instead of a wrong number. function convertCurrency(amount, from, to, rates) { if (amount === '' || amount === null || amount === undefined || isNaN(Number(amount))) return null; from = from || 'USD'; to = to || 'USD'; const n = Number(amount); if (from === to) return n; if (!rates) return null; const usd = from === 'USD' ? n : (rates[from] ? n / rates[from] : null); if (usd === null) return null; if (to === 'USD') return usd; return rates[to] ? usd * rates[to] : null; } function fmtDate(ds) { if (!ds) return '—'; return new Date(ds).toLocaleDateString('en', { month: 'short', day: 'numeric', year: 'numeric' }); } function buildMonthlyBars(invoices, rates) { const months = Array.from({ length: 12 }, (_, i) => { const d = new Date(); d.setMonth(d.getMonth() - (11 - i)); return { key: d.toISOString().slice(0, 7), val: 0 }; }); for (const inv of invoices) { if (inv.status !== 'paid') continue; const pkr = convertCurrency(inv.amount, inv.currency || 'PKR', 'PKR', rates); if (pkr === null) continue; const key = (inv.due_date || inv.created_at || '').slice(0, 7); const m = months.find(x => x.key === key); if (m) m.val += pkr; } return months.map(m => m.val); } function readAgencyBranding() { const saved = (() => { try { return JSON.parse(localStorage.getItem('tf_settings') || '{}'); } catch { return {}; } })(); // Defaults mirror the reference TechyFuel invoice so the PDF looks complete // out of the box even before the workspace fills in Settings. Any value the // user saves in Settings overrides its default here. return { agencyName: saved.agencyName || 'TechyFuel', tagline: saved.tagline || "Fueling Your Brand's Digital Journey", agencyEmail: saved.agencyEmail || 'Info@techyfuel.com', agencyPhone: saved.agencyPhone || '+1 (509) 616-0866', agencyWebsite: saved.agencyWebsite || 'www.techyfuel.com', agencyAddress: saved.agencyAddress || 'Office No. 29, Najam Center 1st floor Dehli Colony Karachi', logoUrl: saved.logoUrl || '', // Meezan Bank (Zain Ahmed) — real payout details. Payoneer removed (unused). bankName: saved.bankName || 'Meezan Bank Limited', accountTitle: saved.accountTitle || 'ZAIN AHMED', paymentAccount: saved.paymentAccount || '01670104757377', iban: saved.iban || 'PK86MEZN0001670104757377', remitlyNote: saved.remitlyNote || 'You can use Remitly for International transactions', signatureName: saved.signatureName || 'ZAIN AHMED', signatureTitle: saved.signatureTitle || 'Founder & CEO', signatureImageUrl: saved.signatureImageUrl || '', servicesLine: saved.servicesLine || 'Digital Marketing • Web Development • UI/UX Design • Graphic Design • Branding • SEO • Performance Marketing • Video Editing', }; } // Ribbon graphic straight from the client's updated vector files // (SVG Header (2).svg / SVG Footer (1).svg). These carry the full-width // rule line built in (a spanning x=0..2539), so the line runs // edge-to-edge with the ribbon riding over it — no separate CSS rule needed. // Inlined as raw SVG (no decode step) so it never drops from the // print/PDF popup. New palette: blue #0f5ed4, dark grey #585a5a. function invoiceRibbonBand(isFooter) { // Full page-width band cropped to the ribbon strip. viewBox spans the whole // 2539-wide page so the rule line reaches both edges. if (isFooter) { // SVG Footer (2).svg — ribbon slats only, no full-width rule line. return ` `; } return ` `; } function invoiceItemsOf(inv) { const items = (Array.isArray(inv.invoice_items) ? inv.invoice_items : (Array.isArray(inv.items) ? inv.items : [])).slice().sort((a, b) => (a.sort_order || 0) - (b.sort_order || 0)); if (items.length) return items; // Legacy single-amount invoices have no line items — render one row so // the itemized template still looks right instead of an empty table. return [{ description: `Services — ${inv.invoice_no}`, qty: 1, unit_price: inv.amount || 0 }]; } // ── Branded invoice document (used for both PDF export and email share) ── function buildInvoiceHtml(inv, clients) { const b = readAgencyBranding(); // Default to the real TechyFuel logo (embedded base64, print-safe) when the // workspace hasn't uploaded its own, so the invoice matches the reference. const logoSrc = b.logoUrl || (typeof window !== 'undefined' && window.TF_INVOICE_LOGO) || ''; const clientObj = clients.find(c => c.id === inv.client_id) || {}; const clientName = inv.clients?.name || clientObj.name || clientObj.company || '—'; const clientEmail = clientObj.email || ''; const clientPhone = clientObj.phone || ''; const currency = inv.currency || 'PKR'; const items = invoiceItemsOf(inv); const itemsSum = items.reduce((s, it) => s + (Number(it.qty) || 0) * (Number(it.unit_price) || 0), 0); const discount = Number(inv.discount) || 0; const bonus = Number(inv.bonus) || 0; // Subtotal = line items (fall back to stored amount adjusted for disc/bonus if // there are no line items). Final total = subtotal − discount + bonus. const subtotal = items.length ? itemsSum : Math.max(0, (Number(inv.amount) || 0) + discount - bonus); const total = Math.max(0, subtotal - discount + bonus); // Payment reconciliation: if a received amount was recorded, show it plus the // gap (conversion loss / bank tax / deductions) as a breakdown under Total. const received = (inv.received_amount === 0 || inv.received_amount) ? Number(inv.received_amount) : null; const recCurrency = inv.received_currency || currency; // Advance taken up front. Only deducted from the printed balance when it was // taken in the SAME currency as the invoice — otherwise it is shown as a note, // never converted at an unknown rate. const advance = Number(inv.advance_paid) || 0; const advCurrency = inv.advance_currency || currency; const advSameCur = advance > 0 && advCurrency === currency; const sameCur = recCurrency === currency; // Deductions only meaningful when received in the SAME currency as invoice. const deductions = (received !== null && sameCur) ? Math.max(0, total - received) : 0; // Item title on line 1; anything after a newline becomes greyed detail text // under it (reference template's two-line item cells). const rowsHtml = items.map((it, i) => { const [title, ...rest] = String(it.description || '').split('\n'); const detail = rest.join(' ').trim(); return `
${title || '—'}
${detail ? `
${detail}
` : ''} ${String(Number(it.qty) || 0).padStart(2, '0')} ${fmtAmt(it.unit_price, currency)} ${fmtAmt((Number(it.qty) || 0) * (Number(it.unit_price) || 0), currency)} `; }).join(''); const noteLines = (inv.notes || '').split('\n').map(l => l.trim()).filter(Boolean); const notesHtml = (noteLines.length ? noteLines : [ `Thank you for choosing ${b.agencyName}.`, 'Payment is due on or before the due date above.', 'Please include the Invoice Number as your payment reference.', ]).map(l => `
  • ${l}
  • `).join(''); return ` Invoice ${inv.invoice_no}
    ${invoiceRibbonBand(false)}
    ${logoSrc ? `${b.agencyName}` : `
    ${b.agencyName}
    `}

    INVOICE

    ${b.servicesLine ? `
    ${b.servicesLine}
    ` : ''}
    ${clientName}
    ${clientObj.company && clientObj.company !== clientName ? `
    ${clientObj.company}
    ` : ''} ${clientPhone ? `
    P : ${clientPhone}
    ` : ''} ${clientEmail ? `
    E : ${clientEmail}
    ` : ''}
    INVOICE NO:#${inv.invoice_no}
    Invoice Date${fmtDate(inv.created_at || new Date().toISOString())}
    Due Date${fmtDate(inv.due_date)}
    ${rowsHtml}
    Item descriptionQuantityUnit PriceTotal Price

    Payment method

    ${b.bankName ? `
    Bank${b.bankName}
    ` : ''} ${b.accountTitle ? `
    Title${b.accountTitle}
    ` : ''} ${b.paymentAccount ? `
    Account${b.paymentAccount}
    ` : ''} ${b.iban ? `
    IBAN${b.iban}
    ` : ''} ${b.remitlyNote ? `
    ${b.remitlyNote}
    ` : ''}
    ${(discount > 0 || bonus > 0) ? `
    Subtotal${fmtAmt(subtotal, currency)}
    ${discount > 0 ? `
    Discount− ${fmtAmt(discount, currency)}
    ` : ''} ${bonus > 0 ? `
    Bonus / Tip+ ${fmtAmt(bonus, currency)}
    ` : ''} ` : ''}
    Total${fmtAmt(total, currency)}
    ${advance > 0 ? `
    Advance paid− ${fmtAmt(advance, advCurrency)}
    ${advSameCur ? `
    Balance due${fmtAmt(Math.max(0, total - advance), currency)}
    ` : `
    advance in ${advCurrency}
    `} ` : ''} ${received !== null ? `
    Received${fmtAmt(received, recCurrency)}
    ${deductions > 0 ? `
    Tax / deductions${fmtAmt(deductions, currency)}
    ` : ''} ` : ''}
    Thanks for your business!

    Notes

      ${notesHtml}
    ${b.signatureName ? `
    ${b.signatureImageUrl ? `` : ''}
    ${b.signatureName}
    ${b.signatureTitle || ''}
    ` : ''}
    CONTACT
    `; } // ── PDF invoice print ───────────────────────────────────────────── function printInvoicePDF(inv, clients) { const printScript = '