'use strict'
/**
 * Payment module — Stripe best-practice integration
 *
 * Architecture:
 *   - Checkout Session (mode: payment) for one-time balance payment
 *   - Checkout Session (mode: payment) for invoice-specific payment (+ Klarna)
 *   - Checkout Session (mode: setup) to save a card without paying
 *   - Billing Portal Session for card management
 *   - PaymentIntent (off_session) for automated PPA charges
 *   - Webhook handler with signature verification
 *   - PPA cron for daily automated billing
 *   - NO Stripe Invoices — ERPNext is the invoicing system
 *
 * Endpoints:
 *   GET  /payments/balance/:customer      -> outstanding balance
 *   GET  /payments/methods/:customer      -> list saved payment methods
 *   GET  /payments/invoice/:invoice       -> invoice payment info (for portal)
 *   POST /payments/checkout               -> create Checkout Session (pay balance)
 *   POST /payments/checkout-invoice       -> create Checkout Session (pay specific invoice)
 *   POST /payments/setup                  -> create Setup Session (save card)
 *   POST /payments/portal                 -> create Billing Portal Session
 *   POST /payments/toggle-ppa             -> enable/disable auto-pay
 *   POST /payments/charge                 -> manually charge saved card (agent)
 *   POST /payments/send-link              -> send payment link via SMS/email
 *   POST /payments/refund                 -> refund a payment
 *   POST /payments/ppa-run               -> manually trigger PPA cron run
 *   GET  /payments/return                 -> Stripe redirect: generate magic link + redirect to portal
 *   POST /webhook/stripe                  -> Stripe webhook receiver
 */
const https = require('https')
const crypto = require('crypto')
const cfg = require('./config')
const { log, json, parseBody } = require('./helpers')
const erp = require('./erp')
const sse = require('./sse')
const { verifyJwt } = require('./magic-link')

// Stripe config from environment
const STRIPE_SECRET = cfg.STRIPE_SECRET_KEY
const STRIPE_WEBHOOK_SECRET = cfg.STRIPE_WEBHOOK_SECRET
const STRIPE_API = 'https://api.stripe.com'
const PORTAL_URL = cfg.CLIENT_PORTAL_URL || 'https://portal.gigafibre.ca'
const HUB_PUBLIC_URL = cfg.HUB_PUBLIC_URL || 'https://msg.gigafibre.ca'
const STRIPE_IS_LIVE = STRIPE_SECRET && STRIPE_SECRET.startsWith('sk_live_')

if (STRIPE_IS_LIVE) log('Stripe: LIVE mode')
else if (STRIPE_SECRET) log('Stripe: TEST mode')
else log('  No STRIPE_SECRET_KEY configured — payment endpoints will fail')

// ────────────────────────────────────────────
// Stripe API helper (raw HTTPS, no SDK needed)
// ────────────────────────────────────────────
function stripeRequest (method, path, params = {}, idempotencyKey = null) {
  return new Promise((resolve, reject) => {
    const body = new URLSearchParams()
    flattenParams(params, body, '')
    const bodyStr = body.toString()

    const opts = {
      hostname: 'api.stripe.com',
      path: '/v1' + path,
      method,
      headers: {
        Authorization: 'Bearer ' + STRIPE_SECRET,
        'Content-Type': 'application/x-www-form-urlencoded',
        ...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}), // anti-double-charge : un retry du MÊME appel ne crée pas une 2e charge (fenêtre 24h Stripe)
        ...(bodyStr && method !== 'GET' ? { 'Content-Length': Buffer.byteLength(bodyStr) } : {}),
      },
    }
    if (method === 'GET' && bodyStr) opts.path += '?' + bodyStr

    const req = https.request(opts, (res) => {
      let data = ''
      res.on('data', c => { data += c })
      res.on('end', () => {
        try {
          const parsed = JSON.parse(data)
          if (parsed.error) reject(new Error(parsed.error.message))
          else resolve(parsed)
        } catch { reject(new Error('Stripe parse error: ' + data.slice(0, 200))) }
      })
    })
    req.on('error', reject)
    if (method !== 'GET' && bodyStr) req.write(bodyStr)
    req.end()
  })
}

// Flatten nested objects for Stripe's form encoding: { a: { b: 'c' } } -> 'a[b]=c'
function flattenParams (obj, params, prefix) {
  for (const [k, v] of Object.entries(obj)) {
    const key = prefix ? `${prefix}[${k}]` : k
    if (v === null || v === undefined) continue
    if (typeof v === 'object' && !Array.isArray(v)) flattenParams(v, params, key)
    else if (Array.isArray(v)) v.forEach((item, i) => {
      if (typeof item === 'object') flattenParams(item, params, `${key}[${i}]`)
      else params.append(`${key}[${i}]`, String(item))
    })
    else params.append(key, String(v))
  }
}

// ────────────────────────────────────────────
// Webhook signature verification
// ────────────────────────────────────────────
function verifyWebhookSignature (rawBody, sigHeader) {
  // Fail-closed : sans secret configuré on REFUSE le webhook (sinon un attaquant
  // forge "paiement réussi"). Opt-in explicite réservé au dev local.
  if (!STRIPE_WEBHOOK_SECRET) return process.env.ALLOW_UNSIGNED_WEBHOOKS === '1'
  if (!sigHeader) return false

  const parts = {}
  sigHeader.split(',').forEach(p => {
    const [k, v] = p.split('=')
    if (k === 't') parts.t = v
    else if (k === 'v1') parts.v1 = parts.v1 || v
  })

  if (!parts.t || !parts.v1) return false

  // Reject events older than 5 minutes
  const tolerance = 300 // seconds
  const now = Math.floor(Date.now() / 1000)
  if (Math.abs(now - parseInt(parts.t)) > tolerance) {
    log('Webhook signature: timestamp too old')
    return false
  }

  const expected = crypto
    .createHmac('sha256', STRIPE_WEBHOOK_SECRET)
    .update(`${parts.t}.${rawBody}`)
    .digest('hex')

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))
}

// ────────────────────────────────────────────
// ERPNext helpers
// ────────────────────────────────────────────
async function getCustomerBalance (customerId) {
  // Sum outstanding amounts from unpaid invoices
  const invoices = await erp.list('Sales Invoice', {
    filters: [
      ['customer', '=', customerId],
      ['outstanding_amount', '>', 0],
      ['docstatus', '=', 1],
    ],
    fields: ['name', 'posting_date', 'grand_total', 'outstanding_amount'],
    orderBy: 'posting_date asc',
    limit: 100,
  })
  const balance = invoices.reduce((sum, inv) => sum + (inv.outstanding_amount || 0), 0)
  return { balance: Math.round(balance * 100) / 100, invoices }
}

async function getInvoiceDoc (invoiceName) {
  return erp.get('Sales Invoice', invoiceName, {
    fields: ['name', 'customer', 'customer_name', 'posting_date', 'due_date', 'grand_total', 'outstanding_amount', 'status', 'docstatus', 'currency'],
  })
}

async function getCustomerDoc (customerId) {
  return erp.get('Customer', customerId)
}

async function getPaymentMethods (customerId) {
  return erp.list('Payment Method', {
    filters: [['customer', '=', customerId]],
    fields: [
      'name', 'provider', 'is_active', 'is_auto_ppa',
      'stripe_customer_id', 'stripe_ppa_enabled', 'stripe_ppa_nocc',
      'paysafe_profile_id', 'paysafe_card_id', 'paysafe_token',
      'ppa_name', 'ppa_institution', 'ppa_branch', 'ppa_account', 'ppa_amount', 'ppa_buffer',
    ],
    limit: 20,
  })
}

// Check for existing Payment Entry with same reference_no (idempotency)
async function paymentEntryExists (referenceNo) {
  if (!referenceNo) return false
  const rows = await erp.list('Payment Entry', {
    filters: [
      ['reference_no', '=', referenceNo],
      ['docstatus', '!=', 2], // not cancelled
    ],
    fields: ['name'],
    limit: 1,
  })
  return rows.length > 0
}

// ────────────────────────────────────────────
// Stripe Customer management
// ────────────────────────────────────────────
async function ensureStripeCustomer (customerId) {
  // Look up customer info from ERPNext
  const cust = await getCustomerDoc(customerId)
  if (!cust) throw new Error('Customer not found: ' + customerId)

  // ── ALIGNEMENT F↔OPS (CRITIQUE) ────────────────────────────────────────────
  // Le client Stripe AUTORITAIRE est celui de F (gestionclient), synchronisé sur
  // Customer.stripe_id (legacy-sync depuis account.stripe_id). On le RÉUTILISE
  // toujours s'il existe — sinon OPS créerait un 2e client Stripe pour la même
  // personne (cartes ET factures invisibles entre les deux systèmes → double
  // facturation / idempotence inter-systèmes impossible). 599/600 clients Stripe
  // sont ceux de F. cf reference_f_stripe_billing.
  if (cust.stripe_id && /^cus_/.test(cust.stripe_id)) {
    await saveStripeCustomerId(customerId, cust.stripe_id) // miroir dans Payment Method
    return cust.stripe_id
  }

  // Pas de client F connu → mapping OPS existant, sinon recherche, sinon création.
  const methods = await getPaymentMethods(customerId)
  const existing = methods.find(m => m.provider === 'Stripe' && m.stripe_customer_id)
  if (existing) return existing.stripe_customer_id

  const email = cust.email_billing || cust.email_id || ''
  const name = cust.customer_name || customerId
  if (!email) throw new Error('Customer has no email — required for Stripe')

  // Search Stripe by metadata (clé OPS)
  const search = await stripeRequest('GET', '/customers/search', { query: `metadata['erp_id']:'${customerId}'` })
  if (search.data?.length) {
    const stripeId = search.data[0].id
    await saveStripeCustomerId(customerId, stripeId)
    return stripeId
  }

  // Dernier recours : créer (client réellement nouveau, absent de F).
  const customer = await stripeRequest('POST', '/customers', {
    name,
    email,
    metadata: { erp_id: customerId },
    preferred_locales: ['fr-CA'],
    tax_exempt: 'exempt', // ERPNext handles taxes
    invoice_settings: { custom_fields: [{ name: '# Client', value: customerId }] },
  })

  await saveStripeCustomerId(customerId, customer.id)
  return customer.id
}

async function saveStripeCustomerId (customerId, stripeId) {
  // Update or create Payment Method record with the Stripe customer ID
  const methods = await getPaymentMethods(customerId)
  const existing = methods.find(m => m.provider === 'Stripe')
  if (existing) {
    await erp.update('Payment Method', existing.name, { stripe_customer_id: stripeId })
  } else {
    await erp.create('Payment Method', {
      customer: customerId, provider: 'Stripe', stripe_customer_id: stripeId,
    })
  }
}

// ────────────────────────────────────────────
// Per-customer authorization (dual-use routes)
// ────────────────────────────────────────────
// balance/methods/invoice/checkout/setup/portal/toggle-ppa are reachable by the
// customer portal WITHOUT the staff token (the hub gate lets them through as
// PUBLIC), so without this check anyone who knows a customer id could read PII
// (balance, saved cards, invoices) or abuse auto-debit. We authorize per-customer:
//   - staff token (HUB_SERVICE_TOKEN) → full access (ops / impersonation)
//   - customer magic-link JWT whose `sub` matches the target customer → self access
// PAYMENTS_AUTH=enforce blocks everything else (401). Until that flag is set
// (report/unset) we allow but log the would-be block, so stale portal sessions and
// the rare ERPNext-session path can be observed before the switch is flipped.
function authorizeCustomer (req, ownerCustomerId) {
  const tok = (req.headers.authorization || '').replace(/^Bearer\s+/i, '')
  if (process.env.HUB_SERVICE_TOKEN && tok === process.env.HUB_SERVICE_TOKEN) return { ok: true, via: 'staff' }
  if (tok) {
    const p = verifyJwt(tok)
    if (p && p.scope === 'customer') {
      if (p.sub && ownerCustomerId && String(p.sub) === String(ownerCustomerId)) return { ok: true, via: 'customer' }
      return { ok: false, via: 'customer', reason: 'customer_mismatch' }
    }
    return { ok: false, via: 'anon', reason: 'invalid_token' }
  }
  return { ok: false, via: 'anon', reason: 'no_token' }
}

// Returns true if the caller should STOP (a 401 was sent). In report mode it never
// sends 401 — it only logs — so the route proceeds.
function denyIfUnauthorized (req, res, ownerCustomerId, route) {
  const a = authorizeCustomer(req, ownerCustomerId)
  if (a.ok) return false
  const enforce = process.env.PAYMENTS_AUTH === 'enforce'
  log(`[payments-auth] ${enforce ? 'BLOCK' : 'report'} route=${route} customer=${ownerCustomerId || '?'} via=${a.via} reason=${a.reason}`)
  if (!enforce) return false
  json(res, 401, { error: 'Unauthorized', detail: 'customer authentication required' })
  return true
}

// ────────────────────────────────────────────
// Route handler
// ────────────────────────────────────────────
async function handle (req, res, method, path, url) {
  try {
    // GET /payments/balance/:customer
    const balMatch = path.match(/^\/payments\/balance\/(.+)$/)
    if (balMatch && method === 'GET') {
      const cid = decodeURIComponent(balMatch[1])
      if (denyIfUnauthorized(req, res, cid, 'balance')) return
      const result = await getCustomerBalance(cid)
      return json(res, 200, result)
    }

    // GET /payments/methods/:customer
    const methMatch = path.match(/^\/payments\/methods\/(.+)$/)
    if (methMatch && method === 'GET') {
      const customerId = decodeURIComponent(methMatch[1])
      if (denyIfUnauthorized(req, res, customerId, 'methods')) return
      const methods = await getPaymentMethods(customerId)

      // Enrich Stripe methods with live card info from Stripe API
      for (const m of methods) {
        if (m.provider === 'Stripe' && m.stripe_customer_id) {
          try {
            const pm = await stripeRequest('GET', '/payment_methods', {
              customer: m.stripe_customer_id, type: 'card', limit: 5,
            })
            m.stripe_cards = (pm.data || []).map(c => ({
              id: c.id,
              brand: c.card?.brand,
              last4: c.card?.last4,
              exp_month: c.card?.exp_month,
              exp_year: c.card?.exp_year,
              is_default: false, // will be set below
            }))
            // Check default payment method
            const cust = await stripeRequest('GET', `/customers/${m.stripe_customer_id}`)
            const defaultPm = cust.invoice_settings?.default_payment_method
            for (const card of m.stripe_cards) {
              if (card.id === defaultPm) card.is_default = true
            }
          } catch (e) {
            log('Stripe card fetch error:', e.message)
            m.stripe_cards = []
          }
        }
      }
      return json(res, 200, { methods })
    }

    // GET /payments/refunds/:customer — statut de remboursement Stripe par PaymentIntent (pi_), pour le bloc paiements de
    // la fiche. 1 appel Stripe (charges du client + refunds) → map { pi_xxx: {amount_refunded, status, refunded_at, fully} }.
    // Lecture seule : NE crée PAS de client Stripe (réutilise l'id existant) ; best-effort (pas de Stripe → map vide).
    const refMatch = path.match(/^\/payments\/refunds\/(.+)$/)
    if (refMatch && method === 'GET') {
      const customerId = decodeURIComponent(refMatch[1])
      if (denyIfUnauthorized(req, res, customerId, 'refunds')) return
      const refunds = {}
      try {
        const cust = await getCustomerDoc(customerId)
        let stripeId = (cust && cust.stripe_id && /^cus_/.test(cust.stripe_id)) ? cust.stripe_id : null
        if (!stripeId) { const methods = await getPaymentMethods(customerId); const m = (methods || []).find(x => x.provider === 'Stripe' && x.stripe_customer_id); if (m) stripeId = m.stripe_customer_id }
        if (stripeId) {
          const ch = await stripeRequest('GET', '/charges', { customer: stripeId, limit: 100, expand: ['data.refunds'] })
          for (const c of (ch.data || [])) {
            if (!c.payment_intent || !(c.amount_refunded > 0)) continue
            const last = ((c.refunds && c.refunds.data) || [])[0] // Stripe : le plus récent en premier
            refunds[c.payment_intent] = {
              amount_refunded: c.amount_refunded / 100,
              fully_refunded: !!c.refunded,
              status: last ? last.status : 'succeeded',
              refunded_at: (last && last.created) ? new Date(last.created * 1000).toISOString().slice(0, 10) : null,
            }
          }
        }
      } catch (e) { log('payments/refunds:', e.message) } // pas de Stripe / erreur → map vide
      return json(res, 200, { refunds })
    }

    // GET /payments/charges/:customer — historique des CHARGES Stripe (lecture seule) pour le bloc paiements de la fiche.
    // Comble le trou : un paiement Stripe (auto-PPA, lien de paiement) n'est visible sur la fiche que s'il a été
    // enregistré comme Payment Entry ERPNext ; ceci montre les charges Stripe réelles, même non synchronisées.
    // Même pattern sûr que /refunds : réutilise l'id cus_ existant, best-effort (pas de Stripe → liste vide), ne CRÉE rien.
    const chgMatch = path.match(/^\/payments\/charges\/(.+)$/)
    if (chgMatch && method === 'GET') {
      const customerId = decodeURIComponent(chgMatch[1])
      if (denyIfUnauthorized(req, res, customerId, 'charges')) return
      const charges = []
      try {
        const cust = await getCustomerDoc(customerId)
        let stripeId = (cust && cust.stripe_id && /^cus_/.test(cust.stripe_id)) ? cust.stripe_id : null
        if (!stripeId) { const methods = await getPaymentMethods(customerId); const m = (methods || []).find(x => x.provider === 'Stripe' && x.stripe_customer_id); if (m) stripeId = m.stripe_customer_id }
        if (stripeId) {
          const ch = await stripeRequest('GET', '/charges', { customer: stripeId, limit: 50 })
          for (const c of (ch.data || [])) {
            charges.push({
              id: c.id,
              payment_intent: c.payment_intent || null,
              amount: (c.amount || 0) / 100,
              amount_refunded: (c.amount_refunded || 0) / 100,
              refunded: !!c.refunded,
              currency: (c.currency || 'cad').toUpperCase(),
              status: c.status, // succeeded / pending / failed
              paid: !!c.paid,
              created: c.created ? new Date(c.created * 1000).toISOString().slice(0, 10) : null,
              description: c.description || null,
              brand: (c.payment_method_details && c.payment_method_details.card && c.payment_method_details.card.brand) || null,
              last4: (c.payment_method_details && c.payment_method_details.card && c.payment_method_details.card.last4) || null,
              receipt_url: c.receipt_url || null,
            })
          }
        }
      } catch (e) { log('payments/charges:', e.message) } // pas de Stripe / erreur → liste vide
      return json(res, 200, { charges })
    }

    // POST /payments/record-invoice — « Enregistrer un paiement » sur une facture (modèle Odoo), depuis la fiche.
    // Staff-only (dans ALWAYS_ENFORCE côté server.js — un JWT client NE peut PAS l'atteindre).
    // Sûreté : (1) `preview:true` calcule SANS écrire (le dialogue montre le montant/les comptes avant de confirmer) ;
    // (2) comptes résolus par la LOGIQUE PROPRE d'ERPNext (get_payment_entry) puis PE minimal propre (mêmes champs que le remboursement) ;
    // (3) garde-fou anti double-paiement : refuse si le solde de la facture est nul.
    if (path === '/payments/record-invoice' && method === 'POST') {
      const body = await parseBody(req)
      const invoice = body.invoice
      if (!invoice) return json(res, 400, { error: 'invoice requis' })
      const inv = await getInvoiceDoc(invoice)
      if (!inv) return json(res, 404, { error: 'Facture introuvable' })
      const outstanding = Number(inv.outstanding_amount || 0)
      if (!(outstanding > 0)) return json(res, 400, { error: 'Facture déjà payée (aucun solde).', outstanding })

      // Montant (partiel autorisé, plafonné au solde)
      const pay = body.amount ? Math.min(Number(body.amount), outstanding) : outstanding
      if (!(pay > 0)) return json(res, 400, { error: 'Montant invalide.' })

      // ERPNext résout les bons comptes (paid_from = Comptes clients, paid_to = banque/caisse par défaut) + le mode.
      let pe, rawResp
      try {
        // Méthode whitelistée en LECTURE → GET avec args en query (plus fiable que POST+JSON pour get_payment_entry)
        rawResp = await erp.raw('/api/method/erpnext.accounts.doctype.payment_entry.payment_entry.get_payment_entry?dt=' + encodeURIComponent('Sales Invoice') + '&dn=' + encodeURIComponent(invoice))
        pe = rawResp && rawResp.data && rawResp.data.message   // /api/method → corps { message: <doc PE> }
      } catch (e) { return json(res, 502, { error: 'ERPNext get_payment_entry : ' + e.message }) }
      if (!pe || !pe.paid_from) return json(res, 502, { error: "ERPNext get_payment_entry : réponse inattendue (paid_from manquant)." })

      // ERPNext laisse paid_to (compte de dépôt) vide tant qu'aucun mode/banque n'est fourni →
      // on le résout via le compte configuré du Mode de paiement (seul Stripe en a un ici), sinon la banque par défaut.
      const mode = body.mode_of_payment || 'Cheque'
      let paidTo = pe.paid_to
      if (!paidTo) {
        try { const mop = await erp.get('Mode of Payment', mode); const acc = ((mop && mop.accounts) || []).find(a => a.company === (pe.company || 'TARGO')); paidTo = acc && acc.default_account } catch (e) { /* repli ci-dessous */ }
      }
      if (!paidTo) paidTo = 'Banque - T'

      const refDate = body.reference_date || new Date().toISOString().slice(0, 10)
      const clean = {
        doctype: 'Payment Entry',
        payment_type: 'Receive',
        company: pe.company,
        party_type: 'Customer',
        party: inv.customer,
        paid_amount: pay,
        received_amount: pay,
        source_exchange_rate: 1,
        target_exchange_rate: 1,
        paid_from: pe.paid_from,
        paid_to: paidTo,
        paid_from_account_currency: pe.paid_from_account_currency || 'CAD',
        paid_to_account_currency: pe.paid_to_account_currency || 'CAD',
        mode_of_payment: mode,
        reference_no: body.reference_no || undefined,
        reference_date: refDate,
        posting_date: refDate,
        remarks: `Paiement enregistré depuis la fiche client — facture ${invoice}`,
        references: [{
          reference_doctype: 'Sales Invoice',
          reference_name: invoice,
          total_amount: inv.grand_total,
          outstanding_amount: outstanding,
          allocated_amount: pay,
        }],
      }

      const summary = {
        customer: inv.customer, invoice, outstanding, paid_amount: pay,
        mode_of_payment: clean.mode_of_payment || null, paid_from: clean.paid_from, paid_to: clean.paid_to,
        reference_no: clean.reference_no || null, reference_date: refDate,
      }
      if (body.preview) return json(res, 200, { preview: true, ...summary })

      // Écriture : insérer (brouillon) puis soumettre — logique standard ERPNext (compat PostgreSQL : full doc au submit)
      const created = await erp.create('Payment Entry', clean)
      if (!created.ok || !created.name) return json(res, 502, { error: 'Création du paiement échouée : ' + (created.error || 'inconnu') })
      const full = await erp.get('Payment Entry', created.name)
      const sub = await erp.raw('/api/method/frappe.client.submit', { method: 'POST', body: JSON.stringify({ doc: full }) })
      if (sub && sub.status >= 400) return json(res, 502, { error: 'Soumission échouée : ' + (erp.errorMessage ? erp.errorMessage(sub) : sub.status), name: created.name })
      log(`Payment Entry ${created.name} enregistré (${pay}$) contre ${invoice} pour ${inv.customer}`)
      return json(res, 200, { ok: true, name: created.name, ...summary })
    }

    // POST /payments/record-on-account — paiement GÉNÉRAL du client (avance / non alloué), SANS facture.
    // Remplace l'ancien lien desk « Payment Entry/new ». preview:true → aucune écriture. Idempotent via reference_no.
    if (path === '/payments/record-on-account' && method === 'POST') {
      const body = await parseBody(req)
      const customer = body.customer
      const amount = Number(body.amount)
      if (!customer) return json(res, 400, { error: 'customer requis' })
      if (!(amount > 0)) return json(res, 400, { error: 'Montant invalide.' })
      const company = body.company || 'TARGO'
      // Compte clients (paid_from) = receivable par défaut de la SOCIÉTÉ. On NE DEVINE PAS : erreur si absent.
      let co
      try { co = await erp.get('Company', company) } catch (e) { return json(res, 502, { error: 'Société introuvable : ' + company }) }
      const paidFrom = co && co.default_receivable_account
      if (!paidFrom) return json(res, 502, { error: 'Compte clients par défaut introuvable sur la société — à configurer dans ERPNext.' })
      // Compte de dépôt (paid_to) : compte du Mode de paiement, sinon banque par défaut (même logique que record-invoice).
      const mode = body.mode_of_payment || 'Cheque'
      let paidTo
      try { const mop = await erp.get('Mode of Payment', mode); const acc = ((mop && mop.accounts) || []).find(a => a.company === company); paidTo = acc && acc.default_account } catch (e) { /* repli ci-dessous */ }
      if (!paidTo) paidTo = 'Banque - T'
      const refDate = body.reference_date || new Date().toISOString().slice(0, 10)
      const curr = co.default_currency || 'CAD'
      const summary = { customer, paid_amount: amount, on_account: true, company, paid_from: paidFrom, paid_to: paidTo, mode_of_payment: mode, reference_no: body.reference_no || null, reference_date: refDate }
      if (body.preview) return json(res, 200, { preview: true, ...summary })
      if (await paymentEntryExists(body.reference_no)) return json(res, 409, { error: 'Un paiement avec ce numéro de référence existe déjà.' })
      const clean = {
        doctype: 'Payment Entry', payment_type: 'Receive', company,
        party_type: 'Customer', party: customer,
        paid_amount: amount, received_amount: amount, source_exchange_rate: 1, target_exchange_rate: 1,
        paid_from: paidFrom, paid_to: paidTo,
        paid_from_account_currency: curr, paid_to_account_currency: curr,
        mode_of_payment: mode, reference_no: body.reference_no || undefined, reference_date: refDate, posting_date: refDate,
        remarks: `Paiement à l'avance / non alloué enregistré depuis la fiche client (${customer}).`,
        // PAS de references[] → ERPNext le laisse NON ALLOUÉ (crédit sur le compte client).
      }
      const created = await erp.create('Payment Entry', clean)
      if (!created.ok || !created.name) return json(res, 502, { error: 'Création du paiement échouée : ' + (created.error || 'inconnu') })
      const full = await erp.get('Payment Entry', created.name)
      const sub = await erp.raw('/api/method/frappe.client.submit', { method: 'POST', body: JSON.stringify({ doc: full }) })
      if (sub && sub.status >= 400) return json(res, 502, { error: 'Soumission échouée : ' + (erp.errorMessage ? erp.errorMessage(sub) : sub.status), name: created.name })
      log(`Payment Entry ${created.name} (à l'avance ${amount}$) pour ${customer}`)
      return json(res, 200, { ok: true, name: created.name, ...summary })
    }

    // GET /payments/invoice/:invoice — invoice payment info for portal
    const invMatch = path.match(/^\/payments\/invoice\/(.+)$/)
    if (invMatch && method === 'GET') {
      const invoiceName = decodeURIComponent(invMatch[1])
      const inv = await getInvoiceDoc(invoiceName)
      if (!inv) return json(res, 404, { error: 'Invoice not found' })
      if (denyIfUnauthorized(req, res, inv.customer, 'invoice')) return
      return json(res, 200, {
        name: inv.name,
        customer: inv.customer,
        customer_name: inv.customer_name,
        grand_total: inv.grand_total,
        outstanding_amount: inv.outstanding_amount,
        posting_date: inv.posting_date,
        due_date: inv.due_date,
        status: inv.status,
        currency: inv.currency || 'CAD',
        can_pay: inv.docstatus === 1 && inv.outstanding_amount > 0,
      })
    }

    // POST /payments/checkout — create Checkout Session to pay balance
    if (path === '/payments/checkout' && method === 'POST') {
      const body = await parseBody(req)
      const { customer } = body
      if (!customer) return json(res, 400, { error: 'customer required' })
      if (denyIfUnauthorized(req, res, customer, 'checkout')) return

      const { balance } = await getCustomerBalance(customer)
      if (balance <= 0) return json(res, 400, { error: 'No outstanding balance', balance })

      const stripeCustomerId = await ensureStripeCustomer(customer)
      const amountCents = Math.round(balance * 100)

      const session = await stripeRequest('POST', '/checkout/sessions', {
        customer: stripeCustomerId,
        mode: 'payment',
        payment_method_types: ['card'],
        line_items: [{
          price_data: {
            currency: 'cad',
            unit_amount: amountCents,
            product_data: { name: `Solde du — Balance due — ${customer}` },
          },
          quantity: 1,
        }],
        // Save the card for future PPA charges
        payment_intent_data: {
          setup_future_usage: 'off_session',
          metadata: { erp_customer: customer, type: 'balance_payment' },
        },
        metadata: { erp_customer: customer, type: 'balance_payment' },
        success_url: `${HUB_PUBLIC_URL}/payments/return?type=success&session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${HUB_PUBLIC_URL}/payments/return?type=cancel`,
        locale: 'fr',
      })

      log(`Checkout session created for ${customer}: ${session.id} ($${balance})`)
      return json(res, 200, { url: session.url, session_id: session.id, amount: balance })
    }

    // POST /payments/checkout-invoice — pay a specific invoice (+ Klarna support)
    if (path === '/payments/checkout-invoice' && method === 'POST') {
      const body = await parseBody(req)
      const { customer, invoice, save_card, payment_method } = body
      if (!customer) return json(res, 400, { error: 'customer required' })
      if (denyIfUnauthorized(req, res, customer, 'checkout-invoice')) return
      if (!invoice) return json(res, 400, { error: 'invoice required' })

      // Fetch invoice to get amount
      const inv = await getInvoiceDoc(invoice)
      if (!inv) return json(res, 404, { error: 'Invoice not found: ' + invoice })
      if (inv.customer !== customer) return json(res, 403, { error: 'Invoice does not belong to this customer' })
      if (inv.docstatus !== 1) return json(res, 400, { error: 'Invoice is not submitted' })
      if (inv.outstanding_amount <= 0) return json(res, 400, { error: 'Invoice already paid', outstanding: inv.outstanding_amount })

      const stripeCustomerId = await ensureStripeCustomer(customer)
      // Use grand_total (includes taxes) — Stripe must collect the full amount with TPS/TVQ
      const chargeAmount = inv.grand_total || inv.outstanding_amount
      const amountCents = Math.round(chargeAmount * 100)

      // Payment method types: card always, klarna for one-time only
      const pmTypes = ['card']
      if (payment_method === 'klarna') {
        pmTypes.push('klarna')
      }

      const sessionParams = {
        customer: stripeCustomerId,
        mode: 'payment',
        payment_method_types: pmTypes,
        line_items: [{
          price_data: {
            currency: 'cad',
            unit_amount: amountCents,
            product_data: {
              name: `Facture ${invoice}`,
              description: `Paiement de la facture ${invoice} — ${inv.customer_name || customer}`,
            },
          },
          quantity: 1,
        }],
        metadata: {
          erp_customer: customer,
          erp_invoice: invoice,
          type: 'invoice_payment',
        },
        success_url: `${HUB_PUBLIC_URL}/payments/return?type=success&session_id={CHECKOUT_SESSION_ID}&invoice=${invoice}`,
        cancel_url: `${HUB_PUBLIC_URL}/payments/return?type=cancel&invoice=${invoice}`,
        locale: 'fr',
      }

      // Save card for future PPA if requested (only for card, not Klarna)
      if (save_card !== false) {
        sessionParams.payment_intent_data = {
          setup_future_usage: 'off_session',
          metadata: {
            erp_customer: customer,
            erp_invoice: invoice,
            type: 'invoice_payment',
          },
        }
      } else {
        sessionParams.payment_intent_data = {
          metadata: {
            erp_customer: customer,
            erp_invoice: invoice,
            type: 'invoice_payment',
          },
        }
      }

      const session = await stripeRequest('POST', '/checkout/sessions', sessionParams)

      log(`Invoice checkout for ${customer}/${invoice}: ${session.id} ($${chargeAmount} incl. taxes)`)
      return json(res, 200, {
        url: session.url,
        session_id: session.id,
        invoice: invoice,
        amount: chargeAmount,
      })
    }

    // POST /payments/checkout-product — direct product checkout (activation fee, ad-hoc charge)
    // Can also be triggered via GET /payments/pay/:customer/:product for magic links
    if ((path === '/payments/checkout-product' && method === 'POST') ||
        (path.match(/^\/payments\/pay\//) && method === 'GET')) {

      let customer, product, amount, description, save_card
      if (method === 'GET') {
        // Magic link: GET /payments/pay/:customer/:product?amount=100&desc=...
        const m = path.match(/^\/payments\/pay\/([^/]+)(?:\/([^/]+))?$/)
        if (!m) return json(res, 400, { error: 'Invalid pay URL' })
        customer = decodeURIComponent(m[1])
        product = decodeURIComponent(m[2] || 'activation')
        amount = parseFloat(url.searchParams.get('amount') || '0')
        description = url.searchParams.get('desc') || ''
        save_card = url.searchParams.get('save_card') !== '0'
      } else {
        const body = await parseBody(req)
        customer = body.customer
        product = body.product || 'activation'
        amount = parseFloat(body.amount || '0')
        description = body.description || ''
        save_card = body.save_card !== false
      }

      if (!customer) return json(res, 400, { error: 'customer required' })

      // Product catalog (simple built-in products)
      const products = {
        activation: { name: 'Frais d\'activation', unit_amount: 100, taxable: true },
        installation: { name: 'Frais d\'installation', unit_amount: 15000, taxable: true },
        router: { name: 'Routeur Wi-Fi', unit_amount: 12999, taxable: true },
        custom: { name: description || 'Paiement', unit_amount: Math.round((amount || 1) * 100), taxable: true },
      }

      const prod = products[product] || products.custom
      if (amount > 0) prod.unit_amount = Math.round(amount * 100) // override with explicit amount

      const stripeCustomerId = await ensureStripeCustomer(customer)

      // Build line items — product + taxes (TPS 5% + TVQ 9.975%)
      const lineItems = [
        {
          price_data: {
            currency: 'cad',
            unit_amount: prod.unit_amount,
            product_data: {
              name: prod.name,
              description: `Client: ${customer}`,
            },
          },
          quantity: 1,
        },
      ]

      // Add tax line items so Stripe shows the tax breakdown
      if (prod.taxable) {
        const tpsAmount = Math.round(prod.unit_amount * 0.05)
        const tvqAmount = Math.round(prod.unit_amount * 0.09975)
        lineItems.push({
          price_data: {
            currency: 'cad',
            unit_amount: tpsAmount,
            product_data: { name: 'TPS (5%)' },
          },
          quantity: 1,
        })
        lineItems.push({
          price_data: {
            currency: 'cad',
            unit_amount: tvqAmount,
            product_data: { name: 'TVQ (9.975%)' },
          },
          quantity: 1,
        })
      }

      const sessionParams = {
        customer: stripeCustomerId,
        mode: 'payment',
        payment_method_types: ['card'],
        line_items: lineItems,
        metadata: {
          erp_customer: customer,
          product: product,
          type: 'product_purchase',
        },
        success_url: `${HUB_PUBLIC_URL}/payments/return?type=success&session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${HUB_PUBLIC_URL}/payments/return?type=cancel`,
        locale: 'fr',
      }

      if (save_card) {
        sessionParams.payment_intent_data = {
          setup_future_usage: 'off_session',
          metadata: { erp_customer: customer, product, type: 'product_purchase' },
        }
      } else {
        sessionParams.payment_intent_data = {
          metadata: { erp_customer: customer, product, type: 'product_purchase' },
        }
      }

      const session = await stripeRequest('POST', '/checkout/sessions', sessionParams)
      const totalCents = lineItems.reduce((s, li) => s + li.price_data.unit_amount, 0)

      log(`Product checkout for ${customer}/${product}: ${session.id} ($${(totalCents / 100).toFixed(2)} incl. taxes)`)

      // For GET magic links, redirect directly to Stripe
      if (method === 'GET') {
        res.writeHead(302, { Location: session.url })
        return res.end()
      }

      return json(res, 200, {
        url: session.url,
        session_id: session.id,
        product,
        amount: totalCents / 100,
      })
    }

    // POST /payments/setup — save card without paying (Checkout in setup mode)
    if (path === '/payments/setup' && method === 'POST') {
      const body = await parseBody(req)
      const { customer } = body
      if (!customer) return json(res, 400, { error: 'customer required' })
      if (denyIfUnauthorized(req, res, customer, 'setup')) return

      const stripeCustomerId = await ensureStripeCustomer(customer)

      const session = await stripeRequest('POST', '/checkout/sessions', {
        customer: stripeCustomerId,
        mode: 'setup',
        payment_method_types: ['card'],
        metadata: { erp_customer: customer, type: 'card_setup' },
        success_url: `${HUB_PUBLIC_URL}/payments/return?type=card_added&session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${HUB_PUBLIC_URL}/payments/return?type=cancel`,
        locale: 'fr',
      })

      log(`Setup session created for ${customer}: ${session.id}`)
      return json(res, 200, { url: session.url, session_id: session.id })
    }

    // POST /payments/portal — Stripe Billing Portal (manage cards)
    if (path === '/payments/portal' && method === 'POST') {
      const body = await parseBody(req)
      const { customer } = body
      if (!customer) return json(res, 400, { error: 'customer required' })
      if (denyIfUnauthorized(req, res, customer, 'portal')) return

      const stripeCustomerId = await ensureStripeCustomer(customer)

      const session = await stripeRequest('POST', '/billing_portal/sessions', {
        customer: stripeCustomerId,
        return_url: `${PORTAL_URL}/#/me`,
      })

      return json(res, 200, { url: session.url })
    }

    // POST /payments/toggle-ppa — enable/disable auto-pay
    if (path === '/payments/toggle-ppa' && method === 'POST') {
      const body = await parseBody(req)
      const { customer, enabled } = body
      if (!customer) return json(res, 400, { error: 'customer required' })
      if (denyIfUnauthorized(req, res, customer, 'toggle-ppa')) return

      const methods = await getPaymentMethods(customer)
      const stripePm = methods.find(m => m.provider === 'Stripe')

      if (enabled && !stripePm?.stripe_customer_id) {
        return json(res, 400, { error: 'No Stripe payment method — customer must add a card first' })
      }

      if (enabled) {
        // Verify customer has at least one saved payment method on Stripe
        const pm = await stripeRequest('GET', '/payment_methods', {
          customer: stripePm.stripe_customer_id, type: 'card', limit: 1,
        })
        if (!pm.data?.length) {
          return json(res, 400, { error: 'No card on file — customer must add one via Stripe Portal' })
        }

        // Set the first card as default if none is set
        const cust = await stripeRequest('GET', `/customers/${stripePm.stripe_customer_id}`)
        if (!cust.invoice_settings?.default_payment_method) {
          await stripeRequest('POST', `/customers/${stripePm.stripe_customer_id}`, {
            invoice_settings: { default_payment_method: pm.data[0].id },
          })
        }
      }

      // Update ERPNext Payment Method
      if (stripePm) {
        await erp.update('Payment Method', stripePm.name, {
          is_auto_ppa: enabled ? 1 : 0,
        })
      }

      // Sync ppa_enabled flag on Customer doc
      await erp.update('Customer', customer, {
        ppa_enabled: enabled ? 1 : 0,
      })

      log(`PPA ${enabled ? 'enabled' : 'disabled'} for ${customer}`)
      return json(res, 200, { ok: true, ppa_enabled: !!enabled })
    }

    // POST /payments/charge — manually charge saved card (agent-triggered or cron)
    if (path === '/payments/charge' && method === 'POST') {
      const body = await parseBody(req)
      const { customer, amount: overrideAmount, invoice: targetInvoice } = body
      if (!customer) return json(res, 400, { error: 'customer required' })

      const methods = await getPaymentMethods(customer)
      const stripePm = methods.find(m => m.provider === 'Stripe' && m.stripe_customer_id)
      if (!stripePm) return json(res, 400, { error: 'No Stripe payment method for this customer' })

      // Get default payment method
      const cust = await stripeRequest('GET', `/customers/${stripePm.stripe_customer_id}`)
      const defaultPmId = cust.invoice_settings?.default_payment_method
      if (!defaultPmId) return json(res, 400, { error: 'No default card — customer must add one via portal' })

      // Calculate amount
      let amount = overrideAmount
      let invoiceName = targetInvoice || null
      if (!amount) {
        if (targetInvoice) {
          const inv = await getInvoiceDoc(targetInvoice)
          if (!inv) return json(res, 404, { error: 'Invoice not found' })
          amount = inv.grand_total || inv.outstanding_amount // grand_total includes taxes
          invoiceName = inv.name
        } else {
          const bal = await getCustomerBalance(customer)
          amount = bal.balance
        }
      }
      if (amount <= 0) return json(res, 400, { error: 'No balance due', balance: amount })

      const amountCents = Math.round(amount * 100)

      // GARDE anti-double-charge : facture déjà chargée par OPS → on refuse (évite le double-clic / re-déclenchement).
      if (invoiceName && await invoiceAlreadyChargedOps(invoiceName)) {
        return json(res, 200, { ok: false, skipped: 'already_charged', invoice: invoiceName, message: 'Cette facture a déjà une charge OPS réussie.' })
      }
      // Create off-session PaymentIntent
      const piMeta = { erp_customer: customer, type: 'ppa_charge' }
      if (invoiceName) piMeta.erp_invoice = invoiceName

      const pi = await stripeRequest('POST', '/payment_intents', {
        amount: amountCents,
        currency: 'cad',
        customer: stripePm.stripe_customer_id,
        payment_method: defaultPmId,
        off_session: 'true',
        confirm: 'true',
        metadata: piMeta,
      }, invoiceName ? 'ppa-' + invoiceName : 'ppachg-' + customer + '-' + amountCents) // clé d'idempotence (par facture, sinon par client+montant)

      log(`PPA charge for ${customer}: ${pi.id} ($${amount}) — status: ${pi.status}`)

      if (pi.status === 'succeeded') {
        await recordPayment(customer, amount, pi.id, 'Stripe', invoiceName)
        return json(res, 200, { ok: true, amount, payment_intent: pi.id, status: 'succeeded' })
      } else {
        return json(res, 200, { ok: false, amount, payment_intent: pi.id, status: pi.status })
      }
    }

    // POST /payments/send-link — send payment link to customer via SMS/email
    if (path === '/payments/send-link' && method === 'POST') {
      const body = await parseBody(req)
      const { customer, channel, invoice: targetInvoice } = body // channel: 'sms' | 'email' | 'both'
      if (!customer) return json(res, 400, { error: 'customer required' })

      let amount, sessionLineItem, meta
      if (targetInvoice) {
        const inv = await getInvoiceDoc(targetInvoice)
        if (!inv || inv.outstanding_amount <= 0) return json(res, 400, { error: 'Invoice not payable' })
        amount = inv.grand_total || inv.outstanding_amount // grand_total includes taxes
        sessionLineItem = {
          price_data: {
            currency: 'cad',
            unit_amount: Math.round(amount * 100),
            product_data: { name: `Facture ${targetInvoice}` },
          },
          quantity: 1,
        }
        meta = { erp_customer: customer, erp_invoice: targetInvoice, type: 'payment_link' }
      } else {
        const { balance } = await getCustomerBalance(customer)
        if (balance <= 0) return json(res, 400, { error: 'No outstanding balance' })
        amount = balance
        sessionLineItem = {
          price_data: {
            currency: 'cad',
            unit_amount: Math.round(amount * 100),
            product_data: { name: `Solde du — Balance due — ${customer}` },
          },
          quantity: 1,
        }
        meta = { erp_customer: customer, type: 'payment_link' }
      }

      const stripeCustomerId = await ensureStripeCustomer(customer)

      // Create a longer-lived Checkout Session (24h) for the link
      const session = await stripeRequest('POST', '/checkout/sessions', {
        customer: stripeCustomerId,
        mode: 'payment',
        payment_method_types: ['card'],
        line_items: [sessionLineItem],
        payment_intent_data: {
          setup_future_usage: 'off_session',
          metadata: meta,
        },
        metadata: meta,
        success_url: `${HUB_PUBLIC_URL}/payments/return?type=success&session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${HUB_PUBLIC_URL}/payments/return?type=cancel`,
        locale: 'fr',
      })

      const cust = await getCustomerDoc(customer)
      const sent = []

      if (channel === 'sms' || channel === 'both') {
        const phone = cust?.cell_phone || cust?.tel_home
        if (phone) {
          try {
            const twilio = require('./twilio')
            await twilio.sendSmsInternal(phone, `Targo: Votre solde est de ${amount}$. Payez en ligne: ${session.url}`)
            sent.push('sms')
          } catch (e) { log('Payment link SMS error:', e.message) }
        }
      }

      if (channel === 'email' || channel === 'both') {
        const email = cust?.email_billing || cust?.email_id
        if (email) {
          try {
            const { sendEmail } = require('./email')
            await sendEmail({
              to: email,
              subject: 'Targo — Payer votre solde / Pay your balance',
              html: `
                <div style="font-family:sans-serif;max-width:500px;margin:0 auto">
                  <img src="https://store.targo.ca/clients/images/logo_trans_noir.png" width="160" alt="Targo" /><br><br>
                  <p>Votre solde est de <strong>${amount}$</strong>.</p>
                  <p><a href="${session.url}" style="display:inline-block;padding:12px 32px;background:#4f46e5;color:#fff;border-radius:8px;text-decoration:none;font-weight:bold">Payer maintenant</a></p>
                  <br><p style="color:#888;font-size:12px">Ce lien est valide pour 24 heures. / This link is valid for 24 hours.</p>
                </div>`,
            })
            sent.push('email')
          } catch (e) { log('Payment link email error:', e.message) }
        }
      }

      log(`Payment link sent to ${customer} via ${sent.join(', ')} ($${amount})`)
      return json(res, 200, { ok: true, url: session.url, amount, sent })
    }

    // POST /payments/refund — refund a Stripe payment
    if (path === '/payments/refund' && method === 'POST') {
      const body = await parseBody(req)
      const { payment_entry, amount: refundAmount, reason } = body
      if (!payment_entry) return json(res, 400, { error: 'payment_entry required' })

      // Look up the Payment Entry in ERPNext to find the Stripe reference
      const pe = await erp.get('Payment Entry', payment_entry)
      if (!pe) return json(res, 404, { error: 'Payment Entry not found' })

      const refNo = pe.reference_no || ''
      const customer = pe.party
      const peAmount = pe.paid_amount || 0

      // Determine refund amount (full or partial)
      const finalAmount = refundAmount ? Math.min(refundAmount, peAmount) : peAmount
      const amountCents = Math.round(finalAmount * 100)

      // If the payment has a Stripe PaymentIntent or charge reference, refund via Stripe
      let stripeRefund = null
      if (refNo && (refNo.startsWith('pi_') || refNo.startsWith('ch_'))) {
        try {
          const params = {
            amount: amountCents,
            reason: reason || 'requested_by_customer',
          }
          // Stripe refund takes either payment_intent or charge
          if (refNo.startsWith('pi_')) params.payment_intent = refNo
          else params.charge = refNo

          stripeRefund = await stripeRequest('POST', '/refunds', params)
          log(`Stripe refund ${stripeRefund.id}: $${finalAmount} on ${refNo} for ${customer}`)
        } catch (e) {
          return json(res, 400, { error: `Stripe refund failed: ${e.message}` })
        }
      }

      // Create a Return Payment Entry in ERPNext (negative/reverse)
      try {
        const returnPe = {
          doctype: 'Payment Entry',
          payment_type: 'Pay',
          party_type: 'Customer',
          party: customer,
          paid_amount: finalAmount,
          received_amount: finalAmount,
          source_exchange_rate: 1,
          target_exchange_rate: 1,
          paid_from: 'Stripe - T',
          paid_to: 'Comptes clients - T',
          paid_from_account_currency: 'CAD',
          paid_to_account_currency: 'CAD',
          mode_of_payment: 'Stripe',
          reference_no: stripeRefund ? stripeRefund.id : `refund-${payment_entry}`,
          reference_date: new Date().toISOString().slice(0, 10),
          posting_date: new Date().toISOString().slice(0, 10),
          remarks: `Remboursement de ${payment_entry}${reason ? ' — ' + reason : ''}`,
          company: 'TARGO',
        }

        // Allocate against the original invoice if PE had references
        if (pe.references?.length) {
          returnPe.references = pe.references.map((ref, i) => ({
            reference_doctype: ref.reference_doctype,
            reference_name: ref.reference_name,
            allocated_amount: refundAmount
              ? Math.min(refundAmount, ref.allocated_amount)
              : ref.allocated_amount,
          }))
        }

        const result = await erp.create('Payment Entry', returnPe)
        let returnName = result.name

        // Submit the return entry (fetch full doc for modified timestamp)
        if (returnName) {
          const fullReturnDoc = await erp.get('Payment Entry', returnName)
          if (fullReturnDoc) {
            await erp.raw('/api/method/frappe.client.submit', {
              method: 'POST',
              body: JSON.stringify({ doc: fullReturnDoc }),
            })
          }
          log(`Return PE ${returnName} created and submitted for ${customer}`)
        }

        // Create Credit Note (return invoice) for the original invoice
        if (pe.references?.length) {
          for (const ref of pe.references) {
            if (ref.reference_doctype === 'Sales Invoice') {
              try {
                const origInv = await erp.get('Sales Invoice', ref.reference_name)
                if (origInv && origInv.docstatus === 1) {
                  const creditNote = {
                    doctype: 'Sales Invoice',
                    is_return: 1,
                    return_against: ref.reference_name,
                    customer: customer,
                    company: 'TARGO',
                    posting_date: new Date().toISOString().slice(0, 10),
                    items: (origInv.items || []).map(item => ({
                      item_code: item.item_code,
                      item_name: item.item_name,
                      qty: -(item.qty || 1),
                      rate: item.rate,
                      amount: -(item.amount || 0),
                      income_account: item.income_account,
                      cost_center: item.cost_center,
                    })),
                    taxes: (origInv.taxes || []).map(tax => ({
                      charge_type: tax.charge_type,
                      account_head: tax.account_head,
                      description: tax.description,
                      rate: tax.rate,
                    })),
                  }
                  const cnResult = await erp.create('Sales Invoice', creditNote)
                  const cnName = cnResult.name
                  if (cnName) {
                    const fullCnDoc = await erp.get('Sales Invoice', cnName)
                    if (fullCnDoc) {
                      await erp.raw('/api/method/frappe.client.submit', {
                        method: 'POST',
                        body: JSON.stringify({ doc: fullCnDoc }),
                      })
                    }
                    log(`Credit Note ${cnName} created for ${ref.reference_name}`)
                  }
                }
              } catch (e) {
                log(`Credit Note creation error for ${ref.reference_name}: ${e.message}`)
              }
            }
          }
        }

        // Notify via SSE
        sse.broadcast('payments', 'refund', { customer, amount: finalAmount, reference: stripeRefund?.id, payment_entry })

        return json(res, 200, {
          ok: true,
          amount: finalAmount,
          stripe_refund: stripeRefund ? { id: stripeRefund.id, status: stripeRefund.status } : null,
          return_entry: returnName || null,
        })
      } catch (e) {
        log(`Return PE creation error: ${e.message}`)
        // Stripe refund already happened, warn about ERPNext sync issue
        return json(res, 200, {
          ok: true,
          amount: finalAmount,
          stripe_refund: stripeRefund ? { id: stripeRefund.id, status: stripeRefund.status } : null,
          return_entry: null,
          warning: 'Stripe refunded but ERPNext return entry failed: ' + e.message,
        })
      }
    }

    // POST /payments/ppa-run — manually trigger a PPA cron run
    if (path === '/payments/ppa-run' && method === 'POST') {
      const result = await runPPACron()
      return json(res, 200, result)
    }

    // GET /payments/return — Stripe redirect handler: look up session, generate magic link, redirect to portal
    if (path === '/payments/return' && method === 'GET') {
      return handlePaymentReturn(req, res, url)
    }

    // POST /webhook/stripe — Stripe webhook handler
    if (path === '/webhook/stripe' && method === 'POST') {
      return handleWebhook(req, res)
    }

    return json(res, 404, { error: 'Payment endpoint not found' })
  } catch (e) {
    log('Payment error:', e.message)
    return json(res, 500, { error: e.message })
  }
}

// ────────────────────────────────────────────
// Payment return handler (Stripe redirect → magic link → portal)
// ────────────────────────────────────────────
async function handlePaymentReturn (req, res, url) {
  const sessionId = url.searchParams.get('session_id') || ''
  const type = url.searchParams.get('type') || 'success' // success | cancel | card_added
  const invoice = url.searchParams.get('invoice') || ''

  let customerId = ''
  let customerName = ''
  let email = ''

  // Try to look up customer from Stripe session
  if (sessionId) {
    try {
      const session = await stripeRequest('GET', `/checkout/sessions/${sessionId}`)
      customerId = session.metadata?.erp_customer || ''
      if (customerId) {
        const cust = await getCustomerDoc(customerId)
        if (cust) {
          customerName = cust.customer_name || ''
          email = cust.email_billing || cust.email_id || ''
        }
      }
    } catch (e) {
      log('Payment return: session lookup failed:', e.message)
    }
  }

  // Build portal redirect URL
  let portalPath
  if (type === 'cancel') portalPath = '/paiement/annule'
  else if (type === 'card_added') portalPath = '/paiement/carte-ajoutee'
  else portalPath = '/paiement/merci'

  const params = new URLSearchParams()
  if (invoice) params.set('invoice', invoice)

  // Generate customer magic link token if we have a customer
  if (customerId) {
    const { generateCustomerToken } = require('./magic-link')
    const token = generateCustomerToken(customerId, customerName, email, 24)
    params.set('token', token)
  }

  const qs = params.toString()
  const redirectUrl = `${PORTAL_URL}/#${portalPath}${qs ? '?' + qs : ''}`

  log(`Payment return: type=${type} customer=${customerId || 'unknown'} → ${redirectUrl}`)

  // 302 redirect to portal with magic link token
  res.writeHead(302, { Location: redirectUrl })
  res.end()
}

// ────────────────────────────────────────────
// Webhook handler
// ────────────────────────────────────────────
async function handleWebhook (req, res) {
  const chunks = []
  req.on('data', c => chunks.push(c))
  await new Promise(r => req.on('end', r))
  const rawBody = Buffer.concat(chunks).toString()

  // Verify webhook signature
  const sigHeader = req.headers['stripe-signature'] || ''
  if (STRIPE_WEBHOOK_SECRET && !verifyWebhookSignature(rawBody, sigHeader)) {
    log('Webhook signature verification FAILED')
    return json(res, 400, { error: 'Invalid signature' })
  }

  let event
  try { event = JSON.parse(rawBody) } catch { return json(res, 400, { error: 'Invalid JSON' }) }

  log(`Stripe webhook: ${event.type}`)

  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object
        const customer = session.metadata?.erp_customer
        if (!customer) break

        if (session.mode === 'payment') {
          const amount = (session.amount_total || 0) / 100
          const piId = session.payment_intent
          const invoiceName = session.metadata?.erp_invoice || null

          // Record payment in ERPNext (with idempotency check)
          await recordPayment(customer, amount, piId, 'Stripe Checkout', invoiceName)
          log(`Payment recorded: ${customer} $${amount} (PI: ${piId})${invoiceName ? ' inv: ' + invoiceName : ''}`)

          // Notify ops-app via SSE
          sse.broadcast('payments', 'payment_received', { customer, amount, reference: piId, invoice: invoiceName })

          // Fire flow trigger (on_payment_received). Non-blocking: log + swallow.
          try {
            require('./flow-runtime').dispatchEvent('on_payment_received', {
              doctype: 'Sales Invoice', docname: invoiceName, customer,
              variables: { amount, payment_intent: piId },
            }).catch(e => log('flow trigger on_payment_received failed:', e.message))
          } catch (e) { log('flow trigger load error:', e.message) }
        } else if (session.mode === 'setup') {
          // Card saved — update Payment Method with card info
          const setupIntent = await stripeRequest('GET', `/setup_intents/${session.setup_intent}`)
          if (setupIntent.payment_method) {
            const pm = await stripeRequest('GET', `/payment_methods/${setupIntent.payment_method}`)
            await updateCardInfo(customer, session.customer, pm)

            // Set as default payment method
            await stripeRequest('POST', `/customers/${session.customer}`, {
              invoice_settings: { default_payment_method: setupIntent.payment_method },
            })
          }
          log(`Card saved for ${customer}`)
          sse.broadcast('payments', 'card_saved', { customer })
        }
        break
      }

      case 'payment_intent.succeeded': {
        const pi = event.data.object
        const customer = pi.metadata?.erp_customer
        if (!customer) break

        // Only process PPA charges here (checkout payments handled above)
        if (pi.metadata?.type === 'ppa_charge') {
          const amount = (pi.amount || 0) / 100
          const invoiceName = pi.metadata?.erp_invoice || null
          await recordPayment(customer, amount, pi.id, 'Stripe', invoiceName)
          log(`PPA payment recorded: ${customer} $${amount}`)
          sse.broadcast('payments', 'ppa_payment', { customer, amount, reference: pi.id })
        }
        break
      }

      case 'payment_intent.payment_failed': {
        const pi = event.data.object
        const customer = pi.metadata?.erp_customer
        if (!customer) break
        const errMsg = pi.last_payment_error?.message || 'Unknown error'
        log(`Payment failed: ${customer} — ${errMsg}`)

        // Send failure notification to customer via SMS
        try {
          const cust = await getCustomerDoc(customer)
          const phone = cust?.cell_phone || cust?.tel_home
          if (phone) {
            const twilio = require('./twilio')
            await twilio.sendSmsInternal(phone,
              `Targo: Votre paiement automatique a echoue. Veuillez mettre a jour votre carte de paiement sur ${PORTAL_URL}/#/me ou nous contacter.`)
          }
        } catch (e) { log('PPA failure SMS error:', e.message) }

        sse.broadcast('payments', 'payment_failed', { customer, error: errMsg })
        break
      }

      case 'customer.subscription.deleted':
      case 'payment_method.detached': {
        // Card removed — update ERPNext
        log(`Stripe event ${event.type}: ${JSON.stringify(event.data.object?.metadata || {})}`)
        break
      }
    }
  } catch (e) {
    log('Webhook processing error:', e.message)
  }

  // Always return 200 to Stripe
  return json(res, 200, { received: true })
}

// ────────────────────────────────────────────
// Payment recording in ERPNext (with idempotency)
// ────────────────────────────────────────────
async function recordPayment (customerId, amount, reference, modeOfPayment, targetInvoice) {
  // Idempotency guard: check if PE with this reference already exists
  if (reference && await paymentEntryExists(reference)) {
    log(`Payment Entry with reference ${reference} already exists — skipping (idempotent)`)
    return null
  }

  // Build references list
  const refs = []

  if (targetInvoice) {
    // Allocate to specific invoice
    const inv = await getInvoiceDoc(targetInvoice)
    if (inv && (inv.outstanding_amount > 0 || inv.grand_total > 0)) {
      const allocate = Math.min(amount, inv.outstanding_amount || inv.grand_total)
      refs.push({
        reference_doctype: 'Sales Invoice',
        reference_name: targetInvoice,
        allocated_amount: allocate,
      })
      // If amount exceeds this invoice, FIFO the rest
      let remaining = Math.round((amount - allocate) * 100) / 100
      if (remaining > 0) {
        const { invoices } = await getCustomerBalance(customerId)
        for (const other of invoices) {
          if (remaining <= 0) break
          if (other.name === targetInvoice) continue
          const alloc = Math.min(remaining, other.outstanding_amount)
          refs.push({
            reference_doctype: 'Sales Invoice',
            reference_name: other.name,
            allocated_amount: alloc,
          })
          remaining = Math.round((remaining - alloc) * 100) / 100
        }
      }
    }
  } else {
    // FIFO allocation across all unpaid invoices
    const { invoices } = await getCustomerBalance(customerId)
    if (!invoices.length) {
      log(`No unpaid invoices for ${customerId}, recording unallocated payment`)
    }
    let remaining = amount
    for (const inv of invoices) {
      if (remaining <= 0) break
      const allocate = Math.min(remaining, inv.outstanding_amount)
      refs.push({
        reference_doctype: 'Sales Invoice',
        reference_name: inv.name,
        allocated_amount: allocate,
      })
      remaining = Math.round((remaining - allocate) * 100) / 100
    }
  }

  const pe = {
    doctype: 'Payment Entry',
    payment_type: 'Receive',
    party_type: 'Customer',
    party: customerId,
    paid_amount: amount,
    received_amount: amount,
    target_exchange_rate: 1,
    paid_to: 'Stripe - T',  // Cash/Bank account
    mode_of_payment: modeOfPayment || 'Stripe',
    reference_no: reference,
    reference_date: new Date().toISOString().slice(0, 10),
    posting_date: new Date().toISOString().slice(0, 10),
    references: refs,
  }

  try {
    const result = await erp.create('Payment Entry', pe)
    if (result.ok && result.name) {
      // Submit the payment entry
      const peName = result.name

      // Fetch full doc for frappe.client.submit (PostgreSQL compatibility)
      const fullPeDoc = await erp.get('Payment Entry', peName)
      if (fullPeDoc) {
        fullPeDoc.docstatus = 1
        await erp.raw('/api/method/frappe.client.submit', {
          method: 'POST',
          body: JSON.stringify({ doc: fullPeDoc }),
        })
      }

      log(`Payment Entry ${peName} created and submitted for ${customerId}`)

      // Update outstanding_amount on allocated invoices (ERPNext v16 PG doesn't auto-update)
      for (const ref of refs) {
        if (ref.reference_doctype === 'Sales Invoice') {
          const inv = await getInvoiceDoc(ref.reference_name)
          if (inv) {
            const newOutstanding = Math.max(0, Math.round((inv.outstanding_amount - ref.allocated_amount) * 100) / 100)
            const newStatus = newOutstanding <= 0 ? 'Paid' : (newOutstanding < inv.grand_total ? 'Partly Paid' : 'Unpaid')
            await erp.update('Sales Invoice', ref.reference_name, {
              outstanding_amount: newOutstanding,
              status: newStatus,
            })
          }
        }
      }

      return peName
    } else {
      log(`Failed to create Payment Entry: ${result.error || 'unknown error'}`)
    }
  } catch (e) {
    log(`Payment Entry creation error: ${e.message}`)
  }
}

// Update card info on the ERPNext Payment Method record (card details fetched live from Stripe API)
async function updateCardInfo (customerId, stripeCustomerId, paymentMethod) {
  // Card details are fetched live from Stripe in the /payments/methods endpoint
  // No need to store them in ERPNext — just log for tracking
  const card = paymentMethod.card || {}
  log(`Card saved for ${customerId}: ${card.brand} ****${card.last4} exp ${card.exp_month}/${card.exp_year}`)
}

// ────────────────────────────────────────────
// PPA Cron — Daily automated billing
// ────────────────────────────────────────────
// Garde anti-double-charge (cœur du correctif) : une facture déjà couverte par un PaymentIntent OPS RÉUSSI ne doit JAMAIS être rechargée — peu importe restart, jour, ou échec de recordPayment.
// FAIL-SAFE : en cas de doute (recherche Stripe en échec), on retourne TRUE (= NE PAS charger). C'est l'inverse du bug d'origine où l'échec menait à recharger.
async function invoiceAlreadyChargedOps (invoice) {
  if (!invoice) return false
  try {
    const q = encodeURIComponent(`metadata['erp_invoice']:'${invoice}' AND metadata['type']:'ppa_charge' AND status:'succeeded'`)
    const r = await stripeRequest('GET', `/payment_intents/search?query=${q}&limit=1`)
    return !!(r && Array.isArray(r.data) && r.data.length)
  } catch (e) { log(`invoiceAlreadyChargedOps(${invoice}) échec recherche → prudence: NE PAS charger (${e.message})`); return true }
}

async function runPPACron () {
  log('PPA Cron: starting run...')
  const results = { charged: [], failed: [], skipped: [], errors: [] }

  try {
    // Find all Payment Methods with is_auto_ppa=1 and provider=Stripe
    const ppaMethods = await erp.list('Payment Method', {
      filters: [
        ['is_auto_ppa', '=', 1],
        ['provider', '=', 'Stripe'],
        ['stripe_customer_id', '!=', ''],
      ],
      fields: ['name', 'customer', 'stripe_customer_id'],
      limit: 1000,
    })

    log(`PPA Cron: ${ppaMethods.length} customers with auto-pay enabled`)

    for (const pm of ppaMethods) {
      const customer = pm.customer
      try {
        // Check outstanding balance
        const { balance, invoices } = await getCustomerBalance(customer)
        if (balance <= 0) {
          results.skipped.push({ customer, reason: 'no_balance' })
          continue
        }

        // Get default payment method from Stripe
        const cust = await stripeRequest('GET', `/customers/${pm.stripe_customer_id}`)
        const defaultPmId = cust.invoice_settings?.default_payment_method
        if (!defaultPmId) {
          results.skipped.push({ customer, reason: 'no_default_card' })
          continue
        }

        // Charge the oldest unpaid invoice (one at a time for cleaner reconciliation)
        const oldestInv = invoices[0]
        // GARDE anti-double-charge : si cette facture a déjà une charge OPS réussie, on saute (évite le re-débit quotidien qui a causé l'incident).
        if (await invoiceAlreadyChargedOps(oldestInv.name)) { results.skipped.push({ customer, invoice: oldestInv.name, reason: 'already_charged_ops' }); continue }
        const amount = oldestInv.grand_total || oldestInv.outstanding_amount // grand_total includes taxes
        const amountCents = Math.round(amount * 100)

        const pi = await stripeRequest('POST', '/payment_intents', {
          amount: amountCents,
          currency: 'cad',
          customer: pm.stripe_customer_id,
          payment_method: defaultPmId,
          off_session: 'true',
          confirm: 'true',
          metadata: {
            erp_customer: customer,
            erp_invoice: oldestInv.name,
            type: 'ppa_charge',
          },
        }, 'ppa-' + oldestInv.name) // clé d'idempotence Stripe par facture

        if (pi.status === 'succeeded') {
          await recordPayment(customer, amount, pi.id, 'Stripe', oldestInv.name)
          results.charged.push({ customer, invoice: oldestInv.name, amount, pi: pi.id })
          log(`PPA charged ${customer}: $${amount} for ${oldestInv.name}`)
        } else {
          results.failed.push({ customer, invoice: oldestInv.name, amount, pi: pi.id, status: pi.status })
          log(`PPA charge pending/failed ${customer}: ${pi.status}`)
        }
      } catch (e) {
        results.errors.push({ customer, error: e.message })
        log(`PPA charge error for ${customer}: ${e.message}`)
      }
    }
  } catch (e) {
    log('PPA Cron global error:', e.message)
    results.errors.push({ error: e.message })
  }

  log(`PPA Cron done: ${results.charged.length} charged, ${results.failed.length} failed, ${results.skipped.length} skipped, ${results.errors.length} errors`)

  // Notify ops via SSE
  sse.broadcast('payments', 'ppa_cron_complete', {
    charged: results.charged.length,
    failed: results.failed.length,
    skipped: results.skipped.length,
    errors: results.errors.length,
  })

  return results
}

// Start the PPA cron scheduler (called from server.js)
let _ppaCronStarted = false
function startPPACron () {
  if (_ppaCronStarted) { log('PPA Cron : déjà démarré dans ce process — 2e appel ignoré (anti-double-planification → évite N timers qui chargent N fois)'); return }
  _ppaCronStarted = true
  function scheduleNext () {
    const now = new Date()
    const target = new Date(now)
    target.setUTCHours(11, 0, 0, 0) // 06:00 EST = 11:00 UTC
    if (target <= now) target.setDate(target.getDate() + 1)
    const delay = target - now
    log(`PPA Cron: next run at ${target.toISOString()} (in ${Math.round(delay / 60000)} min)`)

    setTimeout(async () => {
      try { await runPPACron() } catch (e) { log('PPA Cron error:', e.message) }
      scheduleNext()
    }, delay)
  }

  scheduleNext()
  log('PPA Cron scheduler started')
}

module.exports = { handle, startPPACron, runPPACron, getCustomerBalance }
