'use strict' const cfg = require('./config') const { log, json, parseBody } = require('./helpers') const { signJwt, verifyJwt } = require('./magic-link') // ── Acceptance Links ───────────────────────────────────────────────────────── // Two modes: // 1. Simple JWT acceptance (default) — customer clicks link, sees summary, clicks "J'accepte" // 2. DocuSeal signature (when contract attached) — redirects to DocuSeal for e-signature const DOCUSEAL_URL = cfg.DOCUSEAL_URL || '' // e.g. https://sign.gigafibre.ca const DOCUSEAL_KEY = cfg.DOCUSEAL_API_KEY || '' // API key from DocuSeal settings // ── Generate acceptance token ──────────────────────────────────────────────── function generateAcceptanceToken (quotationName, customer, ttlHours = 168) { const payload = { sub: customer, doc: quotationName, type: 'acceptance', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + ttlHours * 3600, } return signJwt(payload) } function generateAcceptanceLink (quotationName, customer, ttlHours = 168) { const token = generateAcceptanceToken(quotationName, customer, ttlHours) return `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/accept/${token}` } // ── DocuSeal integration ───────────────────────────────────────────────────── async function createDocuSealSubmission (opts) { if (!DOCUSEAL_URL || !DOCUSEAL_KEY) return null const { templateId, email, name, phone, values, completedRedirectUrl } = opts try { const res = await fetch(`${DOCUSEAL_URL}/api/submissions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Auth-Token': DOCUSEAL_KEY, }, body: JSON.stringify({ template_id: templateId, send_email: !!email, send_sms: false, completed_redirect_url: completedRedirectUrl || '', submitters: [{ role: 'Première partie', email: email || '', name: name || '', phone: phone || '', values: values || {}, }], }), }) if (!res.ok) { const err = await res.text() log(`DocuSeal submission failed: ${res.status} ${err}`) return null } const data = await res.json() // Response is array of submitters const submitter = Array.isArray(data) ? data[0] : data return { submissionId: submitter.submission_id, submitterId: submitter.id, signUrl: submitter.embed_src || `${DOCUSEAL_URL}/s/${submitter.slug}`, status: submitter.status, } } catch (e) { log('DocuSeal error:', e.message) return null } } async function checkDocuSealStatus (submissionId) { if (!DOCUSEAL_URL || !DOCUSEAL_KEY) return null try { const res = await fetch(`${DOCUSEAL_URL}/api/submissions/${submissionId}`, { headers: { 'X-Auth-Token': DOCUSEAL_KEY }, }) if (!res.ok) return null return await res.json() } catch { return null } } // ── ERPNext helpers ────────────────────────────────────────────────────────── async function fetchQuotation (name) { const { erpFetch } = require('./helpers') const res = await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`) if (res.status !== 200) return null return res.data.data } async function acceptQuotation (name, acceptanceData) { const { erpFetch } = require('./helpers') // Add acceptance comment with proof await erpFetch(`/api/resource/Comment`, { method: 'POST', body: JSON.stringify({ comment_type: 'Info', reference_doctype: 'Quotation', reference_name: name, content: `✅ Devis accepté par le client
Horodatage: ${new Date().toISOString()}
Méthode: ${acceptanceData.method || 'Lien JWT'}
Contact: ${acceptanceData.contact || 'N/A'}
IP: ${acceptanceData.ip || 'N/A'}
User-Agent: ${acceptanceData.userAgent || 'N/A'}
${acceptanceData.docusealUrl ? `Document signé: ${acceptanceData.docusealUrl}` : ''}`, }), }) // Update quotation status try { await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ accepted_by_client: 1 }), }) } catch {} // ── Create deferred dispatch jobs if wizard_steps exist ── try { const quotation = await fetchQuotation(name) if (quotation && quotation.wizard_steps) { const steps = JSON.parse(quotation.wizard_steps) const ctx = quotation.wizard_context ? JSON.parse(quotation.wizard_context) : {} if (Array.isArray(steps) && steps.length) { const createdJobs = await createDeferredJobs(steps, ctx, name) log(`Created ${createdJobs.length} deferred jobs for ${name} upon acceptance`) // Clear wizard_steps so they don't get created again await erpFetch(`/api/resource/Quotation/${encodeURIComponent(name)}`, { method: 'PUT', body: JSON.stringify({ wizard_steps: '', wizard_context: '' }), }) // Also create subscriptions for recurring items on the quotation await createDeferredSubscriptions(quotation, ctx) } } } catch (e) { log('Deferred job creation on acceptance failed:', e.message) } } // ── Create dispatch jobs from wizard steps stored on quotation ─────────────── async function createDeferredJobs (steps, ctx, quotationName) { const { erpFetch } = require('./helpers') const createdJobs = [] for (let i = 0; i < steps.length; i++) { const step = steps[i] const ticketId = 'DJ-' + Date.now().toString(36).toUpperCase() + '-' + i let dependsOn = '' if (step.depends_on_step !== null && step.depends_on_step !== undefined) { const depJob = createdJobs[step.depends_on_step] if (depJob) dependsOn = depJob.name } const parentJob = i > 0 && createdJobs[0] ? createdJobs[0].name : '' // Chain gating: only the root job (no depends_on) is born "open". // Dependents wait in "On Hold" — dispatch.unblockDependents() flips them // to "open" when their parent completes. This keeps the tech's active // list uncluttered (only currently-actionable work shows up). const payload = { ticket_id: ticketId, subject: step.subject || 'Tâche', address: ctx.address || '', duration_h: step.duration_h || 1, priority: step.priority || 'medium', status: dependsOn ? 'On Hold' : 'open', job_type: step.job_type || 'Autre', source_issue: ctx.issue || '', customer: ctx.customer || '', service_location: ctx.service_location || '', order_source: ctx.order_source || 'Quotation', // assigned_group drives group-based subscription in the Tech PWA // (techs see "Tâches du groupe" matching their assigned_group and can // self-assign). Was previously only in notes which isn't queryable. assigned_group: step.assigned_group || '', depends_on: dependsOn, parent_job: parentJob, step_order: step.step_order || (i + 1), on_open_webhook: step.on_open_webhook || '', on_close_webhook: step.on_close_webhook || '', notes: [ step.assigned_group ? `Groupe: ${step.assigned_group}` : '', `Source: ${quotationName}`, 'Créé automatiquement après acceptation client', ].filter(Boolean).join(' | '), // scheduled_date precedence: explicit step date > ctx default > today. // We default to today because ERPNext list views + dispatcher boards // commonly filter "scheduled_date >= today", and null dates make jobs // disappear from the dispatch queue (user-visible symptom: "Aucune // job disponible pour dispatch"). Dispatcher can always reschedule. scheduled_date: step.scheduled_date || ctx.scheduled_date || new Date().toISOString().slice(0, 10), } try { const res = await erpFetch('/api/resource/Dispatch%20Job', { method: 'POST', body: JSON.stringify(payload), }) if (res.status === 200 && res.data?.data) { createdJobs.push(res.data.data) log(` + Job ${res.data.data.name}: ${step.subject}`) } else { createdJobs.push({ name: ticketId }) log(` ! Job creation returned ${res.status} for: ${step.subject}`) } } catch (e) { createdJobs.push({ name: ticketId }) log(` ! Job creation failed for: ${step.subject} — ${e.message}`) } } return createdJobs } // ── Create Service Subscriptions for recurring items on accepted quotation ── // // Writes the *custom* Service Subscription doctype (not Frappe's built-in // Subscription). Rows are born with status='En attente' and start_date=today // as a placeholder — dispatch.activateSubscriptionForJob() flips status to // 'Actif' and rewrites start_date to the real activation day when the final // job in the install chain completes. // // Linkage to the chain is implicit: (customer, service_location, status='En // attente') uniquely identifies a pending subscription. On terminal job // completion we look it up and activate + prorate. function _guessServiceCategory (item) { const name = `${item.item_name || ''} ${item.item_code || ''} ${item.description || ''}`.toLowerCase() if (/iptv|tv|t[ée]l[ée]|cha[îi]ne/.test(name)) return 'IPTV' if (/voip|t[ée]l[ée]phon|ligne|pbx/.test(name)) return 'VoIP' if (/bundle|combo|forfait.*combin|duo|trio/.test(name)) return 'Bundle' if (/h[ée]bergement|hosting|cloud|email/.test(name)) return 'Hébergement' if (/internet|fibre|fiber|dsl|\bmbps\b|\bgbps\b/.test(name)) return 'Internet' return 'Autre' } function _extractSpeeds (item) { const src = `${item.item_name || ''} ${item.description || ''}` const both = src.match(/(\d+)\s*\/\s*(\d+)\s*mbps/i) if (both) return { down: parseInt(both[1], 10), up: parseInt(both[2], 10) } const down = src.match(/(\d+)\s*mbps/i) if (down) return { down: parseInt(down[1], 10), up: null } return { down: null, up: null } } function _extractDurationMonths (item) { const m = (item.description || '').match(/×\s*(\d+)\s*mois/i) || (item.description || '').match(/(\d+)\s*mois/i) return m ? parseInt(m[1], 10) : null } async function createDeferredSubscriptions (quotation, ctx) { const { erpFetch } = require('./helpers') const customer = ctx.customer || quotation.customer || quotation.party_name || '' const serviceLocation = ctx.service_location || '' if (!customer) return [] if (!serviceLocation) { log(' ! createDeferredSubscriptions: no service_location in ctx — skipping') return [] } const recurringItems = (quotation.items || []).filter(i => (i.description || '').includes('$/mois') ) if (!recurringItems.length) return [] const today = new Date().toISOString().split('T')[0] const created = [] for (const item of recurringItems) { // Extract monthly rate from description like "Service — 49.99$/mois × 12 mois" const rateMatch = (item.description || '').match(/([\d.]+)\$\/mois/) const monthlyPrice = rateMatch ? parseFloat(rateMatch[1]) : Number(item.rate) || 0 const speeds = _extractSpeeds(item) const durationMonths = _extractDurationMonths(item) const payload = { customer, service_location: serviceLocation, status: 'En attente', // waits for final job completion service_category: _guessServiceCategory(item), plan_name: item.item_name || item.item_code || 'Abonnement', speed_down: speeds.down || 0, speed_up: speeds.up || 0, monthly_price: monthlyPrice, billing_cycle: 'Mensuel', contract_duration: durationMonths || 0, // start_date is required by the doctype — use today as a placeholder. // Real activation date is rewritten by activateSubscriptionForJob(). start_date: today, notes: `Créé automatiquement via acceptation du devis ${quotation.name || ''}`, } try { const res = await erpFetch('/api/resource/Service%20Subscription', { method: 'POST', body: JSON.stringify(payload), }) if (res.status === 200 && res.data?.data) { created.push(res.data.data.name) log(` + Service Subscription ${res.data.data.name} (En attente) — ${item.item_name}`) } else { log(` ! Service Subscription creation returned ${res.status} for ${item.item_name}`) } } catch (e) { log(` ! Service Subscription creation failed for ${item.item_name}: ${e.message}`) } } return created } // ── PDF generation via ERPNext ──────────────────────────────────────────────── async function getQuotationPdfBuffer (quotationName, printFormat) { const { erpFetch } = require('./helpers') const format = printFormat || 'Standard' const url = `/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&format=${encodeURIComponent(format)}&no_letterhead=0` const res = await erpFetch(url, { rawResponse: true }) if (res.status !== 200) return null // erpFetch returns parsed JSON by default; we need the raw buffer // Use direct fetch instead const directUrl = `${cfg.ERP_URL}${url}` const pdfRes = await fetch(directUrl, { headers: { 'Authorization': `token ${cfg.ERP_TOKEN}`, 'X-Frappe-Site-Name': cfg.ERP_SITE, }, }) if (!pdfRes.ok) return null const buf = Buffer.from(await pdfRes.arrayBuffer()) return buf } async function getDocPdfBuffer (doctype, name, printFormat) { const format = printFormat || 'Standard' const url = `${cfg.ERP_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=${encodeURIComponent(doctype)}&name=${encodeURIComponent(name)}&format=${encodeURIComponent(format)}&no_letterhead=0` const pdfRes = await fetch(url, { headers: { 'Authorization': `token ${cfg.ERP_TOKEN}`, 'X-Frappe-Site-Name': cfg.ERP_SITE, }, }) if (!pdfRes.ok) return null return Buffer.from(await pdfRes.arrayBuffer()) } // ── Acceptance page HTML ───────────────────────────────────────────────────── function renderAcceptancePage (quotation, token, accepted = false) { const items = (quotation.items || []).map(i => ` ${i.item_name || i.item_code} ${i.qty} ${Number(i.rate).toFixed(2)} $ ${Number(i.amount || i.qty * i.rate).toFixed(2)} $ ` ).join('') const total = Number(quotation.grand_total || quotation.total || 0).toFixed(2) const terms = quotation.terms || '' return ` Devis ${quotation.name} — Gigafibre

Devis ${quotation.name}

${quotation.customer_name || quotation.party_name || ''} — ${new Date().toLocaleDateString('fr-CA')}

${items}
DescriptionQtéPrixTotal
Total ${total} $
📄 Télécharger le PDF
${terms ? `
Conditions :\n${terms}
` : ''} ${accepted ? '
✅ Ce devis a été accepté. Merci!
' : `