'use strict' const cfg = require('./config') const { log, json, parseBody, erpFetch } = require('./helpers') const erp = require('./erp') const { signJwt, verifyJwt } = require('./magic-link') // Lazy-loaded flow runtime. Kept local so the require() cost isn't paid on // modules that don't trigger flows. let _flowRuntime function _fireFlowTrigger (event, ctx) { if (!_flowRuntime) _flowRuntime = require('./flow-runtime') return _flowRuntime.dispatchEvent(event, ctx) } // 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}`) // Snapshot the contract NOW (before async chain-building) so the // post-sign acknowledgment SMS has everything it needs regardless of // what happens later. let signedContract = null try { const snap = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`) if (snap.status === 200) signedContract = snap.data?.data || null } catch { /* non-fatal */ } // Fire flow trigger: on_contract_signed — then guarantee tasks exist. // // Two layers: // 1. Flow Runtime (configurable) — an admin-defined Flow Template with // trigger_event='on_contract_signed' can fan out Issue + Dispatch Job // chains however they want. // 2. Built-in install chain (always, as a safety net) — creates one // master Issue + the fiber_install 4-job chain, idempotent per // contract (keyed on the Issue subject containing contractName). // // CRITICAL: we used to skip (2) when (1) ran at least one template, which // looked safe BUT silently broke signed contracts if the matched template // was disabled/broken/misconfigured (CTR-00008 did exactly that — a // stale FT-00005 "handled" the event, produced nothing, and we thought // we were done). So now we ALWAYS run (2); the idempotency guard inside // _createBuiltInInstallChain (look up existing Issue) means a healthy // flow template wins naturally — the built-in path short-circuits as // soon as it sees an Issue already linked to the contract. // // This runs in the background — the HTTP response returns right away. ;(async () => { try { const results = await _fireFlowTrigger('on_contract_signed', { doctype: 'Service Contract', docname: contractName, customer: payload.sub, variables: { contract_type: payload.contract_type, signed_at: now, }, }) const ranCount = Array.isArray(results) ? results.length : 0 if (ranCount > 0) { log(`[contract] ${contractName}: Flow Runtime dispatched ${ranCount} template(s) — will verify outcome via built-in safety net`) } else { log(`[contract] ${contractName}: no active Flow Template for on_contract_signed — built-in install chain will run`) } // Always attempt the built-in chain. If a Flow Template already // produced an Issue for this contract, the idempotency check inside // _createBuiltInInstallChain will short-circuit with a log line. const chainResult = await _createBuiltInInstallChain(contractName, payload) // Post-sign acknowledgment SMS (bon de commande confirmation). // We fire this ONLY when we actually created the chain — if a // previous run already built it (idempotent skip), the customer // was already notified then and we don't double-send. if (chainResult?.created) { // Resolve address for the SMS body if the chain built it let address = '' if (signedContract?.service_location) { try { const locR = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(signedContract.service_location)}`) if (locR.status === 200 && locR.data?.data) { const loc = locR.data.data address = [loc.address_line_1, loc.city].filter(Boolean).join(', ') } } catch { /* non-fatal */ } } await _sendPostSignAcknowledgment(contractName, signedContract, { address }) } } catch (e) { log(`[contract] ${contractName}: on_contract_signed automation failed: ${e.message}`) } })() return json(res, 200, { ok: true, contract: contractName }) } return json(res, 404, { error: 'Contract endpoint not found' }) } // ───────────────────────────────────────────────────────────────────────────── // Built-in install chain — fallback when no Flow Template handles the signing // ───────────────────────────────────────────────────────────────────────────── // // Pulls the full contract doc to resolve service_location + address, then // creates: // 1. One Issue (master ticket) referencing the contract, visible in ERPNext // Issue list for traceability. Links to customer. // 2. N Dispatch Jobs from project-templates.js::fiber_install — the first // is born "open", rest are "On Hold" until their parent completes (chain // walk via dispatch.unblockDependents). // // Idempotency: we tag the Issue with the contract name in subject. If we // detect an existing Issue for this contract+customer, we skip to avoid // duplicate chains on retried webhooks. async function _createBuiltInInstallChain (contractName, payload) { // 1. Fetch full contract to get service_location, address, customer_name const r = await erpFetch(`/api/resource/Service%20Contract/${encodeURIComponent(contractName)}`) if (r.status !== 200 || !r.data?.data) { log(`[contract] built-in chain skipped — contract ${contractName} not readable`) return } const contract = r.data.data const customer = contract.customer || payload.sub const serviceLocation = contract.service_location || '' const customerName = contract.customer_name || customer const contractType = contract.contract_type || payload.contract_type || 'Résidentiel' // Resolve address for field techs (Dispatch Job needs a geocodable address) let address = '' if (serviceLocation) { try { const locR = await erpFetch(`/api/resource/Service%20Location/${encodeURIComponent(serviceLocation)}`) if (locR.status === 200 && locR.data?.data) { const loc = locR.data.data address = [loc.address_line_1, loc.address_line_2, loc.city, loc.province, loc.postal_code] .filter(Boolean).join(', ') } } catch (e) { log(`[contract] address lookup failed: ${e.message}`) } } // 2. Idempotency check — did we already build a chain for this contract? try { const dupFilters = JSON.stringify([['subject', 'like', `%${contractName}%`], ['customer', '=', customer]]) const dup = await erpFetch(`/api/resource/Issue?filters=${encodeURIComponent(dupFilters)}&limit_page_length=1`) if (dup.status === 200 && Array.isArray(dup.data?.data) && dup.data.data.length) { log(`[contract] ${contractName}: chain already exists (Issue ${dup.data.data[0].name}) — skipping`) return { created: false, issue: dup.data.data[0].name, jobs: [], reason: 'already_exists' } } } catch { /* non-fatal — proceed to create */ } // 3. Create master Issue const issuePayload = { doctype: 'Issue', subject: `Activation contrat ${contractName} — ${customerName}`, description: `Contrat ${contractName} accepté le ${new Date(payload.signed_at || Date.now()).toLocaleString('fr-CA')}.\n` + `Type: ${contractType}\n` + `Client: ${customerName} (${customer})\n` + (serviceLocation ? `Emplacement: ${serviceLocation}\n` : '') + (address ? `Adresse: ${address}\n` : '') + `\nCe ticket regroupe les tâches d'activation créées automatiquement.`, priority: 'Medium', issue_type: 'Activation', status: 'Open', customer, } let issueName = '' try { const ir = await erpFetch('/api/resource/Issue', { method: 'POST', body: JSON.stringify(issuePayload), }) if (ir.status === 200 || ir.status === 201) { issueName = ir.data?.data?.name || '' log(`[contract] ${contractName}: master Issue ${issueName} created`) } else { log(`[contract] ${contractName}: Issue creation returned ${ir.status} — continuing with jobs only`) } } catch (e) { log(`[contract] ${contractName}: Issue creation failed: ${e.message} — continuing with jobs only`) } // 4. Pick the install template. Order of precedence: // (a) contract.install_template if set explicitly // (b) contract_type → template mapping (Test-Simple, Test-Parallèle, etc.) // (c) fiber_install as universal default const { getTemplateSteps, chooseTemplate } = require('./project-templates') const templateId = chooseTemplate(contract) const steps = getTemplateSteps(templateId) if (!steps.length) { log(`[contract] ${contractName}: no "${templateId}" template — chain aborted`) return { created: false, issue: issueName, jobs: [], reason: 'no_template' } } log(`[contract] ${contractName}: using template "${templateId}" (${steps.length} step${steps.length > 1 ? 's' : ''})`) const { createDeferredJobs } = require('./acceptance') // scheduled_date defaults to today so the jobs land on TODAY's dispatch // board. Dispatcher reschedules per capacity. Without this, jobs had // scheduled_date=null and disappeared from the dispatch queue. const today = new Date().toISOString().slice(0, 10) const ctx = { customer, service_location: serviceLocation, address, issue: issueName, scheduled_date: today, order_source: 'Contract', } let jobs = [] try { jobs = await createDeferredJobs(steps, ctx, contractName) log(`[contract] ${contractName}: created ${jobs.length} chained Dispatch Job(s) under Issue ${issueName || '(none)'}`) } catch (e) { log(`[contract] ${contractName}: chained job creation failed: ${e.message}`) } // 5. Create the pending Service Subscription. // Without this, activateSubscriptionForJob() on the last-job-completion // finds nothing to activate and no prorated invoice is ever emitted — // this was the CTR-00008 failure mode. The sub is born 'En attente' and // gets flipped to 'Actif' + prorated by dispatch when the chain finishes. let subscriptionName = '' if (serviceLocation && contract.monthly_rate && Number(contract.monthly_rate) !== 0) { try { const subPayload = { customer, customer_name: customerName, service_location: serviceLocation, status: 'En attente', service_category: _inferServiceCategory(contract), plan_name: _inferPlanName(contract), monthly_price: Number(contract.monthly_rate), billing_cycle: 'Mensuel', contract_duration: Number(contract.duration_months || 0), start_date: today, notes: `Créé automatiquement à la signature du contrat ${contractName}`, } const subRes = await erp.create('Service Subscription', subPayload) if (subRes.ok && subRes.name) { subscriptionName = subRes.name log(`[contract] ${contractName}: Service Subscription ${subscriptionName} (En attente) created`) // Link it back on the contract via our custom field. (The stock // `subscription` field on Service Contract is a Link to the built-in // ERPNext Subscription doctype — a different thing. We added a custom // `service_subscription` Link field that points at our doctype.) const linkRes = await erp.update('Service Contract', contractName, { service_subscription: subscriptionName }) if (!linkRes.ok) log(`[contract] ${contractName}: back-link failed — ${linkRes.error || 'unknown'}`) } else { log(`[contract] ${contractName}: subscription creation failed — ${subRes.error || 'unknown'}`) } } catch (e) { log(`[contract] ${contractName}: subscription creation threw — ${e.message}`) } } else if (!serviceLocation) { log(`[contract] ${contractName}: no service_location — skipping subscription creation`) } else { log(`[contract] ${contractName}: no monthly_rate — skipping subscription creation`) } return { created: true, issue: issueName, jobs, subscription: subscriptionName, template: templateId, scheduled_date: today } } // ── Helpers to derive subscription metadata from a Service Contract ───────── // The Service Contract carries monthly_rate + contract_type but not a specific // plan name or service category, so we infer from free-text markers. Keep this // conservative — for contracts we don't recognize, default to 'Internet' since // that's our primary product. function _inferServiceCategory (contract) { const hay = `${contract.contract_type || ''} ${contract.invoice_note || ''}`.toLowerCase() if (/iptv|t[ée]l[ée]|cha[îi]ne/.test(hay)) return 'IPTV' if (/voip|t[ée]l[ée]phon|ligne|pbx/.test(hay)) return 'VoIP' if (/h[ée]bergement|hosting|cloud|email/.test(hay)) return 'Hébergement' return 'Internet' } function _inferPlanName (contract) { // Prefer an explicit plan if present on a linked quotation/benefit row. const firstBenefit = (contract.benefits || [])[0] if (firstBenefit?.description) return String(firstBenefit.description).slice(0, 140) return `${contract.contract_type || 'Service'} ${contract.monthly_rate}$/mois` } // ───────────────────────────────────────────────────────────────────────────── // Post-sign acknowledgment SMS — customer gets a confirmation that the order // (bon de commande) was received and what happens next. // ───────────────────────────────────────────────────────────────────────────── async function _sendPostSignAcknowledgment (contractName, contract, extras = {}) { if (!contract) return const customer = contract.customer if (!customer) return // Resolve phone. Priority: contract.customer_phone → Customer.cell_phone // → Customer.mobile_no. Our legacy-migrated // Customer records use `cell_phone` (not `mobile_no`, which is Frappe's // default). Check both so we work for old + new records. let phone = contract.customer_phone || '' let customerName = contract.customer_name || customer let email = '' if (!phone || !customerName) { try { const cr = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customer)}?fields=${encodeURIComponent(JSON.stringify(['customer_name','mobile_no','cell_phone','email_id','email_billing']))}`) const d = cr.data?.data || {} phone = phone || d.cell_phone || d.mobile_no || '' email = d.email_billing || d.email_id || '' customerName = customerName || d.customer_name || customer } catch { /* non-fatal */ } } if (!phone) { log(`[contract] ${contractName}: no phone on file — skipping acknowledgment SMS`) return } const monthly = contract.monthly_rate ? `${contract.monthly_rate}$/mois` : '' const duration = contract.duration_months ? ` × ${contract.duration_months} mois` : '' const planLine = monthly ? `${monthly}${duration}` : '' const address = extras.address || '' const firstName = String(customerName).split(/\s+/)[0] || '' // Short SMS body — Twilio caps at 1600 chars but single-segment (<160) is // cheapest and renders best. Keep essentials only. const msg = [ `Gigafibre — Bon de commande ${contractName} ✓`, `Merci ${firstName}, votre commande est reçue.`, planLine ? `Service: ${planLine}` : '', address ? `Adresse: ${address}` : '', `Notre équipe planifie votre installation et vous contactera sous peu.`, `Support: 438-231-3838`, ].filter(Boolean).join('\n') try { const { sendSmsInternal } = require('./twilio') const sid = await sendSmsInternal(phone, msg) log(`[contract] ${contractName}: acknowledgment SMS sent to ${phone} (${sid})`) } catch (e) { log(`[contract] ${contractName}: acknowledgment SMS failed: ${e.message}`) } } 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 benefitRows = (contract.benefits || []).map(b => { return ` ${b.description} ${b.regular_price || 0} $${b.granted_price || 0} $ ` }).join('') const duration = contract.duration_months || 24 const totalBenefit = (contract.benefits || []).reduce( (s, b) => s + ((b.regular_price || 0) - (b.granted_price || 0)), 0, ) const TARGO_LOGO_SVG = `` const isCommercial = contract.contract_type === 'Commercial' const penaltyText = isCommercial ? `la totalité des mensualités restantes au terme (${contract.monthly_rate || 0} $/mois × mois restants)` : `la portion non étalée de la promotion, au prorata des mois restants sur ${duration} mois` return ` Entente de service — TARGO
${TARGO_LOGO_SVG}
Référence
${contract.name || ''}

Récapitulatif de votre service

Un résumé clair des modalités convenues — rien de plus.
Voici un récapitulatif de votre service Gigafibre. Il reprend ce que nous avons convenu ensemble pour que tout soit clair des deux côtés. Aucune surprise — tout est écrit noir sur blanc ci-dessous.
Client${escapeHtml(contract.customer_name || contract.customer || '')}
Mensualité${contract.monthly_rate || 0} $/mois (+taxes)
Durée${duration} mois
Début prévu${contract.start_date || 'À déterminer'}
${benefitRows ? `

Promotions appliquées

${benefitRows}
Valeur totale de la promotion${round2(totalBenefit)} $
Étalée sur ${duration} mois
` : ''}
Changement avant ${duration} mois ?
Pas de pénalité. On récupère simplement ${penaltyText}. Rien de plus.

C'est confirmé !

Merci ! Une copie du récapitulatif vous sera envoyée par courriel sous peu.

` } function escapeHtml (s) { return String(s || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', }[c])) } 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, renderAcceptancePage, // Exposed so ops tools / one-shot scripts can retro-create the install chain // for contracts that were signed before the built-in fallback existed. createBuiltInInstallChain: _createBuiltInInstallChain, sendPostSignAcknowledgment: _sendPostSignAcknowledgment, }