'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: 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 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 =>
`
${quotation.customer_name || quotation.party_name || ''} — ${new Date().toLocaleDateString('fr-CA')}
| Description | Qté | Prix | Total |
|---|