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