gigafibre-fsm/services/targo-hub/lib/payments.js
louispaulb 9fda9eb0b0 refactor(targo-hub): add types.js, migrate acceptance+payments, drop apps/field
- lib/types.js: single source of truth for Dispatch Job status + priority enums.
  Eliminates hard-coded 'In Progress'/'in_progress'/'Completed'/'done' checks
  scattered across tech-mobile, acceptance, dispatch. Includes CLIENT_TYPES_JS
  snippet for embedding in SSR <script> blocks (no require() needed).

- lib/tech-mobile.js: applies types.js predicates (isInProgress, isTerminal,
  isDone, isUrgent) both server-side and client-side via ${CLIENT_TYPES_JS}
  template injection. Single aliasing point for future status renames.

- lib/acceptance.js: migrated 7 erpFetch + 2 erpRequest sites to erp.js wrapper.
  Removed duplicate "Lien expiré" HTML (now ui.pageExpired()). Dispatch Job
  creation uses types.JOB_STATUS + types.JOB_PRIORITY.

- lib/payments.js: migrated 15 erpFetch + 9 erpRequest sites to erp.js wrapper.
  Live Stripe flows preserved exactly — frappe.client.submit calls kept as
  erp.raw passthroughs (fetch-full-doc-then-submit pattern intact). Includes
  refund → Return PE → Credit Note lifecycle, PPA cron, idempotency guard.

- apps/field/ deleted: transitional Quasar PWA fully retired in favor of
  SSR tech-mobile at /t/{jwt}. Saves 14k lines of JS, PWA icons, and
  infra config. Docs already marked it "retiring".

Smoke-tested on prod:
  /payments/balance/:customer (200, proper shape)
  /payments/methods/:customer (200, Stripe cards live-fetched)
  /dispatch/calendar/:tech.ics (200, VCALENDAR)
  /t/{jwt} (55KB render, no errors)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-22 23:18:25 -04:00

1377 lines
54 KiB
JavaScript

'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')
// 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 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) {
// 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 erp.update('Payment Method', existing.name, { stripe_customer_id: stripeId })
} else {
await erp.create('Payment Method', {
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 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)
// 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: `
<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
// ────────────────────────────────────────────
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]
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 }