'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 : '' const payload = { ticket_id: ticketId, subject: step.subject || 'Tâche', address: ctx.address || '', duration_h: step.duration_h || 1, priority: step.priority || 'medium', status: 'open', job_type: step.job_type || 'Autre', source_issue: ctx.issue || '', customer: ctx.customer || '', service_location: ctx.service_location || '', order_source: 'Quotation', 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}` : '', `Quotation: ${quotationName}`, 'Créé automatiquement après acceptation client', ].filter(Boolean).join(' | '), scheduled_date: step.scheduled_date || '', } 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 subscriptions for recurring items on accepted quotation ─────────── async function createDeferredSubscriptions (quotation, ctx) { const { erpFetch } = require('./helpers') const customer = ctx.customer || quotation.customer || quotation.party_name || '' if (!customer) return const recurringItems = (quotation.items || []).filter(i => (i.description || '').includes('$/mois') ) if (!recurringItems.length) return const today = new Date().toISOString().split('T')[0] 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 rate = rateMatch ? parseFloat(rateMatch[1]) : item.rate try { // Create or find Subscription Plan let planName = null const planPayload = { plan_name: item.item_name || item.item_code, item: item.item_code || item.item_name, currency: 'CAD', price_determination: 'Fixed Rate', cost: rate, billing_interval: 'Month', billing_interval_count: 1, } const planRes = await erpFetch('/api/resource/Subscription%20Plan', { method: 'POST', body: JSON.stringify(planPayload), }) if (planRes.status === 200 && planRes.data?.data) { planName = planRes.data.data.name } else { // Try to find existing plan const findRes = await erpFetch(`/api/resource/Subscription%20Plan/${encodeURIComponent(item.item_code || item.item_name)}`) if (findRes.status === 200) planName = findRes.data?.data?.name } await erpFetch('/api/resource/Subscription', { method: 'POST', body: JSON.stringify({ party_type: 'Customer', party: customer, company: 'TARGO', status: 'Active', start_date: today, generate_invoice_at: 'Beginning of the current subscription period', days_until_due: 30, follow_calendar_months: 1, plans: planName ? [{ plan: planName, qty: item.qty || 1 }] : [], }), }) log(` + Subscription created for ${item.item_name}`) } catch (e) { log(` ! Subscription creation failed for ${item.item_name}: ${e.message}`) } } } // ── 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!
' : `