// Moteur de PRORATA — crée les portions de facture selon le TYPE de chaque ligne. // • Récurrent (service / produit / rabais récurrent) → PRORATÉ : montant × jours_restants ÷ jours_période. // • Ponctuel / installation / frais unique → PLEIN montant (jamais proraté). // • Rabais → suit le type de la ligne qu'il réduit (l'appelant pose `recurring` en conséquence ; montants négatifs OK). // Mappe le `price_recurr_type` de F (1=récurrent, 0=ponctuel) → lines[].recurring. // PUR + lecture seule : calcule des lignes proratées, n'écrit AUCUNE facture. const { json, parseBody, erpFetch } = require('./helpers') function dayStart (v) { const d = new Date(v); if (isNaN(d)) throw new Error('date invalide: ' + v); d.setHours(0, 0, 0, 0); return d } function daysInclusive (a, b) { return Math.floor((dayStart(b) - dayStart(a)) / 86400000) + 1 } const ONE_TIME = new Set(['installation', 'one_time', 'setup', 'ponctuel']) // Méthode du PLUS GRAND RESTE (cf. gem OSS `lare_round`, comme Lago/Kill Bill par ligne) : // arrondit chaque montant au cent de sorte que la SOMME des arrondis == arrondi de la somme exacte // → aucun résidu d'arrondi (ex. un net mensuel de 0 donne bien 0,00 et non 0,01). Gère les négatifs. function largestRemainderRound (exacts) { const cents = exacts.map(v => v * 100) const floors = cents.map(c => Math.floor(c)) let diff = Math.round(cents.reduce((s, c) => s + c, 0)) - floors.reduce((s, c) => s + c, 0) const byRemainderDesc = exacts.map((_, i) => i).sort((a, b) => (cents[b] - floors[b]) - (cents[a] - floors[a])) const out = floors.slice() for (let k = 0; diff > 0 && k < byRemainderDesc.length; k++) { out[byRemainderDesc[k]] += 1; diff-- } return out.map(c => c / 100) } // { activation_date, period_start, period_end, lines: [{ label, amount, recurring(bool), kind? }] } function prorate ({ activation_date, period_start, period_end, lines = [] }) { const periodDays = Math.max(1, daysInclusive(period_start, period_end)) const billFrom = dayStart(activation_date) > dayStart(period_start) ? activation_date : period_start const billedDays = Math.max(0, Math.min(periodDays, daysInclusive(billFrom, period_end))) const ratio = billedDays / periodDays // Classer chaque ligne + plein montant const norm = (lines || []).map(l => { const recurring = !!l.recurring && !ONE_TIME.has(l.kind) return { label: l.label || '', kind: l.kind || (recurring ? 'service' : 'one_time'), recurring, full: Math.round((Number(l.amount) || 0) * 100) / 100 } }) // RÉCURRENT : prorata + arrondi « plus grand reste » (la somme des lignes == arrondi de la somme exacte) const recIdx = norm.map((l, i) => (l.recurring ? i : -1)).filter(i => i >= 0) const allocated = largestRemainderRound(recIdx.map(i => norm[i].full * ratio)) recIdx.forEach((i, k) => { norm[i].prorated = allocated[k] }) // PONCTUEL : plein montant (jamais proraté) norm.forEach(l => { if (!l.recurring) l.prorated = l.full }) const out = norm.map(l => ({ label: l.label, kind: l.kind, recurring: l.recurring, full_amount: l.full, factor: l.recurring ? +ratio.toFixed(4) : 1, prorated_amount: l.prorated })) const round2 = n => Math.round(n * 100) / 100 return { period_start, period_end, activation_date, period_days: periodDays, billed_days: billedDays, ratio: +ratio.toFixed(4), lines: out, prorated_total: round2(out.reduce((s, l) => s + l.prorated_amount, 0)), full_total: round2(out.reduce((s, l) => s + l.full_amount, 0)) } } const ENC = v => encodeURIComponent(JSON.stringify(v)) const pad2 = n => String(n).padStart(2, '0') const ymd = d => `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}` // Rassemble les lignes RÉELLES d'activation d'un client (abonnements récurrents + items ponctuels du devis), // les classe (récurrent vs ponctuel) et calcule la facture consolidée proratée. // LECTURE SEULE — n'écrit AUCUNE facture (`written:false`). Reproduit la convention de dispatch.js : // jour d'activation GRATUIT → 1re journée facturée = lendemain ; période = mois de l'activation. async function buildActivationPreview ({ customer, service_location, quotation, activation_date, statuses }) { if (!customer) throw new Error('customer requis') const lines = [] // 1) RÉCURRENT — Service Subscription (monthly_price ; négatif = rabais récurrent, conservé) const subFilters = [['customer', '=', customer]] if (service_location) subFilters.push(['service_location', '=', service_location]) if (Array.isArray(statuses) && statuses.length) subFilters.push(['status', 'in', statuses]) const subRes = await erpFetch(`/api/resource/Service Subscription?filters=${ENC(subFilters)}&fields=${ENC(['name', 'monthly_price', 'plan_name', 'status'])}&limit_page_length=100`) const subs = (subRes.status === 200 && Array.isArray(subRes.data?.data)) ? subRes.data.data : [] for (const s of subs) { const amt = Number(s.monthly_price) || 0 if (!amt) continue lines.push({ label: s.plan_name || s.name, amount: amt, recurring: true, kind: amt < 0 ? 'discount' : 'service', source: s.name }) } // 2) PONCTUEL — items du devis SANS "$/mois" (installation, équipement, frais unique). Les items // récurrents du devis sont déjà couverts par les abonnements ci-dessus → sautés (anti-double-compte). if (quotation) { const qRes = await erpFetch(`/api/resource/Quotation/${encodeURIComponent(quotation)}`) const items = (qRes.status === 200 && Array.isArray(qRes.data?.data?.items)) ? qRes.data.data.items : [] for (const it of items) { if (/\$\s*\/\s*mois/i.test(it.description || '')) continue const amt = Number(it.amount != null ? it.amount : (Number(it.rate) || 0) * (Number(it.qty) || 1)) || 0 if (!amt) continue const txt = `${it.item_code || ''} ${it.item_name || ''}`.toLowerCase() const kind = amt < 0 ? 'discount' : (/install|activ|frais|setup/.test(txt) ? 'installation' : 'one_time') lines.push({ label: it.item_name || it.item_code || '', amount: amt, recurring: false, kind, source: quotation }) } } // 3) Période = mois de l'activation ; 1re journée facturée = lendemain (jour d'activation gratuit) const on = activation_date ? new Date(activation_date + 'T12:00:00') : new Date() const y = on.getFullYear(); const m = on.getMonth() const dim = new Date(y, m + 1, 0).getDate() const firstBilled = new Date(y, m, on.getDate() + 1) const result = prorate({ activation_date: ymd(firstBilled), period_start: `${y}-${pad2(m + 1)}-01`, period_end: `${y}-${pad2(m + 1)}-${pad2(dim)}`, lines }) return { customer, service_location: service_location || null, quotation: quotation || null, activated_on: ymd(on), courtesy_note: `Jour d'activation (${ymd(on)}) gratuit — facturation dès le lendemain`, subscriptions: subs.map(s => s.name), written: false, ...result } } let _incomeAccount = null async function defaultIncomeAccount (company) { if (_incomeAccount) return _incomeAccount try { const r = await erpFetch(`/api/resource/Company/${encodeURIComponent(company)}?fields=${ENC(['default_income_account'])}`) if (r.data?.data?.default_income_account) { _incomeAccount = r.data.data.default_income_account; return _incomeAccount } } catch (_) { /* repli */ } _incomeAccount = 'Ventes - T' return _incomeAccount } // ÉCRITURE GATÉE — crée la facture d'activation consolidée en BROUILLON (docstatus=0, JAMAIS soumise). // Double verrou : `commit:true` (param) ET env PRORATION_WRITE=on. Sinon → aperçu (written:false). // Un brouillon ne touche AUCUN grand livre, ne débite RIEN, et reste supprimable d'un clic. async function commitActivationInvoice (body) { const preview = await buildActivationPreview(body) const armed = process.env.PRORATION_WRITE === 'on' if (!body.commit || !armed) { return { ...preview, written: false, gate: { commit: !!body.commit, env_PRORATION_WRITE: armed ? 'on' : 'off' }, note: 'écriture désarmée — il faut commit:true ET PRORATION_WRITE=on' } } if (!preview.lines.length) return { ...preview, written: false, note: 'aucune ligne à facturer' } const company = body.company || 'TARGO' const incomeAccount = await defaultIncomeAccount(company) const items = preview.lines.map(l => ({ item_name: `${l.label} — prorata ${preview.billed_days}/${preview.period_days} j`.slice(0, 140), description: `${l.recurring ? 'Récurrent proraté' : 'Ponctuel — plein montant'} · ${l.full_amount.toFixed(2)}$ × ${l.factor} = ${l.prorated_amount.toFixed(2)}$ (${l.kind})`, qty: 1, rate: l.prorated_amount, amount: l.prorated_amount, income_account: incomeAccount })) const payload = { customer: preview.customer, company, posting_date: preview.activated_on, due_date: preview.activated_on, docstatus: 0, // BROUILLON — jamais soumis (aucune écriture comptable, aucun débit) remarks: `Facture d'activation consolidée proratée (BROUILLON, non soumise). ${preview.courtesy_note}. Période ${preview.period_start}→${preview.period_end} · ${preview.billed_days}/${preview.period_days} j.`, items } const inv = await erpFetch('/api/resource/Sales Invoice', { method: 'POST', body: JSON.stringify(payload) }) if (inv.status === 200 && inv.data?.data?.name) { const d = inv.data.data return { ...preview, written: true, draft_invoice: d.name, docstatus: d.docstatus, submitted: d.docstatus === 1 } } throw new Error(`création brouillon échouée: HTTP ${inv.status} ${JSON.stringify(inv.data?.exception || inv.data).slice(0, 300)}`) } async function handle (req, res, method, path) { // POST /billing/prorate-preview — calcule l'aperçu des lignes proratées (aucune écriture). Gaté (staff). if (path === '/billing/prorate-preview' && method === 'POST') { const b = await parseBody(req) if (!b || !b.period_start || !b.period_end || !b.activation_date) return json(res, 400, { error: 'period_start, period_end, activation_date requis' }) try { return json(res, 200, prorate(b)) } catch (e) { return json(res, 400, { error: e.message }) } } // POST /billing/activation-preview — facture d'activation consolidée d'un VRAI client (LECTURE SEULE). // body : { customer, service_location?, quotation?, activation_date?, statuses? } if (path === '/billing/activation-preview' && method === 'POST') { const b = await parseBody(req) if (!b || !b.customer) return json(res, 400, { error: 'customer requis' }) try { return json(res, 200, b.commit ? await commitActivationInvoice(b) : await buildActivationPreview(b)) } catch (e) { return json(res, 400, { error: e.message }) } } // ── P1 : porte d'approbation de facturation (Ops). LECTURE SEULE + activation à l'approbation ; AUCUNE facturation ici. ── // GET /billing/approvals — activations en attente d'approbation (enrichies client/adresse). if (path === '/billing/approvals' && method === 'GET') { const pend = require('./billing-approvals').listPending() const out = [] for (const p of pend) { const jr = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(p.job)}?fields=${ENC(['customer', 'service_location', 'customer_name'])}`) const d = (jr.status === 200 && jr.data?.data) ? jr.data.data : {} out.push({ ...p, customer: d.customer || null, customer_name: d.customer_name || null, service_location: d.service_location || null }) } return json(res, 200, { pending: out }) } // GET /billing/approvals/preview?job=… — aperçu prorata consolidé (LECTURE SEULE) pour un job. if (path === '/billing/approvals/preview' && method === 'GET') { const job = new URL(req.url, 'http://h').searchParams.get('job') if (!job) return json(res, 400, { error: 'job requis' }) const jr = await erpFetch(`/api/resource/Dispatch Job/${encodeURIComponent(job)}?fields=${ENC(['customer', 'service_location'])}`) const d = (jr.status === 200 && jr.data?.data) ? jr.data.data : null if (!d || !d.customer) return json(res, 404, { error: 'job/client introuvable' }) try { return json(res, 200, await buildActivationPreview({ customer: d.customer, service_location: d.service_location, statuses: ['En attente'] })) } catch (e) { return json(res, 400, { error: e.message }) } } // POST /billing/approvals/approve { job, approver } — approuve → ACTIVE les abonnements (facturation gérée par PRORATION_WRITE ; OFF en P1 → active SANS facturer). if (path === '/billing/approvals/approve' && method === 'POST') { const b = await parseBody(req) if (!b || !b.job) return json(res, 400, { error: 'job requis' }) const approver = b.approver || req.headers['x-authentik-email'] || 'inconnu' try { return json(res, 200, await require('./billing-approvals').approve(b.job, approver)) } catch (e) { return json(res, 400, { error: e.message }) } } return json(res, 404, { error: 'not found' }) } module.exports = { handle, prorate, buildActivationPreview, commitActivationInvoice }