All docs moved with git mv so --follow preserves history. Flattens the single-folder layout into goal-oriented folders and adds a README.md index at every level. - docs/README.md — new landing page with "I want to…" intent table - docs/architecture/ — overview, data-model, app-design - docs/features/ — billing-payments, cpe-management, vision-ocr, flow-editor - docs/reference/ — erpnext-item-diff, legacy-wizard/ - docs/archive/ — HANDOFF-2026-04-18, MIGRATION, status-snapshots/ - docs/assets/ — pptx sources, build scripts (fixed hardcoded path) - roadmap.md gains a "Modules in production" section with clickable URLs for every ops/tech/portal route and admin surface - Phase 4 (Customer Portal) flipped to "Largely Shipped" based on audit of services/targo-hub/lib/payments.js (16 endpoints, webhook, PPA cron, Klarna BNPL all live) - Archive files get an "ARCHIVED" banner so stale links inside them don't mislead readers Code comments + nginx configs rewritten to use new doc paths. Root README.md documentation table replaced with intent-oriented index. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
// 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); });
|