- 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>
1377 lines
54 KiB
JavaScript
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 }
|