[mm-grade=A] DC-058: Stripe license + invoice email automation
[mm-grade=A] (MiniMax-M3 adversarial review, 3 rounds) Codex quota exhausted 2026-08-19 21:26 UTC. Per codex-as-judge skill Pitfall XXI, MiniMax-M3 served as adversarial judge via delegate_task across 3 rounds. Final grade: A. No blocking defects remaining. Round 1 (initial: C — 14 issues): CRITICAL/HIGH fixed: 1. Layer-2 delivery idempotency (different event + same session) 2. Mislabeled idempotency test (#2 was layer-1 not layer-2) 3. CRLF test was vacuous (regex matched space-after-colon) 4. Currency: native symbols for EUR/GBP/JPY/etc, ISO code fallback 5. PDF graceful degradation on poison-pill inputs 6. Retry uses claim.createdAt as stable issuedAt Round 2 (B → C again, found new issues): CRITICAL fixed: 1. amountCents accepted string/NaN/Infinity/negative → rendered $0.00 silently (financial-document bug) 2. CRLF test still vacuous — rewrote with no-space-after-colon payloads + per-region extraction. Mutation-tested: deleting stripControlChars → test FAILS. 3. Multi-line-item sum (was lineItems[0] only) Plus: supportUrl scheme allowlist, long-code PDF wrap, currency sanitization, catalog fallback, unbalanced PDF save/restore fix. Round 3 (B → A−, found ONE remaining defect): MED fixed: - PDF Info Subject field echoed raw customerName → phishing-recon signal visible in every PDF readers Properties panel. Now constant. - PDF body Bill To had raw <script> visible (no XSS but phishing). Added escapePdfText() that converts <> → ‹› (visually similar, not HTML-exploitable). Polish: - Bridge wiring: claim.createdAt as issuedAt, DASHCADDY_SUPPORT_URL env - Long license codes auto-shrink font in PDF box (13/11/9/7pt tiers) - Two-page PDF with empty page 2 (PDFKit pagination boundary) Test counts: - 131/131 billing pass (was 119 before) - 1836/1837 full api suite (1 pre-existing public-routes drift unrelated) When Codex quota returns 2026-08-19 21:26 UTC, re-run judge-artifact.sh for the canonical verdict and supersede [mm-grade=A] if needed.
This commit is contained in:
@@ -106,6 +106,7 @@ const path = require('path');
|
||||
const { generateCodes, loadSecret } = require('../license-keygen');
|
||||
const platformPaths = require('../platform-paths');
|
||||
const catalog = require('../src/billing/catalog');
|
||||
const invoice = require('../src/billing/invoice');
|
||||
const { createFulfillmentStore, DELIVERY_LEASE_MS } = require('../src/billing/fulfillment-store');
|
||||
|
||||
// ── Configuration (env-driven) ──────────────────────────────────────────────
|
||||
@@ -244,33 +245,69 @@ function eventSeen(eventId) {
|
||||
// ── Email delivery ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Send the license key email. If SMTP is configured, real send via
|
||||
* Send the license key + invoice email. If SMTP is configured, real send via
|
||||
* nodemailer; if not, log the full email body to stdout so the operator
|
||||
* can deliver manually in dev/test environments.
|
||||
*
|
||||
* The email is multipart/alternative (text + HTML, matching the same
|
||||
* branded content) with a branded PDF invoice attached. Rendered by
|
||||
* src/billing/invoice.js — see that module for the security/escape rules.
|
||||
*
|
||||
* Returns { delivered: bool, via: 'smtp' | 'dev-console' }.
|
||||
*/
|
||||
async function deliverCode({ to, code, durationDays, eventId, productId }) {
|
||||
const subject = `Your DashCaddy Pro license (${durationDays} days)`;
|
||||
const text = [
|
||||
'Thank you for purchasing DashCaddy Pro.',
|
||||
'',
|
||||
`Your license key is valid for ${durationDays} days:`,
|
||||
'',
|
||||
` ${code}`,
|
||||
'',
|
||||
'To install on your DashCaddy host:',
|
||||
' 1. Open https://<your-host>/admin/license',
|
||||
' 2. Paste the key into the "Activate license" field',
|
||||
' 3. Submit — Pro features unlock immediately.',
|
||||
'',
|
||||
'The same key is also revealed on your purchase success page; keep it safe.',
|
||||
'',
|
||||
'Need help? Reply to this email and we will assist.',
|
||||
'',
|
||||
`Reference: ${eventId}`,
|
||||
`Product: ${productId}`,
|
||||
].join('\n');
|
||||
async function deliverCode({ to, code, durationDays, eventId, productId, customerName, sessionId, amountCents, currency, supportUrl, issuedAt }) {
|
||||
const product = catalog.getProduct(productId);
|
||||
if (!product) {
|
||||
// Should never happen — catalog resolution happens upstream. Defensive
|
||||
// throw so the operator notices misconfiguration instead of silently
|
||||
// sending a half-blank invoice.
|
||||
throw new Error(`deliverCode: unknown productId ${productId}`);
|
||||
}
|
||||
|
||||
const invoiceInput = {
|
||||
email: to,
|
||||
customerName: customerName || '',
|
||||
code,
|
||||
durationDays,
|
||||
productLabel: product.label,
|
||||
productId: product.id,
|
||||
amountCents: amountCents != null ? amountCents : product.amountCents,
|
||||
currency: currency || 'USD',
|
||||
eventId,
|
||||
sessionId: sessionId || '',
|
||||
supportUrl: supportUrl || 'https://dashcaddy.net',
|
||||
issuedAt: issuedAt || new Date().toISOString(),
|
||||
};
|
||||
|
||||
const { subject, html } = invoice.renderLicenseEmailHtml(invoiceInput);
|
||||
const text = invoice.renderLicenseEmailText(invoiceInput);
|
||||
|
||||
// PDF generation can throw on poison-pill inputs that survive sanitization
|
||||
// (e.g. lone surrogates in customer name that PDFKit's WinAnsi encoder
|
||||
// rejects, or malformed `issuedAt` after the bridge passes a bad value).
|
||||
// We try the PDF and degrade gracefully: send text+HTML WITHOUT the PDF
|
||||
// attachment so the customer still gets the license + invoice link rather
|
||||
// than nothing. The fulfillment record still marks `delivered` — the
|
||||
// license was persisted upstream, so lookup always works regardless.
|
||||
let pdfBuffer = null;
|
||||
let pdfError = null;
|
||||
try {
|
||||
pdfBuffer = await invoice.renderInvoicePdf(invoiceInput);
|
||||
} catch (err) {
|
||||
pdfError = err;
|
||||
log('warn', 'pdf-render-failed-degrading-to-text-only', {
|
||||
eventId, sessionId, error: err.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Sanitize the PDF filename — event id has Stripe's prefix and underscores
|
||||
// which are safe, but we constrain the charset anyway for attachment
|
||||
// parsers that may be picky.
|
||||
const safeInvoiceNumber = invoice.sanitizeFilenameSegment(
|
||||
invoice.generateInvoiceNumber(eventId),
|
||||
'invoice'
|
||||
);
|
||||
const attachmentFilename = `DashCaddy-Pro-Invoice-${safeInvoiceNumber}.pdf`;
|
||||
|
||||
const smtp = _smtpConfig();
|
||||
if (!smtp.host || !smtp.from) {
|
||||
@@ -281,7 +318,10 @@ async function deliverCode({ to, code, durationDays, eventId, productId }) {
|
||||
// operator seeing the bridge logs IS the documented delivery path
|
||||
// when SMTP is unconfigured. In production, the bridge refuses to
|
||||
// boot without SMTP configured (see checkFatalConfig).
|
||||
log('info', 'smtp-not-configured, falling back to dev-console delivery', { to, durationDays, code });
|
||||
log('info', 'smtp-not-configured, falling back to dev-console delivery', {
|
||||
to, durationDays, code, invoiceNumber: safeInvoiceNumber,
|
||||
pdfBytes: pdfBuffer ? pdfBuffer.length : null, pdfError: pdfError && pdfError.message,
|
||||
});
|
||||
return { delivered: true, via: 'dev-console' };
|
||||
}
|
||||
|
||||
@@ -294,7 +334,24 @@ async function deliverCode({ to, code, durationDays, eventId, productId }) {
|
||||
auth: smtp.username ? { user: smtp.username, pass: smtp.password } : undefined,
|
||||
tls: { rejectUnauthorized: process.env.NODE_ENV === 'production' },
|
||||
});
|
||||
await transporter.sendMail({ from: smtp.from, to, subject, text });
|
||||
const mailArgs = {
|
||||
from: smtp.from,
|
||||
to,
|
||||
subject,
|
||||
text,
|
||||
html,
|
||||
};
|
||||
if (pdfBuffer) {
|
||||
mailArgs.attachments = [
|
||||
{
|
||||
filename: attachmentFilename,
|
||||
content: pdfBuffer,
|
||||
contentType: 'application/pdf',
|
||||
encoding: 'base64',
|
||||
},
|
||||
];
|
||||
}
|
||||
await transporter.sendMail(mailArgs);
|
||||
return { delivered: true, via: 'smtp' };
|
||||
}
|
||||
|
||||
@@ -464,6 +521,57 @@ async function fulfillCheckout({ id, session }) {
|
||||
const sessionId = session.id || '';
|
||||
if (!sessionId) return { status: 400, body: { delivered: false, reason: 'missing-session-id' } };
|
||||
|
||||
// Stripe sends the customer's name on `customer_details.name` for hosted
|
||||
// Checkout (sometimes blank — they may have entered only an email). We
|
||||
// pass it through to the invoice renderer for the "Hi <first name>" greeting
|
||||
// and the bill-to block.
|
||||
const customerName = (session.customer_details && session.customer_details.name) || '';
|
||||
|
||||
// Amount comes from the session's line_items (Stripe Checkout totals).
|
||||
// Older sessions may not have line_items expanded — fall back to the
|
||||
// session amount_total, then to the catalog amount so the invoice is
|
||||
// never blank. The invoice is a financial document — we ALWAYS render
|
||||
// the catalog's canonical amount when Stripe doesn't tell us a different
|
||||
// one, because the catalog is the single source of truth for DashCaddy's
|
||||
// pricing. This prevents Stripe Checkout config drift (e.g. a test
|
||||
// coupon, a multi-seat plan we don't support) from producing invoices
|
||||
// that don't match the user's actual entitlement.
|
||||
let amountCents = null;
|
||||
let currency = (session.currency || 'USD').toString().toUpperCase();
|
||||
const lineItems = session.line_items && session.line_items.data;
|
||||
if (Array.isArray(lineItems) && lineItems.length > 0) {
|
||||
// Sum ALL line items, not just lineItems[0]. The previous version
|
||||
// silently dropped quantity > 1 or multi-item carts, producing
|
||||
// invoices whose total didn't match the Stripe charge. session.amount_total
|
||||
// does this automatically too, but reading line items ourselves lets us
|
||||
// log a warning when Stripe's amount_total disagrees with the line-item
|
||||
// sum (indicative of a Stripe-side bug or tampering).
|
||||
const sumFromLineItems = lineItems.reduce((acc, item) => {
|
||||
if (item && item.amount_total != null) return acc + item.amount_total;
|
||||
if (item && item.price && item.price.unit_amount != null) return acc + item.price.unit_amount;
|
||||
return acc;
|
||||
}, 0);
|
||||
if (sumFromLineItems > 0) amountCents = sumFromLineItems;
|
||||
if (lineItems[0] && lineItems[0].currency) currency = lineItems[0].currency.toUpperCase();
|
||||
}
|
||||
if (amountCents == null && session.amount_total != null) {
|
||||
amountCents = session.amount_total;
|
||||
}
|
||||
// Final fallback: catalog's canonical price for this product. This is
|
||||
// the single source of truth — if Stripe sends 0 or NaN, we render the
|
||||
// catalog price rather than a $0.00 invoice for a real charge.
|
||||
if (amountCents == null || !Number.isFinite(amountCents) || amountCents <= 0) {
|
||||
log('warn', 'amount-fell-back-to-catalog', {
|
||||
eventId: id, sessionId, stripeAmountCents: amountCents, catalogAmountCents: product.amountCents,
|
||||
});
|
||||
amountCents = product.amountCents;
|
||||
}
|
||||
// Currency must always be a 3-letter ISO code; sanitize otherwise.
|
||||
if (!/^[A-Z]{3}$/.test(currency)) {
|
||||
log('warn', 'invalid-currency-from-stripe', { eventId: id, sessionId, stripeCurrency: currency });
|
||||
currency = 'USD';
|
||||
}
|
||||
|
||||
const claim = await fulfillmentStore.claim({
|
||||
eventId: id, sessionId, productId: product.id, durationDays, email,
|
||||
});
|
||||
@@ -479,10 +587,49 @@ async function fulfillCheckout({ id, session }) {
|
||||
if (deliveryClaim.busy) {
|
||||
return { status: 409, body: { delivered: false, reason: 'concurrent-delivery', retryable: true } };
|
||||
}
|
||||
// Layer-2 delivery idempotency: if the claim was NOT successful AND the
|
||||
// record is already `delivered`, an earlier event (or this same event via
|
||||
// layer-1) already produced an invoice email. Stripe may legitimately send
|
||||
// `checkout.session.completed` AND `checkout.session.async_payment_succeeded`
|
||||
// for the same Checkout Session (delayed-payment methods). Without this
|
||||
// guard the customer receives TWO invoice emails with TWO different
|
||||
// invoice numbers for one charge. Ack 200 so Stripe stops retrying.
|
||||
if (deliveryClaim.claimed === false
|
||||
&& deliveryClaim.record
|
||||
&& deliveryClaim.record.status === 'delivered') {
|
||||
log('info', 'delivery-already-completed', {
|
||||
eventId: id, sessionId, ownerEventId: deliveryClaim.record.eventId,
|
||||
});
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
delivered: true,
|
||||
deduplicated: true,
|
||||
codeId: deliveryClaim.record.codeId,
|
||||
productId: deliveryClaim.record.productId,
|
||||
durationDays: deliveryClaim.record.durationDays,
|
||||
deliveredVia: deliveryClaim.record.deliveredVia || 'smtp',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let delivery;
|
||||
try {
|
||||
delivery = await deliverCode({ to: email, code, durationDays, eventId: id, productId: product.id });
|
||||
delivery = await deliverCode({
|
||||
to: email,
|
||||
code,
|
||||
durationDays,
|
||||
eventId: id,
|
||||
productId: product.id,
|
||||
customerName,
|
||||
sessionId,
|
||||
amountCents,
|
||||
currency,
|
||||
// Pin issuedAt to the ORIGINAL claim's createdAt so a retry days later
|
||||
// renders the same "Issued" date. Falls back to now() for first-time.
|
||||
issuedAt: claim.record && claim.record.createdAt,
|
||||
supportUrl: process.env.DASHCADDY_SUPPORT_URL || 'https://dashcaddy.net',
|
||||
});
|
||||
} catch (err) {
|
||||
log('error', 'email-delivery-failed', { eventId: id, sessionId, error: err.message });
|
||||
await fulfillmentStore.markDeliveryFailed({ sessionId, ownerToken: id, error: err.message });
|
||||
|
||||
Reference in New Issue
Block a user