// Billing & Payments — Dev handoff presentation // Regenerate with: cd docs && node build-billing-pptx.js // // Structure (per user feedback 2026-04-17): // 1. Title // 2. End-to-end flow (facture → paiement) with scripts listed under each stage // 3. OPS app modules with scripts/components per module // 4. PDF preview + pay-public preview (aspect ratios preserved) const fs = require("fs"); const path = require("path"); const pptxgen = require("pptxgenjs"); const sharp = require("sharp"); function imgPath(rel) { const p = path.join(__dirname, rel); return fs.existsSync(p) ? p : null; } async function imgRatio(rel) { const p = path.join(__dirname, rel); if (!fs.existsSync(p)) return null; const m = await sharp(p).metadata(); return { w: m.width, h: m.height, ratio: m.width / m.height, path: p }; } // Fit an image inside a target box (tx, ty, tw, th) maintaining its native ratio function fitImage(meta, tx, ty, tw, th) { const boxR = tw / th; let w, h; if (meta.ratio > boxR) { w = tw; h = tw / meta.ratio; } else { h = th; w = th * meta.ratio; } const x = tx + (tw - w) / 2; const y = ty + (th - h) / 2; return { path: meta.path, x, y, w, h }; } const C = { darkBg: "0D0F18", cardBg: "181C2E", cardBg2: "111422", purple: "5C59A8", purpleDk: "3F3D7A", accent: "818CF8", green: "22C55E", amber: "F59E0B", red: "EF4444", white: "FFFFFF", text: "E2E4EF", muted: "7B80A0", indigo: "6366F1", }; const shadow = () => ({ type: "outer", blur: 8, offset: 3, angle: 135, color: "000000", opacity: 0.3 }); let pres; function header(s, title, subtitle) { s.background = { color: C.darkBg }; s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } }); s.addText(title, { x: 0.6, y: 0.22, w: 9, h: 0.6, fontSize: 26, fontFace: "Arial Black", color: C.white, margin: 0 }); if (subtitle) s.addText(subtitle, { x: 0.6, y: 0.76, w: 9, h: 0.3, fontSize: 11, fontFace: "Calibri", color: C.muted, margin: 0 }); } async function build() { pres = new pptxgen(); pres.layout = "LAYOUT_16x9"; pres.author = "Targo"; pres.title = "Facturation & Paiements — Handoff dev"; // ═════════ SLIDE 1 — Title ═════════ let s = pres.addSlide(); s.background = { color: C.darkBg }; s.addShape(pres.shapes.RECTANGLE, { x: 0, y: 0, w: 10, h: 0.06, fill: { color: C.purple } }); s.addShape(pres.shapes.RECTANGLE, { x: 0.8, y: 1.2, w: 0.12, h: 1.8, fill: { color: C.indigo } }); s.addText("Facturation", { x: 1.15, y: 1.2, w: 8, h: 0.7, fontSize: 42, fontFace: "Arial Black", color: C.white, bold: true, margin: 0 }); s.addText("& Paiements", { x: 1.15, y: 1.85, w: 8, h: 0.6, fontSize: 36, fontFace: "Arial Black", color: C.accent, margin: 0 }); s.addText("Handoff technique — pipeline end-to-end + modules OPS", { x: 1.15, y: 2.7, w: 8.5, h: 0.4, fontSize: 16, fontFace: "Calibri", color: C.muted, margin: 0 }); const heroStats = [ { v: "80", l: "Scripts migration" }, { v: "115 K", l: "Factures importées" }, { v: "1,6 M", l: "Items (incl. backfill)" }, { v: "15", l: "Pages OPS" }, ]; heroStats.forEach((st, i) => { const sx = 1.15 + i * 2.1; s.addShape(pres.shapes.RECTANGLE, { x: sx, y: 3.7, w: 1.8, h: 1.0, fill: { color: C.cardBg }, shadow: shadow() }); s.addText(st.v, { x: sx, y: 3.72, w: 1.8, h: 0.55, fontSize: 22, fontFace: "Arial Black", color: C.accent, align: "center", valign: "middle", margin: 0 }); s.addText(st.l, { x: sx, y: 4.25, w: 1.8, h: 0.35, fontSize: 10, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 }); }); s.addText("Avril 2026 • erp.gigafibre.ca + client.gigafibre.ca + ops", { x: 0.8, y: 5.1, w: 9, h: 0.3, fontSize: 10, fontFace: "Calibri", color: C.muted, margin: 0 }); // ═════════ SLIDE 2 — End-to-end flow with scripts per stage ═════════ s = pres.addSlide(); header(s, "Flux facture → paiement", "Scripts & fichiers utilisés à chaque étape"); // 6 stages laid out left-to-right, each with a card + scripts list below const stages = [ { title: "1. Import legacy", tagline: "MariaDB → PostgreSQL", color: C.purple, scripts: [ "import_invoices.py", "import_payments.py", "import_payment_methods.py", "import_payment_arrangements.py", "import_expro_payments.py", "backfill_service_location.py", "fix_invoice_outstanding.py", "fix_invoice_customer_names.py", "add_missing_custom_fields.py", ], }, { title: "2. Facture", tagline: "ERPNext Sales Invoice", color: C.accent, scripts: [ "setup_invoice_print_format.py", "test_jinja_render.py", "Print Format: « Facture TARGO »", "Jinja template (inline)", "pdf_generator=\"chrome\"", "Doctype: Sales Invoice", "Custom field: service_location", ], }, { title: "3. Rendu PDF", tagline: "Chromium --print-to-pdf", color: C.green, scripts: [ "gigafibre_utils.api.invoice_pdf", "Dockerfile (chromium install)", "common_site_config:", " chromium_path=/usr/bin/chromium", "cairosvg → logo PNG", "Producer: Skia/PDF m147", "~1 s / facture", ], }, { title: "4. QR / Magic-link", tagline: "Tokens HMAC-SHA256", color: C.indigo, scripts: [ "api.invoice_qr(invoice)", "api.invoice_qr_datauri(invoice)", "api._sign_pay_token", "api.validate_pay_token", "api.request_magic_link", "api.pay_token (admin)", "api.pay_redirect (portal)", "Secret: gigafibre_pay_secret", ], }, { title: "5. Landing publique", tagline: "client.gigafibre.ca/pay-public", color: C.amber, scripts: [ "gigafibre_utils/www/", " pay-public.html", "/opt/traefik/dynamic/", " pay-public.yml", "Traefik priority ≥ 250", "(carve-out Authentik)", "Vanilla JS, pas de build", ], }, { title: "6. Stripe + Payment Entry", tagline: "Checkout → webhook → réconciliation", color: C.red, scripts: [ "api.create_checkout_session", "api.stripe_webhook", "api._stripe_post", "api._stripe_verify_signature", "Secret: gigafibre_stripe_secret_key", "Secret: gigafibre_stripe_webhook_secret", "Twilio: twilio_account_sid / token / from", ], }, ]; const cardW = 1.5, cardH = 0.7, gap = 0.08; const totalW = stages.length * cardW + (stages.length - 1) * gap; const startX = (10 - totalW) / 2; const cardY = 1.3; stages.forEach((st, i) => { const x = startX + i * (cardW + gap); s.addShape(pres.shapes.RECTANGLE, { x, y: cardY, w: cardW, h: cardH, fill: { color: C.cardBg }, line: { color: st.color, width: 1.5 }, shadow: shadow(), }); s.addText(st.title, { x, y: cardY + 0.03, w: cardW, h: 0.32, fontSize: 10, fontFace: "Arial Black", color: C.white, align: "center", valign: "middle", margin: 0 }); s.addText(st.tagline, { x, y: cardY + 0.35, w: cardW, h: 0.3, fontSize: 7.5, fontFace: "Calibri", color: st.color, align: "center", valign: "middle", margin: 0 }); // Arrow to next stage if (i < stages.length - 1) { s.addText("→", { x: x + cardW - 0.05, y: cardY + 0.18, w: gap + 0.1, h: 0.35, fontSize: 14, color: C.muted, align: "center", valign: "middle", margin: 0, bold: true }); } // Scripts list under the card const listY = cardY + cardH + 0.15; s.addShape(pres.shapes.RECTANGLE, { x, y: listY, w: cardW, h: 3.3, fill: { color: C.cardBg2 }, line: { color: st.color, width: 0.5 }, }); const txt = st.scripts.map(sc => "• " + sc).join("\n"); s.addText(txt, { x: x + 0.04, y: listY + 0.05, w: cardW - 0.08, h: 3.2, fontSize: 6.8, fontFace: "Courier New", color: C.text, valign: "top", margin: 0, paraSpaceAfter: 2, }); }); // Footer: common secrets s.addShape(pres.shapes.RECTANGLE, { x: 0.4, y: 5.5, w: 9.2, h: 0.8, fill: { color: "1a1a2e" }, line: { color: C.purple, width: 0.5 } }); s.addText("Configuration (common_site_config.json)", { x: 0.55, y: 5.55, w: 9, h: 0.2, fontSize: 9, fontFace: "Calibri", color: C.accent, bold: true, margin: 0 }); s.addText("gigafibre_pay_secret · gigafibre_pay_host · gigafibre_stripe_secret_key · gigafibre_stripe_webhook_secret · twilio_account_sid · twilio_auth_token · twilio_from_number · chromium_path", { x: 0.55, y: 5.8, w: 9, h: 0.45, fontSize: 8, fontFace: "Courier New", color: C.text, margin: 0, }); // ═════════ SLIDE 3 — OPS modules with scripts/components ═════════ s = pres.addSlide(); header(s, "Modules OPS", "Pages & composants · apps/ops/src/"); const modules = [ { title: "Clients", color: C.purple, files: [ "pages/ClientsPage.vue", "pages/ClientDetailPage.vue", "components/customer/*", "components/shared/", " detail-sections/", " InvoiceDetail.vue", " PaymentDetail.vue", " SubscriptionDetail.vue", " EquipmentDetail.vue", " IssueDetail.vue", ], }, { title: "Dispatch", color: C.amber, files: [ "pages/DispatchPage.vue", "components/dispatch/", "pages/dispatch-styles.scss", "mode sombre (exception)", ], }, { title: "Facturation", color: C.green, files: [ "shared/CreateInvoiceModal.vue", "shared/detail-sections/", " InvoiceDetail.vue", " onglet Aperçu client", " iframe → invoice_pdf", "shared/detail-sections/", " PaymentDetail.vue", ], }, { title: "Rapports", color: C.indigo, files: [ "pages/RapportsPage.vue", "pages/ReportARPage.vue", "pages/ReportRevenuPage.vue", "pages/ReportTaxesPage.vue", "pages/ReportVentesPage.vue", ], }, { title: "Network", color: C.accent, files: [ "pages/NetworkPage.vue", "pages/TelephonyPage.vue", "pages/OcrPage.vue", ], }, { title: "Équipe & Tickets", color: C.red, files: [ "pages/EquipePage.vue", "pages/TicketsPage.vue", "pages/AgentFlowsPage.vue", "pages/DashboardPage.vue", "pages/SettingsPage.vue", ], }, ]; const cols = 3, rows = 2; const mW = 2.9, mH = 2.25; const mGapX = 0.15, mGapY = 0.15; const mStartX = (10 - (cols * mW + (cols - 1) * mGapX)) / 2; const mStartY = 1.3; modules.forEach((mod, i) => { const cx = mStartX + (i % cols) * (mW + mGapX); const cy = mStartY + Math.floor(i / cols) * (mH + mGapY); s.addShape(pres.shapes.RECTANGLE, { x: cx, y: cy, w: mW, h: mH, fill: { color: C.cardBg }, line: { color: mod.color, width: 1.5 }, shadow: shadow(), }); s.addShape(pres.shapes.RECTANGLE, { x: cx, y: cy, w: mW, h: 0.35, fill: { color: mod.color } }); s.addText(mod.title, { x: cx + 0.1, y: cy, w: mW - 0.2, h: 0.35, fontSize: 12, fontFace: "Arial Black", color: C.white, valign: "middle", margin: 0 }); s.addText(mod.files.map(f => "• " + f).join("\n"), { x: cx + 0.1, y: cy + 0.42, w: mW - 0.2, h: mH - 0.5, fontSize: 8.5, fontFace: "Courier New", color: C.text, valign: "top", margin: 0, }); }); // Footer: deploy note s.addShape(pres.shapes.RECTANGLE, { x: 0.4, y: mStartY + 2 * (mH + mGapY) + 0.1, w: 9.2, h: 0.5, fill: { color: "1a1a2e" }, line: { color: C.amber, width: 0.5 } }); s.addText("Déploiement: scp dist/spa/* → /opt/ops-app/ · nginx proxy API (token) · Authentik SSO · Traefik routing", { x: 0.55, y: mStartY + 2 * (mH + mGapY) + 0.18, w: 9, h: 0.4, fontSize: 9, fontFace: "Calibri", color: C.text, margin: 0, }); // ═════════ SLIDE 4 — Visuels : PDF facture + landing pay-public ═════════ s = pres.addSlide(); header(s, "Visuels", "PDF Facture TARGO (Chrome) + Landing /pay-public"); const invoiceMeta = await imgRatio("assets/screenshots/invoice-pdf.png"); const payMeta = await imgRatio("assets/screenshots/pay-public.png"); // Two equal 4.5" × 4.5" boxes, images fit maintaining ratio const box = { w: 4.3, h: 4.4, y: 1.2 }; const leftX = 0.4; const rightX = 5.3; s.addShape(pres.shapes.RECTANGLE, { x: leftX, y: box.y, w: box.w, h: box.h, fill: { color: C.white }, line: { color: C.purple, width: 1 } }); if (invoiceMeta) { const fit = fitImage(invoiceMeta, leftX + 0.05, box.y + 0.05, box.w - 0.1, box.h - 0.1); s.addImage({ path: fit.path, x: fit.x, y: fit.y, w: fit.w, h: fit.h }); } s.addText("PDF Facture TARGO", { x: leftX, y: box.y + box.h + 0.05, w: box.w, h: 0.3, fontSize: 11, fontFace: "Calibri", color: C.white, bold: true, align: "center", margin: 0 }); s.addText("Chromium --print-to-pdf · 1 page · ~1 s", { x: leftX, y: box.y + box.h + 0.32, w: box.w, h: 0.25, fontSize: 9, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 }); s.addShape(pres.shapes.RECTANGLE, { x: rightX, y: box.y, w: box.w, h: box.h, fill: { color: C.white }, line: { color: C.accent, width: 1 } }); if (payMeta) { const fit = fitImage(payMeta, rightX + 0.05, box.y + 0.05, box.w - 0.1, box.h - 0.1); s.addImage({ path: fit.path, x: fit.x, y: fit.y, w: fit.w, h: fit.h }); } s.addText("Landing /pay-public", { x: rightX, y: box.y + box.h + 0.05, w: box.w, h: 0.3, fontSize: 11, fontFace: "Calibri", color: C.white, bold: true, align: "center", margin: 0 }); s.addText("Bypass Authentik · Stripe Checkout · Magic-link SMS/email", { x: rightX, y: box.y + box.h + 0.32, w: box.w, h: 0.25, fontSize: 9, fontFace: "Calibri", color: C.muted, align: "center", margin: 0 }); // Save const out = path.join(__dirname, "Gigafibre-Billing-Handoff.pptx"); await pres.writeFile({ fileName: out }); console.log("Wrote:", out); } build().catch(e => { console.error(e); process.exit(1); });