gigafibre-fsm/services/targo-hub/lib/acceptance.js
louispaulb 9fda9eb0b0 refactor(targo-hub): add types.js, migrate acceptance+payments, drop apps/field
- lib/types.js: single source of truth for Dispatch Job status + priority enums.
  Eliminates hard-coded 'In Progress'/'in_progress'/'Completed'/'done' checks
  scattered across tech-mobile, acceptance, dispatch. Includes CLIENT_TYPES_JS
  snippet for embedding in SSR <script> blocks (no require() needed).

- lib/tech-mobile.js: applies types.js predicates (isInProgress, isTerminal,
  isDone, isUrgent) both server-side and client-side via ${CLIENT_TYPES_JS}
  template injection. Single aliasing point for future status renames.

- lib/acceptance.js: migrated 7 erpFetch + 2 erpRequest sites to erp.js wrapper.
  Removed duplicate "Lien expiré" HTML (now ui.pageExpired()). Dispatch Job
  creation uses types.JOB_STATUS + types.JOB_PRIORITY.

- lib/payments.js: migrated 15 erpFetch + 9 erpRequest sites to erp.js wrapper.
  Live Stripe flows preserved exactly — frappe.client.submit calls kept as
  erp.raw passthroughs (fetch-full-doc-then-submit pattern intact). Includes
  refund → Return PE → Credit Note lifecycle, PPA cron, idempotency guard.

- apps/field/ deleted: transitional Quasar PWA fully retired in favor of
  SSR tech-mobile at /t/{jwt}. Saves 14k lines of JS, PWA icons, and
  infra config. Docs already marked it "retiring".

Smoke-tested on prod:
  /payments/balance/:customer (200, proper shape)
  /payments/methods/:customer (200, Stripe cards live-fetched)
  /dispatch/calendar/:tech.ics (200, VCALENDAR)
  /t/{jwt} (55KB render, no errors)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:18:25 -04:00

694 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict'
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
const types = require('./types')
const ui = require('./ui')
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) {
return erp.get('Quotation', name)
}
async function acceptQuotation (name, acceptanceData) {
// Add acceptance comment with proof
await erp.create('Comment', {
comment_type: 'Info',
reference_doctype: 'Quotation',
reference_name: name,
content: `<b>✅ Devis accepté par le client</b><br>
<b>Horodatage:</b> ${new Date().toISOString()}<br>
<b>Méthode:</b> ${acceptanceData.method || 'Lien JWT'}<br>
<b>Contact:</b> ${acceptanceData.contact || 'N/A'}<br>
<b>IP:</b> ${acceptanceData.ip || 'N/A'}<br>
<b>User-Agent:</b> ${acceptanceData.userAgent || 'N/A'}<br>
${acceptanceData.docusealUrl ? `<b>Document signé:</b> <a href="${acceptanceData.docusealUrl}">${acceptanceData.docusealUrl}</a>` : ''}`,
})
// Update quotation status (best-effort — ignore failure)
await erp.update('Quotation', name, { 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 erp.update('Quotation', name, { 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 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 || types.JOB_PRIORITY.MEDIUM,
status: dependsOn ? types.JOB_STATUS.ON_HOLD : types.JOB_STATUS.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),
}
const res = await erp.create('Dispatch Job', payload)
if (res.ok && res.data) {
createdJobs.push(res.data)
log(` + Job ${res.data.name}: ${step.subject}`)
} else {
createdJobs.push({ name: ticketId })
log(` ! Job creation failed for: ${step.subject}${res.error || 'unknown'}`)
}
}
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 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 || ''}`,
}
const res = await erp.create('Service Subscription', payload)
if (res.ok && res.name) {
created.push(res.name)
log(` + Service Subscription ${res.name} (En attente) — ${item.item_name}`)
} else {
log(` ! Service Subscription creation failed for ${item.item_name}: ${res.error || 'unknown'}`)
}
}
return created
}
// ── PDF generation via ERPNext ────────────────────────────────────────────────
async function getQuotationPdfBuffer (quotationName, printFormat) {
// PDF comes back as raw bytes, not JSON — go around erpFetch.
const format = printFormat || 'Standard'
const url = `${cfg.ERP_URL}/api/method/frappe.utils.print_format.download_pdf?doctype=Quotation&name=${encodeURIComponent(quotationName)}&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())
}
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 =>
`<tr>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;">${i.item_name || i.item_code}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:center;">${i.qty}</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:right;">${Number(i.rate).toFixed(2)} $</td>
<td style="padding:8px 12px;border-bottom:1px solid #e2e8f0;text-align:right;font-weight:700;">${Number(i.amount || i.qty * i.rate).toFixed(2)} $</td>
</tr>`
).join('')
const total = Number(quotation.grand_total || quotation.total || 0).toFixed(2)
const terms = quotation.terms || ''
return `<!DOCTYPE html>
<html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Devis ${quotation.name} — Gigafibre</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:'Inter',system-ui,sans-serif;background:#f1f5f9;color:#1e293b;min-height:100vh;display:flex;justify-content:center;padding:1.5rem}
.card{background:white;border-radius:16px;max-width:640px;width:100%;box-shadow:0 4px 24px rgba(0,0,0,0.08);overflow:hidden}
.header{background:linear-gradient(135deg,#6366f1,#8b5cf6);color:white;padding:1.5rem 2rem}
.header h1{font-size:1.3rem;font-weight:700}
.header p{font-size:0.82rem;opacity:0.85;margin-top:0.25rem}
.body{padding:1.5rem 2rem}
table{width:100%;border-collapse:collapse;margin:1rem 0;font-size:0.85rem}
th{background:#f8fafc;padding:8px 12px;text-align:left;font-size:0.72rem;text-transform:uppercase;letter-spacing:0.05em;color:#64748b;border-bottom:2px solid #e2e8f0}
.total-row{display:flex;justify-content:space-between;align-items:center;background:#f0fdf4;padding:0.75rem 1rem;border-radius:10px;margin:1rem 0}
.total-row .amount{font-size:1.3rem;font-weight:700;color:#16a34a}
.terms{background:#f8fafc;border:1px solid #e2e8f0;border-radius:8px;padding:1rem;font-size:0.78rem;color:#475569;margin:1rem 0;max-height:200px;overflow-y:auto;white-space:pre-wrap}
.accept-form{text-align:center;padding:1rem 0}
.cb-label{display:flex;align-items:center;justify-content:center;gap:0.5rem;font-size:0.85rem;color:#475569;margin-bottom:1rem;cursor:pointer}
.cb-label input{accent-color:#6366f1;width:18px;height:18px}
.btn-accept{background:#22c55e;color:white;border:none;border-radius:12px;padding:0.85rem 2.5rem;font-size:1rem;font-weight:700;font-family:inherit;cursor:pointer;transition:background 0.15s}
.btn-accept:hover{background:#16a34a}
.btn-accept:disabled{background:#86efac;cursor:not-allowed}
.btn-pdf{display:inline-flex;align-items:center;gap:0.4rem;background:#6366f1;color:white;border:none;border-radius:10px;padding:0.6rem 1.5rem;font-size:0.85rem;font-weight:600;font-family:inherit;cursor:pointer;text-decoration:none;transition:background 0.15s;margin-bottom:1rem}
.btn-pdf:hover{background:#4f46e5}
.accepted-banner{background:#dcfce7;color:#166534;padding:1rem;text-align:center;font-weight:700;font-size:0.92rem;border-radius:10px;margin:1rem 0}
.footer{text-align:center;padding:1rem;font-size:0.72rem;color:#94a3b8}
</style></head><body>
<div class="card">
<div class="header">
<h1>Devis ${quotation.name}</h1>
<p>${quotation.customer_name || quotation.party_name || ''}${new Date().toLocaleDateString('fr-CA')}</p>
</div>
<div class="body">
<table>
<thead><tr><th>Description</th><th style="text-align:center">Qté</th><th style="text-align:right">Prix</th><th style="text-align:right">Total</th></tr></thead>
<tbody>${items}</tbody>
</table>
<div class="total-row">
<span style="font-weight:600;color:#475569">Total</span>
<span class="amount">${total} $</span>
</div>
<div style="text-align:center">
<a class="btn-pdf" href="/accept/pdf/${token}" target="_blank">&#128196; Télécharger le PDF</a>
</div>
${terms ? `<div class="terms"><strong>Conditions :</strong>\n${terms}</div>` : ''}
${accepted
? '<div class="accepted-banner">✅ Ce devis a été accepté. Merci!</div>'
: `<div class="accept-form">
<label class="cb-label"><input type="checkbox" id="cb-accept" onchange="document.getElementById('btn-accept').disabled=!this.checked"> J'ai lu et j'accepte les conditions</label>
<button class="btn-accept" id="btn-accept" disabled onclick="doAccept()">Accepter le devis</button>
<div id="accept-status" style="margin-top:0.75rem;font-size:0.82rem;color:#64748b"></div>
</div>
<script>
async function doAccept(){
const btn=document.getElementById('btn-accept');
const status=document.getElementById('accept-status');
btn.disabled=true;btn.textContent='Traitement...';
try{
const r=await fetch('/accept/confirm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:'${token}'})});
const d=await r.json();
if(d.ok){status.innerHTML='<span style="color:#16a34a;font-weight:700">✅ Devis accepté! Vous recevrez une confirmation.</span>';btn.style.display='none';document.getElementById('cb-accept').parentElement.style.display='none';}
else{status.innerHTML='<span style="color:#dc2626">Erreur: '+(d.error||'Lien invalide')+'</span>';btn.disabled=false;btn.textContent='Accepter le devis';}
}catch(e){status.innerHTML='<span style="color:#dc2626">Erreur réseau</span>';btn.disabled=false;btn.textContent='Accepter le devis';}
}
<\/script>`
}
</div>
<div class="footer">Gigafibre — Targo Télécommunications</div>
</div>
</body></html>`
}
// ── HTTP handler ─────────────────────────────────────────────────────────────
async function handle (req, res, method, path) {
// GET /accept/pdf/:token — Download PDF of the quotation
if (path.startsWith('/accept/pdf/') && method === 'GET') {
const token = path.replace('/accept/pdf/', '')
const payload = verifyJwt(token)
if (!payload || payload.type !== 'acceptance') {
return json(res, 401, { error: 'Token invalide ou expiré' })
}
try {
const buf = await getDocPdfBuffer('Quotation', payload.doc)
if (!buf) return json(res, 404, { error: 'PDF non disponible' })
res.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${payload.doc}.pdf"`,
'Content-Length': buf.length,
})
return res.end(buf)
} catch (e) {
log('PDF download error:', e.message)
return json(res, 500, { error: 'Erreur lors de la génération du PDF' })
}
}
// GET /accept/doc-pdf/:doctype/:name — Download PDF of any document (authenticated)
if (path.startsWith('/accept/doc-pdf/') && method === 'GET') {
const parts = path.replace('/accept/doc-pdf/', '').split('/')
const doctype = decodeURIComponent(parts[0] || '')
const name = decodeURIComponent(parts[1] || '')
const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
const format = url.searchParams.get('format') || 'Standard'
if (!doctype || !name) return json(res, 400, { error: 'doctype and name required' })
try {
const buf = await getDocPdfBuffer(doctype, name, format)
if (!buf) return json(res, 404, { error: 'PDF non disponible' })
res.writeHead(200, {
'Content-Type': 'application/pdf',
'Content-Disposition': `inline; filename="${name}.pdf"`,
'Content-Length': buf.length,
})
return res.end(buf)
} catch (e) {
log('Doc PDF error:', e.message)
return json(res, 500, { error: 'Erreur PDF' })
}
}
// GET /accept/:token — Show acceptance page
if (path.startsWith('/accept/') && method === 'GET' && !path.includes('/confirm')) {
const token = path.replace('/accept/', '')
const payload = verifyJwt(token)
if (!payload || payload.type !== 'acceptance') {
res.writeHead(200, ui.htmlHeaders())
return res.end(ui.pageExpired())
}
try {
const quotation = await fetchQuotation(payload.doc)
if (!quotation) {
return json(res, 404, { error: 'Quotation not found' })
}
const html = renderAcceptancePage(quotation, token)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' })
return res.end(html)
} catch (e) {
log('Acceptance page error:', e.message)
return json(res, 500, { error: 'Server error' })
}
}
// POST /accept/confirm — Record acceptance
if (path === '/accept/confirm' && method === 'POST') {
const body = await parseBody(req)
const payload = verifyJwt(body.token)
if (!payload || payload.type !== 'acceptance') {
return json(res, 401, { error: 'Token invalide ou expiré' })
}
const ip = req.headers['x-forwarded-for'] || req.socket.remoteAddress || ''
const ua = req.headers['user-agent'] || ''
try {
await acceptQuotation(payload.doc, {
method: 'Lien JWT',
contact: payload.sub,
ip,
userAgent: ua,
})
log(`Quotation accepted: ${payload.doc} by ${payload.sub} from ${ip}`)
// Fire flow trigger (on_quotation_accepted). Non-blocking.
try {
require('./flow-runtime').dispatchEvent('on_quotation_accepted', {
doctype: 'Quotation', docname: payload.doc, customer: payload.sub,
variables: { method: 'JWT', ip, user_agent: ua },
}).catch(e => log('flow trigger on_quotation_accepted failed:', e.message))
} catch (e) { log('flow trigger load error:', e.message) }
return json(res, 200, { ok: true, quotation: payload.doc, message: 'Devis accepté. Le projet a été lancé.' })
} catch (e) {
log('Accept confirm error:', e.message)
return json(res, 500, { error: 'Failed to record acceptance' })
}
}
// POST /accept/generate — Generate acceptance link (called from ops app / wizard)
if (path === '/accept/generate' && method === 'POST') {
const body = await parseBody(req)
const { quotation, customer, ttl_hours, send_sms, phone, send_email, email, use_docuseal, docuseal_template_id } = body
if (!quotation || !customer) {
return json(res, 400, { error: 'quotation and customer required' })
}
const result = { ok: true }
// Mode 1: DocuSeal e-signature
if (use_docuseal && DOCUSEAL_URL && DOCUSEAL_KEY) {
const acceptLink = generateAcceptanceLink(quotation, customer, ttl_hours || 168)
const dsResult = await createDocuSealSubmission({
templateId: docuseal_template_id || cfg.DOCUSEAL_DEFAULT_TEMPLATE_ID || 1,
email: email || '',
name: customer,
phone: phone || '',
values: { 'Nom': customer, 'Customer': customer, 'Quotation': quotation },
completedRedirectUrl: acceptLink + '?signed=1',
})
if (dsResult) {
result.method = 'docuseal'
result.sign_url = dsResult.signUrl
result.submission_id = dsResult.submissionId
// Persist signing URL on the Quotation so the print-format QR code is populated
const up = await erp.update('Quotation', quotation, { custom_docuseal_signing_url: dsResult.signUrl })
if (!up.ok) log('Failed to save DocuSeal signing URL to Quotation:', up.error)
} else {
// Fallback to JWT if DocuSeal fails
result.method = 'jwt'
result.link = acceptLink
}
} else {
// Mode 2: Simple JWT acceptance
result.method = 'jwt'
result.link = generateAcceptanceLink(quotation, customer, ttl_hours || 168)
}
// Send SMS if requested
if (send_sms && phone) {
const linkToSend = result.sign_url || result.link
const msg = `Gigafibre — Votre devis ${quotation} est prêt pour acceptation.\n\n📋 Voir le devis: ${linkToSend}\n\nCe lien expire dans ${Math.round((ttl_hours || 168) / 24)} jours.`
try {
const { sendSmsInternal } = require('./twilio')
await sendSmsInternal(phone, msg)
result.sms_sent = true
log(`Acceptance link sent via SMS to ${phone} for ${quotation}`)
} catch (e) {
result.sms_sent = false
log('SMS send failed:', e.message)
}
}
// Send email if requested — via nodemailer with PDF attachment
if (send_email && email) {
try {
const { sendQuotationEmail } = require('./email')
const linkToSend = result.sign_url || result.link
// Fetch PDF to attach
let pdfBuf = null
try { pdfBuf = await getDocPdfBuffer('Quotation', quotation) } catch {}
const sent = await sendQuotationEmail({
to: email,
quotationName: quotation,
acceptLink: linkToSend,
pdfBuffer: pdfBuf,
})
result.email_sent = sent
if (sent) log(`Acceptance email sent to ${email} for ${quotation}`)
} catch (e) {
result.email_sent = false
log('Email send failed:', e.message)
}
}
return json(res, 200, result)
}
// POST /accept/send — Send acceptance link via email or SMS (from success screen)
if (path === '/accept/send' && method === 'POST') {
const body = await parseBody(req)
const { quotation, customer, channel, to } = body
// channel: 'email' | 'sms'
if (!quotation || !to || !channel) {
return json(res, 400, { error: 'quotation, channel and to required' })
}
const result = { ok: true }
const link = generateAcceptanceLink(quotation, customer || '', 168)
result.link = link
if (channel === 'sms') {
const msg = `Gigafibre — Votre devis ${quotation} est prêt.\n\nConsultez-le ici: ${link}\n\nValide 7 jours.`
try {
const { sendSmsInternal } = require('./twilio')
await sendSmsInternal(to, msg)
result.sent = true
log(`Acceptance SMS sent to ${to} for ${quotation}`)
} catch (e) {
result.sent = false
result.error = e.message
log('SMS send failed:', e.message)
}
} else if (channel === 'email') {
try {
const { sendQuotationEmail } = require('./email')
let pdfBuf = null
try { pdfBuf = await getDocPdfBuffer('Quotation', quotation) } catch {}
const sent = await sendQuotationEmail({
to,
quotationName: quotation,
acceptLink: link,
pdfBuffer: pdfBuf,
})
result.sent = sent
if (sent) log(`Acceptance email sent to ${to} for ${quotation}`)
} catch (e) {
result.sent = false
result.error = e.message
log('Email send failed:', e.message)
}
}
return json(res, 200, result)
}
// POST /accept/docuseal-webhook — DocuSeal completion webhook
if (path === '/accept/docuseal-webhook' && method === 'POST') {
const body = await parseBody(req)
log('DocuSeal webhook:', body.event_type, body.data?.external_id || '')
if (body.event_type === 'form.completed') {
const submitter = body.data
const quotationName = submitter?.values?.find(v => v.field === 'Quotation')?.value
const customer = submitter?.values?.find(v => v.field === 'Customer')?.value
const signedDocUrl = submitter?.documents?.[0]?.url || ''
if (quotationName) {
await acceptQuotation(quotationName, {
method: 'DocuSeal e-signature',
contact: submitter.email || submitter.phone || customer || '',
ip: req.headers['x-forwarded-for'] || '',
userAgent: 'DocuSeal Webhook',
docusealUrl: signedDocUrl,
})
log(`Quotation ${quotationName} accepted via DocuSeal signature`)
}
}
return json(res, 200, { ok: true })
}
return json(res, 404, { error: 'Acceptance endpoint not found' })
}
module.exports = {
handle,
generateAcceptanceToken,
generateAcceptanceLink,
createDocuSealSubmission,
// Exposed so other modules (contracts.js) can build the same chained
// Dispatch Job structure without duplicating the On-Hold / depends_on logic.
createDeferredJobs,
}