'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: `

Votre solde est de ${amount}$.
Ce lien est valide pour 24 heures. / This link is valid for 24 hours.