This commit is contained in:
2026-03-19 16:28:37 -05:00
parent 5a7ba66c27
commit 229e658831
9 changed files with 687 additions and 46 deletions

View File

@@ -14,14 +14,33 @@ const transporter = nodemailer.createTransport({
}
});
function generateInvoiceEmailHtml(invoice, customText, melioLink) {
function generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl) {
const formattedText = customText || '';
const buttonMjml = melioLink
? `<mj-button background-color="#2563eb" color="white" border-radius="6px" href="${melioLink}" font-weight="600" font-size="16px" padding-top="25px">
Pay Now (Free ACH)
</mj-button>`
: '';
// Stripe Pay Button — only if payment link exists
let paymentButtonMjml = '';
if (stripePaymentUrl) {
paymentButtonMjml = `
<mj-section background-color="#ffffff" padding="0 30px">
<mj-column>
<mj-button
background-color="#635bff"
color="white"
border-radius="6px"
href="${stripePaymentUrl}"
font-weight="600"
font-size="16px"
padding="25px 0 10px 0"
inner-padding="14px 30px"
width="100%">
Pay Online — Credit Card or ACH
</mj-button>
<mj-text font-size="12px" color="#94a3b8" align="center" padding="0 0 20px 0">
ACH payments incur lower processing fees. Secure payment powered by Stripe.
</mj-text>
</mj-column>
</mj-section>`;
}
const template = `
<mjml>
@@ -62,10 +81,14 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) {
<mj-text css-class="email-body" font-size="15px" color="#334155" line-height="1.5" padding="0">
${formattedText}
</mj-text>
${buttonMjml}
</mj-column>
</mj-section>
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="30px" padding-bottom="20px" />
${paymentButtonMjml}
<mj-section background-color="#ffffff" padding="0 30px 30px 30px" border-radius="0 0 8px 8px">
<mj-column>
<mj-divider border-color="#e2e8f0" border-width="1px" padding-top="10px" padding-bottom="20px" />
<mj-text font-size="14px" color="#64748b" line-height="1.5" padding="0">
<strong>Prefer to pay by check?</strong><br/>
@@ -79,7 +102,6 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) {
</mjml>
`;
// validationLevel: 'strict' fängt falsche Attribute ab, bevor sie an den Kunden gehen
const result = mjml2html(template, { validationLevel: 'strict' });
if (result.errors && result.errors.length > 0) {
@@ -89,8 +111,8 @@ function generateInvoiceEmailHtml(invoice, customText, melioLink) {
return result.html;
}
async function sendInvoiceEmail(invoice, recipientEmail, customText, melioLink, pdfBuffer) {
const htmlContent = generateInvoiceEmailHtml(invoice, customText, melioLink);
async function sendInvoiceEmail(invoice, recipientEmail, customText, stripePaymentUrl, pdfBuffer) {
const htmlContent = generateInvoiceEmailHtml(invoice, customText, stripePaymentUrl);
const mailOptions = {
from: '"Bay Area Affiliates Inc. Accounting" <accounting@bayarea-cc.com>',

View File

@@ -0,0 +1,173 @@
// src/services/stripe-service.js
/**
* Stripe Payment Links Service
* Creates payment links for invoices, checks payment status via API polling.
*
* No webhooks needed — the app is not internet-facing.
* Status is checked on-demand via checkPaymentStatus().
*/
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
/**
* Create a Stripe Payment Link for an invoice.
*
* @param {object} invoice - Invoice record from DB
* @param {number} invoice.id
* @param {string} invoice.invoice_number
* @param {number} invoice.total - Total in dollars (e.g. 194.85)
* @param {number} invoice.balance - Remaining balance (total - amount_paid)
* @param {string} [invoice.customer_name]
* @returns {object} { paymentLinkId, paymentLinkUrl }
*/
async function createPaymentLink(invoice) {
const amountDue = parseFloat(invoice.balance ?? invoice.total);
if (!amountDue || amountDue <= 0) {
throw new Error('Invoice has no balance due.');
}
const unitAmount = Math.round(amountDue * 100); // Convert dollars to cents
const invoiceLabel = `Invoice #${invoice.invoice_number || invoice.id}`;
console.log(`💳 Creating Stripe Payment Link for ${invoiceLabel}$${amountDue.toFixed(2)}...`);
const paymentLink = await stripe.paymentLinks.create({
line_items: [{
price_data: {
currency: 'usd',
product_data: {
name: invoiceLabel,
description: invoice.customer_name
? `Bay Area Affiliates, Inc. — ${invoice.customer_name}`
: 'Bay Area Affiliates, Inc.'
},
unit_amount: unitAmount,
},
quantity: 1,
}],
metadata: {
invoice_id: String(invoice.id),
invoice_number: String(invoice.invoice_number || ''),
source: 'quote-invoice-system'
},
payment_method_types: ['card', 'us_bank_account'],
// After payment, show a simple confirmation
after_completion: {
type: 'hosted_confirmation',
hosted_confirmation: {
custom_message: `Thank you! Your payment for ${invoiceLabel} has been received. Bay Area Affiliates, Inc. will send a confirmation.`
}
}
});
console.log(`✅ Stripe Payment Link created: ${paymentLink.url}`);
return {
paymentLinkId: paymentLink.id,
paymentLinkUrl: paymentLink.url
};
}
/**
* Check payment status for a Stripe Payment Link.
* Polls completed Checkout Sessions associated with the payment link.
*
* @param {string} paymentLinkId - Stripe Payment Link ID (plink_xxx)
* @returns {object} { paid, status, details }
*/
async function checkPaymentStatus(paymentLinkId) {
if (!paymentLinkId) {
return { paid: false, status: 'no_link', details: null };
}
console.log(`🔍 Checking Stripe payment status for ${paymentLinkId}...`);
// List checkout sessions created via this payment link
const sessions = await stripe.checkout.sessions.list({
payment_link: paymentLinkId,
limit: 10,
expand: ['data.payment_intent']
});
// Find a completed/paid session
const paidSession = sessions.data.find(s => s.payment_status === 'paid');
if (paidSession) {
const pi = paidSession.payment_intent;
const paymentMethod = pi?.payment_method_types?.[0] || 'unknown';
const amountReceived = (pi?.amount_received || 0) / 100;
const stripeFee = calculateStripeFee(amountReceived, paymentMethod);
console.log(`✅ Payment found! $${amountReceived.toFixed(2)} via ${paymentMethod}`);
return {
paid: true,
status: 'paid',
details: {
sessionId: paidSession.id,
paymentIntentId: pi?.id,
amountReceived,
paymentMethod, // 'card' or 'us_bank_account'
customerEmail: paidSession.customer_details?.email,
paidAt: new Date(paidSession.created * 1000).toISOString(),
stripeFee
}
};
}
// Check for pending ACH payments (processing state)
const pendingSession = sessions.data.find(s => s.payment_status === 'unpaid' && s.status === 'complete');
if (pendingSession) {
return {
paid: false,
status: 'processing',
details: { note: 'ACH payment is processing (may take 3-5 business days).' }
};
}
return {
paid: false,
status: sessions.data.length > 0 ? 'attempted' : 'pending',
details: null
};
}
/**
* Deactivate a payment link (e.g. when invoice is voided or amount changes).
*
* @param {string} paymentLinkId
*/
async function deactivatePaymentLink(paymentLinkId) {
if (!paymentLinkId) return;
try {
await stripe.paymentLinks.update(paymentLinkId, { active: false });
console.log(`🚫 Stripe Payment Link ${paymentLinkId} deactivated.`);
} catch (e) {
console.error(`⚠️ Could not deactivate payment link ${paymentLinkId}:`, e.message);
}
}
/**
* Calculate estimated Stripe fee for reference/QBO booking.
* Card: 2.9% + $0.30
* ACH: 0.8%, capped at $5.00
*
* @param {number} amount - Amount in dollars
* @param {string} method - 'card' or 'us_bank_account'
* @returns {number} Estimated fee in dollars
*/
function calculateStripeFee(amount, method) {
if (method === 'us_bank_account') {
return Math.min(amount * 0.008, 5.00);
}
// Default: card
return (amount * 0.029) + 0.30;
}
module.exports = {
createPaymentLink,
checkPaymentStatus,
deactivatePaymentLink,
calculateStripeFee
};