gigafibre-fsm/services/targo-hub/lib/contracts.js
louispaulb 607ea54b5c refactor: reduce token count, DRY code, consolidate docs
Backend services:
- targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons
  lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas,
  extract dispatch scoring weights, trim section dividers across 9 files
- modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(),
  consolidate DM query factory, fix duplicate username fill bug, trim headers
  (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%)

Frontend:
- useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into
  6 focused helpers (processOnlineStatus, processWanIPs, processRadios,
  processMeshNodes, processClients, checkRadioIssues)
- EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments

Documentation (17 → 13 files, -1,400 lines):
- New consolidated README.md (architecture, services, dependencies, auth)
- Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md
- Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md
- Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md
- Update ROADMAP.md with current phase status
- Delete CONTEXT.md (absorbed into README)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-13 08:39:58 -04:00

549 lines
21 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, erpFetch } = require('./helpers')
const { signJwt, verifyJwt } = require('./magic-link')
// Résidentiel:
// - Pénalité = valeur résiduelle des avantages non compensés
// - Chaque mois d'abonnement "reconnaît" (compense) benefit_value / duration_months
// - Ex: Installation 288$ gratuite sur 24 mois → 12$/mois compensé
// - Résiliation au mois 18 → 6 × 12$ = 72$ de pénalité
//
// Commercial:
// - Pénalité = toutes les mensualités restantes au contrat
// - Ex: 79.95$/mois × 6 mois restants = 479.70$
function calculateTerminationFee (contract) {
const now = new Date()
const start = new Date(contract.start_date)
const monthsElapsed = Math.max(0, monthsBetween(start, now))
const monthsRemaining = Math.max(0, (contract.duration_months || 24) - monthsElapsed)
const benefits = (contract.benefits || []).map(b => {
const benefitValue = (b.regular_price || 0) - (b.granted_price || 0)
const monthlyRecognition = benefitValue / (contract.duration_months || 24)
const recognized = monthlyRecognition * monthsElapsed
const remaining = Math.max(0, benefitValue - recognized)
return {
description: b.description,
regular_price: b.regular_price,
granted_price: b.granted_price,
benefit_value: round2(benefitValue),
monthly_recognition: round2(monthlyRecognition),
months_recognized: monthsElapsed,
remaining_value: round2(remaining),
}
})
const totalBenefitRemaining = round2(benefits.reduce((s, b) => s + b.remaining_value, 0))
const monthlyRate = contract.monthly_rate || 0
let fee = 0
let feeBreakdown = {}
if (contract.contract_type === 'Commercial') {
// Commercial: all remaining monthly payments
fee = round2(monthsRemaining * monthlyRate)
feeBreakdown = {
type: 'commercial',
months_remaining: monthsRemaining,
monthly_rate: monthlyRate,
termination_fee_remaining: fee,
termination_fee_benefits: 0,
termination_fee_total: fee,
}
} else {
// Résidentiel: only benefit residual value (+ current month max)
fee = totalBenefitRemaining
feeBreakdown = {
type: 'residential',
months_remaining: monthsRemaining,
monthly_rate: monthlyRate,
termination_fee_benefits: totalBenefitRemaining,
termination_fee_remaining: 0,
termination_fee_total: totalBenefitRemaining,
}
}
return {
months_elapsed: monthsElapsed,
months_remaining: monthsRemaining,
benefits,
total_benefit_remaining: totalBenefitRemaining,
...feeBreakdown,
}
}
function generateInvoiceNote (contract) {
const benefits = contract.benefits || []
const lines = []
for (const b of benefits) {
const benefitValue = (b.regular_price || 0) - (b.granted_price || 0)
if (benefitValue <= 0) continue
const monthly = round2(benefitValue / (contract.duration_months || 24))
lines.push(
`${b.description} ${b.granted_price || 0}$ sur entente de ${contract.duration_months || 24} mois. ` +
`(Prix régulier ${b.regular_price}$) Chaque mois d'abonnement reconnaît ${monthly}$ de compensé.`
)
}
return lines.join('\n')
}
function generateContractToken (contractName, customer, phone, ttlHours = 168) {
return signJwt({
sub: customer,
doc: contractName,
type: 'contract_accept',
phone: phone || '',
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + ttlHours * 3600,
})
}
function generateContractLink (contractName, customer, phone) {
const token = generateContractToken(contractName, customer, phone)
return `${cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'}/contract/accept/${token}`
}
async function handle (req, res, method, path) {
// GET /contract/list?customer=CUST-001
if (path === '/contract/list' && method === 'GET') {
const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
const customer = url.searchParams.get('customer')
const filters = customer ? [['customer', '=', customer]] : []
filters.push(['status', '!=', 'Brouillon'])
const params = new URLSearchParams({
fields: JSON.stringify(['name', 'customer', 'customer_name', 'contract_type', 'status',
'start_date', 'end_date', 'duration_months', 'monthly_rate', 'total_benefit_value',
'total_remaining_value', 'months_elapsed', 'months_remaining']),
filters: JSON.stringify(filters),
limit_page_length: 200,
order_by: 'start_date desc',
})
const r = await erpFetch('/api/resource/Service%20Contract?' + params)
return json(res, 200, { contracts: r.status === 200 ? (r.data?.data || []) : [] })
}
// GET /contract/detail/CTR-00001
if (path.startsWith('/contract/detail/') && method === 'GET') {
const name = decodeURIComponent(path.replace('/contract/detail/', ''))
const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(name)}`)
if (r.status !== 200) return json(res, 404, { error: 'Contract not found' })
return json(res, 200, { contract: r.data.data })
}
// POST /contract/create — create a new contract
if (path === '/contract/create' && method === 'POST') {
const body = await parseBody(req)
if (!body.customer || !body.duration_months) {
return json(res, 400, { error: 'customer and duration_months required' })
}
// Build benefits with calculated fields
const duration = body.duration_months || 24
const benefits = (body.benefits || []).map(b => ({
description: b.description || '',
regular_price: b.regular_price || 0,
granted_price: b.granted_price || 0,
benefit_value: round2((b.regular_price || 0) - (b.granted_price || 0)),
monthly_recognition: round2(((b.regular_price || 0) - (b.granted_price || 0)) / duration),
months_recognized: 0,
remaining_value: round2((b.regular_price || 0) - (b.granted_price || 0)),
}))
const totalBenefitValue = round2(benefits.reduce((s, b) => s + b.benefit_value, 0))
const startDate = body.start_date || new Date().toISOString().slice(0, 10)
const endDate = addMonths(startDate, duration)
// Generate invoice note
const invoiceNote = generateInvoiceNote({
benefits: body.benefits || [],
duration_months: duration,
})
const payload = {
customer: body.customer,
contract_type: body.contract_type || 'Résidentiel',
status: 'Brouillon',
start_date: startDate,
end_date: endDate,
duration_months: duration,
monthly_rate: body.monthly_rate || 0,
service_location: body.service_location || '',
quotation: body.quotation || '',
subscription: body.subscription || '',
benefits,
total_benefit_value: totalBenefitValue,
total_remaining_value: totalBenefitValue,
months_elapsed: 0,
months_remaining: duration,
invoice_note: invoiceNote,
acceptance_method: body.acceptance_method || '',
docuseal_template_id: body.docuseal_template_id || 0,
}
const r = await erpFetch('/api/resource/Service%20Contract', {
method: 'POST',
body: JSON.stringify(payload),
})
if (r.status !== 200) {
return json(res, 500, { error: 'Failed to create contract', detail: r.data })
}
log(`Contract created: ${r.data.data.name} for ${body.customer}`)
return json(res, 200, { ok: true, contract: r.data.data })
}
// POST /contract/calculate-termination — preview termination fee without saving
if (path === '/contract/calculate-termination' && method === 'POST') {
const body = await parseBody(req)
if (!body.name) return json(res, 400, { error: 'contract name required' })
const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`)
if (r.status !== 200) return json(res, 404, { error: 'Contract not found' })
const contract = r.data.data
const calc = calculateTerminationFee(contract)
return json(res, 200, { contract_name: body.name, ...calc })
}
// POST /contract/terminate — execute termination + create invoice
if (path === '/contract/terminate' && method === 'POST') {
const body = await parseBody(req)
if (!body.name) return json(res, 400, { error: 'contract name required' })
const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`)
if (r.status !== 200) return json(res, 404, { error: 'Contract not found' })
const contract = r.data.data
if (contract.status === 'Résilié' || contract.status === 'Complété') {
return json(res, 400, { error: 'Contract already terminated' })
}
const calc = calculateTerminationFee(contract)
const today = new Date().toISOString().slice(0, 10)
// Update contract with termination data
const updateData = {
status: 'Résilié',
terminated_at: today,
termination_reason: body.reason || '',
termination_fee_benefits: calc.termination_fee_benefits,
termination_fee_remaining: calc.termination_fee_remaining,
termination_fee_total: calc.termination_fee_total,
months_elapsed: calc.months_elapsed,
months_remaining: calc.months_remaining,
}
// Update benefits rows with current recognition
if (contract.benefits && contract.benefits.length) {
updateData.benefits = calc.benefits.map((b, i) => ({
...contract.benefits[i],
months_recognized: b.months_recognized,
remaining_value: b.remaining_value,
benefit_value: b.benefit_value,
monthly_recognition: b.monthly_recognition,
}))
updateData.total_remaining_value = calc.total_benefit_remaining
}
await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`, {
method: 'PUT',
body: JSON.stringify(updateData),
})
// Create termination invoice if fee > 0
let invoiceName = null
if (calc.termination_fee_total > 0 && body.create_invoice !== false) {
invoiceName = await createTerminationInvoice(contract, calc, body.reason)
if (invoiceName) {
await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`, {
method: 'PUT',
body: JSON.stringify({ termination_invoice: invoiceName }),
})
}
}
log(`Contract ${body.name} terminated. Fee: ${calc.termination_fee_total}$. Invoice: ${invoiceName || 'none'}`)
return json(res, 200, {
ok: true,
...calc,
termination_invoice: invoiceName,
})
}
// POST /contract/send — send contract for acceptance (DocuSeal or JWT SMS)
if (path === '/contract/send' && method === 'POST') {
const body = await parseBody(req)
if (!body.name) return json(res, 400, { error: 'contract name required' })
const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`)
if (r.status !== 200) return json(res, 404, { error: 'Contract not found' })
const contract = r.data.data
const phone = body.phone
const email = body.email
let result = {}
// Try DocuSeal first if configured
if (body.use_docuseal && cfg.DOCUSEAL_URL && cfg.DOCUSEAL_API_KEY) {
const { createDocuSealSubmission } = require('./acceptance')
const acceptLink = generateContractLink(body.name, contract.customer, phone)
const ds = await createDocuSealSubmission({
templateId: contract.docuseal_template_id || body.docuseal_template_id || 1,
email: email || '',
name: contract.customer_name || contract.customer,
phone: phone || '',
values: {
'Nom': contract.customer_name || '',
'Contrat': body.name,
'Mensualité': `${contract.monthly_rate}$`,
'Durée': `${contract.duration_months} mois`,
'Avantages': (contract.benefits || []).map(b => b.description).join(', '),
},
completedRedirectUrl: acceptLink + '?signed=1',
})
if (ds) {
await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`, {
method: 'PUT',
body: JSON.stringify({
status: 'Envoyé',
acceptance_method: 'DocuSeal',
docuseal_submission_id: ds.submissionId,
}),
})
result = { method: 'docuseal', sign_url: ds.signUrl, submission_id: ds.submissionId }
}
}
// Fallback: JWT SMS acceptance
if (!result.method && phone) {
const link = generateContractLink(body.name, contract.customer, phone)
const { sendSmsInternal } = require('./twilio')
const note = (contract.benefits || []).map(b =>
`${b.description}: ${b.granted_price || 0}$ (rég. ${b.regular_price}$)`
).join('\n')
const msg = `Gigafibre — Entente de service\n` +
`${contract.monthly_rate}$/mois × ${contract.duration_months} mois\n` +
(note ? note + '\n' : '') +
`Accepter: ${link}`
const sid = await sendSmsInternal(phone, msg)
await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(body.name)}`, {
method: 'PUT',
body: JSON.stringify({ status: 'Envoyé', acceptance_method: 'JWT SMS' }),
})
result = { method: 'sms', sent_to: phone, sms_sid: sid, accept_link: link }
}
if (!result.method) {
return json(res, 400, { error: 'No phone or DocuSeal configured for sending' })
}
log(`Contract ${body.name} sent via ${result.method}`)
return json(res, 200, { ok: true, ...result })
}
// GET /contract/accept/:token — accept contract via JWT
if (path.startsWith('/contract/accept/') && method === 'GET') {
const token = path.replace('/contract/accept/', '')
const payload = verifyJwt(token)
if (!payload || payload.type !== 'contract_accept') {
return json(res, 401, { error: 'Lien expiré ou invalide.' })
}
// Render simple acceptance page
const contractName = payload.doc
const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`)
if (r.status !== 200) return json(res, 404, { error: 'Contrat introuvable' })
const contract = r.data.data
const html = renderAcceptancePage(contract, token)
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
return res.end(html)
}
// POST /contract/confirm — confirm acceptance
if (path === '/contract/confirm' && method === 'POST') {
const body = await parseBody(req)
if (!body.token) return json(res, 400, { error: 'token required' })
const payload = verifyJwt(body.token)
if (!payload || payload.type !== 'contract_accept') {
return json(res, 401, { error: 'Lien expiré' })
}
const contractName = payload.doc
const ip = req.headers['x-forwarded-for'] || req.socket?.remoteAddress || ''
const ua = req.headers['user-agent'] || ''
const now = new Date().toISOString()
await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`, {
method: 'PUT',
body: JSON.stringify({
status: 'Actif',
signed_at: now,
signed_by: payload.sub,
signature_proof: `IP: ${ip}\nUser-Agent: ${ua}\nDate: ${now}\nPhone: ${payload.phone || ''}`,
}),
})
log(`Contract ${contractName} accepted by ${payload.sub}`)
return json(res, 200, { ok: true, contract: contractName })
}
return json(res, 404, { error: 'Contract endpoint not found' })
}
async function createTerminationInvoice (contract, calc, reason) {
const items = []
const today = new Date().toISOString().slice(0, 10)
if (contract.contract_type === 'Commercial') {
items.push({
item_name: 'Frais de résiliation anticipée',
description: `Résiliation anticipée du contrat ${contract.name}. ` +
`${calc.months_remaining} mois restants × ${contract.monthly_rate}$/mois.` +
(reason ? ` Raison: ${reason}` : ''),
qty: calc.months_remaining,
rate: contract.monthly_rate,
income_account: '', // will use default
})
} else {
// Residential: charge benefit residual
for (const b of calc.benefits) {
if (b.remaining_value <= 0) continue
items.push({
item_name: `Résiliation — ${b.description}`,
description: `Valeur résiduelle: ${b.benefit_value}$ - ${b.months_recognized} mois reconnus ` +
`(${b.monthly_recognition}$/mois). Restant: ${b.remaining_value}$`,
qty: 1,
rate: b.remaining_value,
})
}
}
if (!items.length) return null
const payload = {
customer: contract.customer,
posting_date: today,
due_date: today,
items,
remarks: `Facture de résiliation anticipée — Contrat ${contract.name}`,
}
try {
const r = await erpFetch('/api/resource/Sales%20Invoice', {
method: 'POST',
body: JSON.stringify(payload),
})
if (r.status === 200 && r.data?.data) {
return r.data.data.name
}
} catch (e) {
log('Termination invoice creation failed:', e.message)
}
return null
}
function renderAcceptancePage (contract, token) {
const benefits = (contract.benefits || []).map(b => {
const val = (b.regular_price || 0) - (b.granted_price || 0)
const monthly = round2(val / (contract.duration_months || 24))
return `<tr>
<td>${b.description}</td>
<td style="text-align:right">${b.regular_price || 0}$</td>
<td style="text-align:right">${b.granted_price || 0}$</td>
<td style="text-align:right">${monthly}$/mois compensé</td>
</tr>`
}).join('')
return `<!DOCTYPE html><html lang="fr"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Entente de service — Gigafibre</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:-apple-system,system-ui,sans-serif;background:#f8fafc;color:#1e293b;padding:16px}
.card{background:#fff;border-radius:12px;padding:24px;max-width:500px;margin:0 auto;box-shadow:0 2px 8px rgba(0,0,0,.08)}
h1{font-size:20px;margin-bottom:4px}
.sub{color:#64748b;font-size:14px;margin-bottom:20px}
.field{display:flex;justify-content:space-between;padding:8px 0;border-bottom:1px solid #f1f5f9}
.field .label{color:#64748b;font-size:14px}
.field .value{font-weight:600}
table{width:100%;border-collapse:collapse;margin:12px 0;font-size:13px}
th{text-align:left;padding:6px 8px;background:#f1f5f9;font-weight:600}
td{padding:6px 8px;border-bottom:1px solid #f1f5f9}
.note{background:#fffbeb;border:1px solid #fde68a;border-radius:8px;padding:12px;margin:16px 0;font-size:13px;line-height:1.5}
.btn{display:block;width:100%;padding:14px;background:#10b981;color:#fff;border:none;border-radius:10px;font-size:16px;font-weight:600;cursor:pointer;margin-top:20px}
.btn:active{background:#059669}
.ok{text-align:center;padding:40px 20px}
.ok h2{color:#10b981;margin-bottom:8px}
#result{display:none}
</style></head><body>
<div class="card" id="contract">
<h1>Entente de service</h1>
<div class="sub">Gigafibre — TARGO Communications Inc.</div>
<div class="field"><span class="label">Client</span><span class="value">${contract.customer_name || contract.customer}</span></div>
<div class="field"><span class="label">Type</span><span class="value">${contract.contract_type}</span></div>
<div class="field"><span class="label">Mensualité</span><span class="value">${contract.monthly_rate}$/mois</span></div>
<div class="field"><span class="label">Durée</span><span class="value">${contract.duration_months} mois</span></div>
<div class="field"><span class="label">Début</span><span class="value">${contract.start_date || 'À déterminer'}</span></div>
${benefits ? `
<h3 style="margin-top:16px;font-size:15px">Avantages accordés</h3>
<table><thead><tr><th>Description</th><th>Rég.</th><th>Accordé</th><th>Reconnaissance</th></tr></thead>
<tbody>${benefits}</tbody></table>` : ''}
${contract.invoice_note ? `<div class="note">${contract.invoice_note.replace(/\n/g, '<br>')}</div>` : ''}
<div style="font-size:12px;color:#94a3b8;margin-top:16px;line-height:1.6">
En acceptant, vous confirmez avoir lu et compris les termes de cette entente de service.
${contract.contract_type === 'Résidentiel'
? 'En cas de résiliation anticipée, les avantages non compensés au pro-rata des mois écoulés seront facturés.'
: 'En cas de résiliation anticipée, la totalité des mensualités restantes au contrat sera facturée.'}
</div>
<button class="btn" onclick="accept()">✓ J'accepte cette entente</button>
</div>
<div class="card ok" id="result">
<h2>✓ Entente acceptée</h2>
<p>Merci ! Votre acceptation a été enregistrée.</p>
</div>
<script>
async function accept(){
const btn=document.querySelector('.btn');btn.disabled=true;btn.textContent='En cours...';
try{
const r=await fetch('/contract/confirm',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:'${token}'})});
const d=await r.json();
if(d.ok){document.getElementById('contract').style.display='none';document.getElementById('result').style.display='block';}
else{btn.textContent='Erreur: '+(d.error||'Réessayez');btn.disabled=false;}
}catch(e){btn.textContent='Erreur réseau';btn.disabled=false;}
}
</script></body></html>`
}
function monthsBetween (d1, d2) {
return (d2.getFullYear() - d1.getFullYear()) * 12 + (d2.getMonth() - d1.getMonth())
}
function addMonths (dateStr, months) {
const d = new Date(dateStr)
d.setMonth(d.getMonth() + months)
return d.toISOString().slice(0, 10)
}
function round2 (v) { return Math.round(v * 100) / 100 }
module.exports = { handle, calculateTerminationFee, generateInvoiceNote, generateContractLink }