'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, erpFetch, erpRequest } = require('./helpers') const sse = require('./sse') // 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 = {}) { 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', ...(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) { if (!STRIPE_WEBHOOK_SECRET) return true // Skip in dev if no secret configured 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 filters = encodeURIComponent(JSON.stringify([ ['customer', '=', customerId], ['outstanding_amount', '>', 0], ['docstatus', '=', 1], ])) const fields = encodeURIComponent(JSON.stringify(['name', 'posting_date', 'grand_total', 'outstanding_amount'])) const res = await erpFetch(`/api/resource/Sales%20Invoice?filters=${filters}&fields=${fields}&order_by=posting_date asc&limit_page_length=100`) if (res.status !== 200) return { balance: 0, invoices: [] } const invoices = res.data?.data || [] const balance = invoices.reduce((sum, inv) => sum + (inv.outstanding_amount || 0), 0) return { balance: Math.round(balance * 100) / 100, invoices } } async function getInvoiceDoc (invoiceName) { const res = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(invoiceName)}?fields=["name","customer","customer_name","posting_date","due_date","grand_total","outstanding_amount","status","docstatus","currency"]`) if (res.status !== 200) return null return res.data?.data || null } async function getCustomerDoc (customerId) { const res = await erpFetch(`/api/resource/Customer/${encodeURIComponent(customerId)}`) if (res.status !== 200) return null return res.data?.data || null } async function getPaymentMethods (customerId) { const filters = encodeURIComponent(JSON.stringify([['customer', '=', customerId]])) const fields = encodeURIComponent(JSON.stringify([ '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', ])) const res = await erpFetch(`/api/resource/Payment%20Method?filters=${filters}&fields=${fields}&limit_page_length=20`) if (res.status !== 200) { log('getPaymentMethods error:', JSON.stringify(res.data).slice(0, 300)) return [] } return res.data?.data || [] } // Check for existing Payment Entry with same reference_no (idempotency) async function paymentEntryExists (referenceNo) { if (!referenceNo) return false const filters = encodeURIComponent(JSON.stringify([ ['reference_no', '=', referenceNo], ['docstatus', '!=', 2], // not cancelled ])) const res = await erpFetch(`/api/resource/Payment%20Entry?filters=${filters}&fields=["name"]&limit_page_length=1`) if (res.status !== 200) return false return (res.data?.data || []).length > 0 } // ──────────────────────────────────────────── // Stripe Customer management // ──────────────────────────────────────────── async function ensureStripeCustomer (customerId) { // Check if we already have a stripe_customer_id in Payment Method const methods = await getPaymentMethods(customerId) const stripeMethods = methods.filter(m => m.provider === 'Stripe' && m.stripe_customer_id) if (stripeMethods.length) return stripeMethods[0].stripe_customer_id // Look up customer info from ERPNext const cust = await getCustomerDoc(customerId) if (!cust) throw new Error('Customer not found: ' + customerId) 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 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 } // Create new Stripe customer 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 erpRequest('PUT', `/api/resource/Payment%20Method/${existing.name}`, { stripe_customer_id: stripeId }) } else { await erpRequest('POST', '/api/resource/Payment%20Method', { customer: customerId, provider: 'Stripe', stripe_customer_id: stripeId, }) } } // ──────────────────────────────────────────── // 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 result = await getCustomerBalance(decodeURIComponent(balMatch[1])) return json(res, 200, result) } // GET /payments/methods/:customer const methMatch = path.match(/^\/payments\/methods\/(.+)$/) if (methMatch && method === 'GET') { const customerId = decodeURIComponent(methMatch[1]) 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/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' }) 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' }) 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 (!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' }) 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' }) 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' }) 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 erpRequest('PUT', `/api/resource/Payment%20Method/${stripePm.name}`, { is_auto_ppa: enabled ? 1 : 0, }) } // Sync ppa_enabled flag on Customer doc await erpRequest('PUT', `/api/resource/Customer/${encodeURIComponent(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) // 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, }) 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: `
Targo

Votre solde est de ${amount}$.

Payer maintenant


Ce lien est valide pour 24 heures. / This link is valid for 24 hours.

`, }) 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 peRes = await erpFetch(`/api/resource/Payment%20Entry/${encodeURIComponent(payment_entry)}`) if (peRes.status !== 200) return json(res, 404, { error: 'Payment Entry not found' }) const pe = peRes.data?.data 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 erpRequest('POST', '/api/resource/Payment%20Entry', returnPe) let returnName = result.data?.data?.name // Submit the return entry (fetch full doc for modified timestamp) if (returnName) { const fullReturnRes = await erpFetch(`/api/resource/Payment%20Entry/${returnName}`) const fullReturnDoc = fullReturnRes.data?.data if (fullReturnDoc) { await erpFetch('/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 invRes = await erpFetch(`/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`) const origInv = invRes.data?.data 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 erpRequest('POST', '/api/resource/Sales%20Invoice', creditNote) const cnName = cnResult.data?.data?.name if (cnName) { const fullCnRes = await erpFetch(`/api/resource/Sales%20Invoice/${cnName}`) const fullCnDoc = fullCnRes.data?.data if (fullCnDoc) { await erpFetch('/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 erpRequest('POST', '/api/resource/Payment%20Entry', pe) if (result.status === 200 && result.data?.data?.name) { // Submit the payment entry const peName = result.data.data.name // Fetch full doc for frappe.client.submit (PostgreSQL compatibility) const fullPeRes = await erpFetch(`/api/resource/Payment%20Entry/${peName}`) const fullPeDoc = fullPeRes.data?.data if (fullPeDoc) { fullPeDoc.docstatus = 1 await erpFetch('/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 erpRequest('PUT', `/api/resource/Sales%20Invoice/${encodeURIComponent(ref.reference_name)}`, { outstanding_amount: newOutstanding, status: newStatus, }) } } } return peName } else { log(`Failed to create Payment Entry: ${JSON.stringify(result.data).slice(0, 500)}`) } } 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 // ──────────────────────────────────────────── 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 filters = encodeURIComponent(JSON.stringify([ ['is_auto_ppa', '=', 1], ['provider', '=', 'Stripe'], ['stripe_customer_id', '!=', ''], ])) const fields = encodeURIComponent(JSON.stringify(['name', 'customer', 'stripe_customer_id'])) const res = await erpFetch(`/api/resource/Payment%20Method?filters=${filters}&fields=${fields}&limit_page_length=1000`) const ppaMethods = res.data?.data || [] 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] 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', }, }) 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) function startPPACron () { // Run daily at 06:00 EST (11:00 UTC) const INTERVAL_MS = 24 * 60 * 60 * 1000 // 24 hours 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 }