Three bugs combined to make CTR-00008 (and likely others) land silently:
1. Fallback was count-based, not outcome-based.
_fireFlowTrigger returned >0 when a broken Flow Template (FT-00005)
"matched" on_contract_signed but did nothing. We took that as success
and skipped the built-in install chain. Now we ALWAYS run the built-in
chain; the idempotency check inside (look up existing Issue linked to
contract) lets a healthy Flow Template short-circuit us naturally.
2. scheduled_date was null on all chained jobs.
createDeferredJobs passed '' when no step.scheduled_date was set, and
the fiber_install template doesn't set one. Jobs with null dates are
filtered out of most dispatch board views, giving the user-visible
"Aucune job disponible pour dispatch" symptom even after the chain was
built. Default to today (via ctx.scheduled_date) so jobs appear on the
board; dispatcher reschedules per capacity.
3. No post-sign acknowledgment to the customer.
Previously the Flow Template was expected to send the confirmation SMS;
since the template was broken, the customer got nothing after signing.
Add _sendPostSignAcknowledgment that sends a "Bon de commande reçu"
SMS with contract ref + service details + next steps. Fires only when
the chain is actually created (not on idempotent skip) so we never
double-notify.
Also:
- Resolve phone/email from cell_phone + email_billing (legacy-migrated
Customer records use those fields, not Frappe defaults mobile_no /
email_id) — otherwise we'd keep skipping SMS with "no phone on file".
- _createBuiltInInstallChain now returns { created, issue, jobs,
scheduled_date, reason } so callers can branch on outcome.
- Export sendPostSignAcknowledgment so one-shot backfill scripts can
re-notify customers whose contracts were signed during the broken
window.
- Set order_source='Contract' (existing Select options patched separately
to include 'Contract' alongside Manual/Online/Quotation).
Backfilled CTR-00008: ISS-0000250003 + 4 chained Dispatch Jobs all with
scheduled_date=2026-04-23, ack SMS delivered to Louis-Paul's cell.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
860 lines
38 KiB
JavaScript
860 lines
38 KiB
JavaScript
'use strict'
|
||
const cfg = require('./config')
|
||
const { log, json, parseBody, erpFetch } = require('./helpers')
|
||
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. Build the chained Dispatch Jobs using acceptance.createDeferredJobs
|
||
// (same proven On-Hold / depends_on chaining used by online checkout).
|
||
const { getTemplateSteps } = require('./project-templates')
|
||
const steps = getTemplateSteps('fiber_install')
|
||
if (!steps.length) {
|
||
log(`[contract] ${contractName}: no fiber_install template — chain aborted`)
|
||
return { created: false, issue: issueName, jobs: [], reason: 'no_template' }
|
||
}
|
||
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}`)
|
||
}
|
||
return { created: true, issue: issueName, jobs, scheduled_date: today }
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// 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 `<tr>
|
||
<td>${b.description}</td>
|
||
<td class="r"><s style="color:#9ca3af">${b.regular_price || 0} $</s> → <strong>${b.granted_price || 0} $</strong></td>
|
||
</tr>`
|
||
}).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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 326.39 70.35" width="130" height="28"><path fill="#019547" d="M25.83,15H8.41a4.14,4.14,0,0,1-3.89-2.71L0,1.18H66.59L62.07,12.27A4.14,4.14,0,0,1,58.18,15H40.76V69.19H25.81Z"/><path fill="#019547" d="M90.74.68h18.55a5.63,5.63,0,0,1,5.63,5.6l.26,62.91H99.55L99.76,54H71.87L66.41,65.51a6.45,6.45,0,0,1-5.84,3.68H49.35L73.53,12.11A18.7,18.7,0,0,1,90.74.68M100,40.73l.07-26h-8a6.75,6.75,0,0,0-6.26,4.18L76.73,40.73Z"/><path fill="#019547" d="M124.51,6.81a5.64,5.64,0,0,1,5.65-5.65H155.6c8.64,0,15.37,2.44,19.81,6.91,3.81,3.78,5.84,9.14,5.84,15.53v.18c0,11-5.92,17.87-14.59,21.1l16.61,24.29h-14a6.51,6.51,0,0,1-5.39-2.87L151.21,47.41H139.44V69.17h-15Zm30.12,27.41c7.28,0,11.45-3.89,11.45-9.62v-.21c0-6.42-4.46-9.7-11.74-9.7H139.46V34.22Z"/><path fill="#019547" d="M184.74,35.37v-.18C184.74,15.85,199.8,0,220.4,0,232.65,0,240,3.31,247.13,9.33l-6.28,7.57a5.18,5.18,0,0,1-6.84,1,23.71,23.71,0,0,0-14.11-4.13c-10.88,0-19.52,9.62-19.52,21.18v.19c0,12.43,8.54,21.57,20.6,21.57a24,24,0,0,0,14.09-4.07V42.91h-8.68A6.41,6.41,0,0,1,220,36.53V30h29.54V59.52a44,44,0,0,1-29,10.78c-21.18,0-35.74-14.8-35.74-34.93"/><path fill="#019547" d="M254.09,35.37v-.18C254.09,15.85,269.33,0,290.33,0s36.06,15.66,36.06,35v.18c0,19.34-15.27,35.19-36.24,35.19s-36.06-15.64-36.06-35m56.63,0v-.18c0-11.67-8.54-21.39-20.6-21.39S269.73,23.34,269.73,35v.18c0,11.67,8.54,21.37,20.6,21.37S310.72,47,310.72,35.37"/></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 `<!DOCTYPE html><html lang="fr"><head>
|
||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Entente de service — TARGO</title>
|
||
<style>
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;background:#f5f7fa;color:#1a1a1a;padding:16px;line-height:1.5}
|
||
.card{background:#fff;border-radius:14px;padding:24px;max-width:560px;margin:0 auto;box-shadow:0 4px 16px rgba(0,0,0,.06)}
|
||
.brand{display:flex;align-items:center;gap:12px;padding-bottom:16px;border-bottom:2px solid #019547;margin-bottom:18px}
|
||
.brand .num{margin-left:auto;font-size:12px;color:#888;text-align:right}
|
||
h1{font-size:20px;font-weight:800;color:#019547;margin-bottom:4px;letter-spacing:.2px}
|
||
.sub{color:#6b7280;font-size:13px;margin-bottom:18px}
|
||
|
||
.field{display:flex;justify-content:space-between;gap:12px;padding:9px 0;border-bottom:1px solid #f1f5f9}
|
||
.field:last-child{border-bottom:none}
|
||
.field .label{color:#6b7280;font-size:13px}
|
||
.field .value{font-weight:600;font-size:14px;text-align:right}
|
||
|
||
h3{font-size:14px;color:#019547;margin-top:18px;margin-bottom:8px;font-weight:700;text-transform:uppercase;letter-spacing:.3px}
|
||
|
||
table{width:100%;border-collapse:collapse;margin:6px 0 10px 0;font-size:12.5px}
|
||
th{text-align:left;padding:6px 8px;background:#f4fff6;color:#00733a;font-weight:700;border-bottom:2px solid #019547}
|
||
th.r,td.r{text-align:right}
|
||
td{padding:6px 8px;border-bottom:1px solid #f1f5f9}
|
||
tfoot td{font-weight:700;color:#00733a;background:#f4fff6;border-top:1px solid #019547;border-bottom:none}
|
||
|
||
.callout{background:#fffbeb;border:1px solid #fde68a;border-radius:10px;padding:12px 14px;margin:14px 0;font-size:13px;line-height:1.55}
|
||
.callout .head{color:#b45309;font-weight:700;margin-bottom:4px}
|
||
|
||
.legal{font-size:12px;color:#6b7280;margin-top:14px;line-height:1.55}
|
||
.legal a{color:#019547;text-decoration:none;font-weight:600}
|
||
|
||
.btn{display:block;width:100%;padding:16px;background:#019547;color:#fff;border:none;border-radius:12px;font-size:16px;font-weight:700;cursor:pointer;margin-top:18px;letter-spacing:.3px}
|
||
.btn:active{background:#01733a}
|
||
.btn:disabled{background:#9ca3af;cursor:wait}
|
||
|
||
.ok{text-align:center;padding:40px 20px}
|
||
.ok .check{width:64px;height:64px;border-radius:50%;background:#019547;color:#fff;display:flex;align-items:center;justify-content:center;margin:0 auto 16px;font-size:32px;font-weight:700}
|
||
.ok h2{color:#019547;margin-bottom:8px;font-size:22px}
|
||
.ok p{color:#4b5563;font-size:14px}
|
||
#result{display:none}
|
||
</style></head><body>
|
||
|
||
<div class="card" id="contract">
|
||
<div class="brand">
|
||
${TARGO_LOGO_SVG}
|
||
<div class="num">Référence<br><strong>${contract.name || ''}</strong></div>
|
||
</div>
|
||
|
||
<h1>Récapitulatif de votre service</h1>
|
||
<div class="sub">Un résumé clair des modalités convenues — rien de plus.</div>
|
||
|
||
<div class="intro" style="background:#f4fff6;border:1px solid #bde5cb;border-radius:10px;padding:12px 14px;margin-bottom:16px;font-size:13px;color:#00733a;line-height:1.55">
|
||
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.
|
||
</div>
|
||
|
||
<div class="field"><span class="label">Client</span><span class="value">${escapeHtml(contract.customer_name || contract.customer || '')}</span></div>
|
||
<div class="field"><span class="label">Mensualité</span><span class="value">${contract.monthly_rate || 0} $/mois <span style="color:#9ca3af;font-weight:400;font-size:12px">(+taxes)</span></span></div>
|
||
<div class="field"><span class="label">Durée</span><span class="value">${duration} mois</span></div>
|
||
<div class="field"><span class="label">Début prévu</span><span class="value">${contract.start_date || 'À déterminer'}</span></div>
|
||
|
||
${benefitRows ? `
|
||
<h3>Promotions appliquées</h3>
|
||
<table>
|
||
<tbody>${benefitRows}</tbody>
|
||
<tfoot>
|
||
<tr><td>Valeur totale de la promotion</td><td class="r">${round2(totalBenefit)} $</td></tr>
|
||
<tr><td colspan="2" style="font-weight:400;color:#00733a;font-size:12px;text-align:left;">Étalée sur ${duration} mois</td></tr>
|
||
</tfoot>
|
||
</table>` : ''}
|
||
|
||
<div class="callout">
|
||
<div class="head">Changement avant ${duration} mois ?</div>
|
||
Pas de pénalité. On récupère simplement ${penaltyText}. Rien de plus.
|
||
</div>
|
||
|
||
<div class="legal">
|
||
En cliquant <strong>« J'accepte »</strong>, vous confirmez que les informations ci-dessus correspondent à ce que nous avons convenu. Les <a href="https://www.targo.ca/conditions" target="_blank">conditions complètes du service</a> (facturation, équipement, Loi 25, juridiction du Québec) s'appliquent également. Votre confirmation est horodatée.
|
||
</div>
|
||
|
||
<button class="btn" onclick="accept()">J'accepte ce récapitulatif</button>
|
||
</div>
|
||
|
||
<div class="card ok" id="result">
|
||
<div class="check">✓</div>
|
||
<h2>C'est confirmé !</h2>
|
||
<p>Merci ! Une copie du récapitulatif vous sera envoyée par courriel sous peu.</p>
|
||
</div>
|
||
|
||
<script>
|
||
async function accept(){
|
||
const btn=document.querySelector('.btn');btn.disabled=true;btn.textContent='Enregistrement...';
|
||
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 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,
|
||
}
|