gigafibre-fsm/services/targo-hub/lib/contracts.js
louispaulb ba4b5bae82 fix(chain+subs): safe job-delete, plan_name from Quotation, bi-dir sub link
- contracts.js: _inferPlanName now reads the Quotation's first positive-rate
  item ("Internet Megafibre 80 Mbps") instead of generic fallback.
- contracts.js: subPayload writes service_contract back-ref so an active/
  pending sub blocks its parent contract's deletion (LinkExistsError).
- contracts.js: GET /contract/audit-orphans[?fix=1] scans for orphaned subs
  (dangling contract link or no link at all) and contracts without a sub;
  filters out 2026-03-29 legacy-migration batch via LEGACY_CUTOFF.
- dispatch.js: deleteJobSafely() rewires children's depends_on to the
  victim's parent, re-parents descendants if victim was chain root, then
  deletes. POST /dispatch/job-delete exposes it. Fixes LinkExistsError
  when users delete a middle step in the UI.
- TaskNode.vue: confirmDelete calls /dispatch/job-delete and surfaces a
  warning when dependents will be rewired.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-23 10:19:56 -04:00

1068 lines
48 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 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 })
}
// GET /contract/audit-orphans[?fix=1]
// Audits the contract ↔ subscription integrity. Reports:
// - subs_without_contract: Service Subscriptions where service_contract is empty
// - subs_dangling_contract: service_contract is set but the contract is gone
// - contracts_without_sub: Service Contract is Actif but has no service_subscription
// When ?fix=1:
// - subs_without_contract → try to repair via customer + service_location match
// against a signed Service Contract; set service_contract on the sub and
// service_subscription on the contract if both sides are empty.
// - subs_dangling_contract → mark sub status='Annulé' + append an orphan note.
// - contracts_without_sub → no auto-fix (needs human review; report only).
if (path === '/contract/audit-orphans' && method === 'GET') {
const url = new URL(req.url, `http://localhost:${cfg.PORT}`)
const fix = url.searchParams.get('fix') === '1'
return json(res, 200, await _auditOrphans(fix))
}
return json(res, 404, { error: 'Contract endpoint not found' })
}
// ─────────────────────────────────────────────────────────────────────────────
// Subscription orphan audit
// ─────────────────────────────────────────────────────────────────────────────
// The contract → sub relationship is bi-directional:
// Service Contract.service_subscription (custom Link)
// Service Subscription.service_contract (custom Link)
// Both are populated at signing (contracts.js:_createBuiltInInstallChain).
// This audit walks both directions and reports any asymmetry.
async function _auditOrphans (applyFixes = false) {
const report = {
checked_at: new Date().toISOString(),
subs_without_contract: [], // sub has no service_contract link (post-legacy)
subs_dangling_contract: [], // service_contract points at a deleted contract
contracts_without_sub: [], // contract is Actif but has no sub linked
legacy_subs_count: 0, // pre-contract-system migration — normal, not drift
fixed: [], // actions taken when applyFixes=true
}
// Legacy-migration cutoff: subs created before this timestamp came from the
// legacy PHP/MariaDB import (batch-inserted 2026-03-29 19:38:51). They're
// real customer service records that pre-date the contract system, so
// "service_contract = empty" is expected and not a drift signal.
const LEGACY_CUTOFF = '2026-03-30T00:00:00Z'
// 1. All Service Subscriptions (excluding Annulé — those are already dead).
// Pull in batches to avoid the 500-row cap.
const subs = []
let start = 0
for (let page = 0; page < 20; page++) { // cap at 10k rows defensively
const rows = await erp.list('Service Subscription', {
filters: [['status', '!=', 'Annulé']],
fields: ['name', 'customer', 'service_location', 'service_contract', 'status', 'plan_name', 'creation'],
limit: 500, start,
})
subs.push(...rows)
if (rows.length < 500) break
start += 500
}
// 2. Resolve which contracts actually exist (for the dangling-link check)
const suspectContractNames = [...new Set(subs.map(s => s.service_contract).filter(Boolean))]
const contractExists = {}
for (const cn of suspectContractNames) {
const c = await erp.get('Service Contract', cn)
contractExists[cn] = !!c
}
// 3. Bucket the subs, skipping legacy-migration rows from the "orphan" buckets
for (const sub of subs) {
const isLegacy = new Date(sub.creation) < new Date(LEGACY_CUTOFF)
if (!sub.service_contract) {
if (isLegacy) { report.legacy_subs_count++; continue }
report.subs_without_contract.push({ name: sub.name, customer: sub.customer, service_location: sub.service_location, status: sub.status, created: sub.creation })
} else if (!contractExists[sub.service_contract]) {
report.subs_dangling_contract.push({ name: sub.name, service_contract: sub.service_contract, customer: sub.customer, status: sub.status })
}
}
// 3. Contracts that should have a sub but don't (Actif status, has monthly_rate > 0)
const contracts = await erp.list('Service Contract', {
filters: [['status', '=', 'Actif']],
fields: ['name', 'customer', 'service_location', 'service_subscription', 'monthly_rate'],
limit: 500,
})
for (const c of contracts) {
if (Number(c.monthly_rate) === 0) continue // no sub needed
if (!c.service_subscription) {
report.contracts_without_sub.push({ name: c.name, customer: c.customer, service_location: c.service_location, monthly_rate: c.monthly_rate })
}
}
// 4. Apply fixes
if (applyFixes) {
// 4a. subs_without_contract: try to recover from customer+service_location
for (const row of report.subs_without_contract) {
const match = contracts.find(c => c.customer === row.customer && c.service_location === row.service_location && !c.service_subscription)
if (!match) continue
await erp.update('Service Subscription', row.name, { service_contract: match.name })
await erp.update('Service Contract', match.name, { service_subscription: row.name })
report.fixed.push({ action: 'linked', sub: row.name, contract: match.name })
// Prevent reusing this contract for another sub
match.service_subscription = row.name
}
// 4b. subs_dangling_contract: contract is gone → mark sub Annulé
for (const row of report.subs_dangling_contract) {
await erp.update('Service Subscription', row.name, {
status: 'Annulé',
cancellation_date: new Date().toISOString().slice(0, 10),
cancellation_reason: `Contrat lié ${row.service_contract} supprimé — abonnement marqué orphelin par audit du ${new Date().toISOString().slice(0, 10)}.`,
})
report.fixed.push({ action: 'cancelled_orphan', sub: row.name, dead_contract: row.service_contract })
}
}
report.summary = {
subs_without_contract: report.subs_without_contract.length,
subs_dangling_contract: report.subs_dangling_contract.length,
contracts_without_sub: report.contracts_without_sub.length,
legacy_subs: report.legacy_subs_count,
fixed: report.fixed.length,
}
return report
}
// ─────────────────────────────────────────────────────────────────────────────
// 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,
service_contract: contractName, // reverse link — lets us detect orphans
status: 'En attente',
service_category: _inferServiceCategory(contract),
plan_name: await _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'
}
async function _inferPlanName (contract) {
// Priority 1: the source Quotation's first positive-rate item. That's
// where the actual product lives ("Internet Megafibre 80 Mbps", etc.) —
// what the customer signed for, what we want on the invoice line.
if (contract.quotation) {
try {
const q = await erp.get('Quotation', contract.quotation)
const items = q && Array.isArray(q.items) ? q.items : []
// First item with rate > 0 is the service (skip negative-rate rabais rows)
const plan = items.find(i => Number(i.rate) > 0)
if (plan && (plan.item_name || plan.item_code)) {
return String(plan.item_name || plan.item_code).slice(0, 140)
}
} catch { /* fall through to benefit / generic */ }
}
// Priority 2: a benefit row description (legacy contracts without a
// quotation link sometimes captured the plan there).
const firstBenefit = (contract.benefits || [])[0]
if (firstBenefit?.description) return String(firstBenefit.description).slice(0, 140)
// Last resort: build a generic label from the contract type + rate.
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 `<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 => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[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,
}