'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 `
| Description | Rég. | Accordé | Reconnaissance |
|---|
Merci ! Votre acceptation a été enregistrée.