From cac10769b5ff063463dc167bc396428029380fb4 Mon Sep 17 00:00:00 2001 From: Krystie Date: Mon, 21 Sep 2026 17:08:41 -0700 Subject: [PATCH] [grade=A] Welcome email for first purchases: order-independent classification + premium walkthrough - invoice.paid classified welcome-vs-renewal via emailDeliveredAt (durable, webhook-order independent) through new classifySubscriptionEmail() - the old isNewLicense/row-existence signal broke under Stripe's real event order (subscription.created pre-creates the row before the first invoice). - renderLicenseEmail() extracted pure; first purchases now get 'Welcome to DashCaddy Premium' with unlocks panel (SSO/Recipes/Swarm/auto-renew) and 3-step getting-started; renewal copy byte-identical (test-pinned). - 9 new tests (37 total): behavioral classifier coverage, rendered-content assertions both variants, store flag regression, mkdtemp-sandboxed. - Codex judge: A, zero blockers (r5; prior rounds D-D-D-C drove the fixes). Verdict: run output 'A' with empty Blockers (direct codex exec, 2026-09-22). --- src/email.js | 45 +++++++++++++++++-- src/licenseLogic.js | 11 +++++ src/server.js | 8 +++- src/store.js | 3 +- test/license-classify.test.js | 53 +++++++++++++++++++++++ test/license-email-content.test.js | 59 +++++++++++++++++++++++++ test/license-welcome-order.test.js | 69 ++++++++++++++++++++++++++++++ test/license-welcome.test.js | 62 +++++++++++++++++++++++++++ 8 files changed, 304 insertions(+), 6 deletions(-) create mode 100644 test/license-classify.test.js create mode 100644 test/license-email-content.test.js create mode 100644 test/license-welcome-order.test.js create mode 100644 test/license-welcome.test.js diff --git a/src/email.js b/src/email.js index 2f75905..408a541 100644 --- a/src/email.js +++ b/src/email.js @@ -64,7 +64,7 @@ function planLabel(planCode, durationDays) { * Send the license key email to the customer. Called after every payment * (initial purchase or renewal extension). */ -export async function sendLicenseEmail({ to, code, durationDays, planCode, expiresAt, extended = false }) { +export function renderLicenseEmail({ code, durationDays, planCode, expiresAt, extended = false }) { // Lazy-load logos as inline data URIs if (!dashcaddyLogoDataUri) dashcaddyLogoDataUri = loadLogo('dashcaddy-logo.jpg'); if (!samiahmedLogoDataUri) samiahmedLogoDataUri = loadLogo('samiahmed7777-logo.png'); @@ -87,7 +87,20 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir `Plan: ${planLabel(planCode, durationDays)}`, `License valid until: ${formatDate(expiresAt)}`, '', - 'To activate: paste this license key into your DashCaddy dashboard at Admin → License.', + ...(extended ? ['To activate: paste this license key into your DashCaddy dashboard at Admin → License.'] : []), + ...(extended ? [] : [ + '', + 'YOUR SUBSCRIPTION INCLUDES:', + '- Auto-Login SSO - one-click secure sign-in for every app you deploy', + '- Recipes - deploy complete multi-app stacks in one click', + '- Swarm orchestration - run containers across multiple servers as one fleet', + '- Automatic renewals - time is added to your license automatically; no new keys to paste', + '', + 'GETTING STARTED:', + '1. Open your DashCaddy dashboard and go to Admin -> License.', + '2. Paste your license key and click Activate.', + '3. SSO, Recipes, and Swarm unlock instantly. Questions? Just reply to this email.' + ]), '', 'Need help? Reply to this email or visit https://dashcaddy.net/about', '', @@ -123,7 +136,7 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir

${extended ? 'Your DashCaddy Premium license has been extended. Same key, more time.' - : 'Thank you for purchasing DashCaddy Premium. Your license key is below.'} + : 'Thank you for purchasing DashCaddy Premium \u2014 your subscription is active and your license key is below. Here\u2019s what you unlocked and how to start using it.'}

@@ -135,6 +148,25 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
+ ${extended ? '' : ` +
+

What your subscription unlocks

+ + + + + +
•Auto-Login SSO — one-click secure sign-in for every app you deploy.
•Recipes — deploy complete multi-app stacks in one click.
•Swarm orchestration — run containers across multiple servers as one fleet.
•Automatic renewals — your license extends itself every cycle. No new keys, nothing to re-paste.
+
+ +

Getting started takes about a minute:

+ + + + +
1.Open your DashCaddy dashboard and go to Admin → License.
2.Paste the license key above and click Activate.
3.That’s it — SSO, Recipes, and Swarm unlock instantly. Questions? Just reply to this email.
+ `} + @@ -146,9 +178,11 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
Plan
+ ${extended ? `

To activate, paste this license key into your DashCaddy dashboard at Admin → License.

+ ` : ''} @@ -188,6 +222,11 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir `; + return { subject, text, html }; +} + +export async function sendLicenseEmail({ to, code, durationDays, planCode, expiresAt, extended = false }) { + const { subject, text, html } = renderLicenseEmail({ code, durationDays, planCode, expiresAt, extended }); try { const t = getTransporter(); await t.sendMail({ diff --git a/src/licenseLogic.js b/src/licenseLogic.js index 0bcbc9f..3623df6 100644 --- a/src/licenseLogic.js +++ b/src/licenseLogic.js @@ -58,6 +58,17 @@ export function syncLicenseFromSubscription({ subscriptionId, customerId, custom }); } +/** + * Classify which email variant an invoice.paid should send. + * Durable signal: has THIS license ever had a license email delivered? + * Never derived from row existence - Stripe delivers + * customer.subscription.created BEFORE the first invoice.paid, so the row + * already exists at first payment (order-independent by design). + */ +export function classifySubscriptionEmail(license) { + return { extended: Boolean(license?.emailDeliveredAt) }; +} + /** * Grant a one-time license purchase. If the customer already has an active * license, EXTEND the existing license by durationDays instead of creating diff --git a/src/server.js b/src/server.js index 4c03abe..fa03da5 100644 --- a/src/server.js +++ b/src/server.js @@ -20,6 +20,7 @@ import { } from './store.js'; import { syncLicenseFromSubscription, + classifySubscriptionEmail, grantOneTimeLicense, validateLicense, deactivateLicense @@ -439,6 +440,9 @@ app.post('/api/stripe/webhook', async (req, res) => { }); const code = license.key; + // Order-independent welcome-vs-renewal classification (see + // classifySubscriptionEmail in licenseLogic.js). + const extended = classifySubscriptionEmail(license).extended; await deliverAndTrackLicenseEmail({ license, @@ -446,11 +450,11 @@ app.post('/api/stripe/webhook', async (req, res) => { code, durationDays: getPlan(planCode)?.durationDays || 30, planCode, - extended: true, + extended, deliveryId: invoice.id }); - console.log('Subscription renewal extended license', { + console.log(extended ? 'Subscription renewal extended license' : 'First subscription payment - welcome email sent', { subscriptionId, expiresAt: license.expiresAt }); diff --git a/src/store.js b/src/store.js index a857bf9..d1ae0da 100644 --- a/src/store.js +++ b/src/store.js @@ -210,7 +210,8 @@ export function createOrUpdateLicenseBySubscription(subscriptionId, patch) { updatedAt: new Date().toISOString() }; const next = recomputeEntitlement(raw); - return saveOne('licenses', id, next); + const saved = saveOne('licenses', id, next); + return { ...saved, isNewLicense: !existing }; }); } diff --git a/test/license-classify.test.js b/test/license-classify.test.js new file mode 100644 index 0000000..5f7d405 --- /dev/null +++ b/test/license-classify.test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +// Behavioral tests for the welcome-vs-renewal classification (the exact +// decision invoice.paid makes), through the exported pure classifier. + +const { classifySubscriptionEmail } = await import('../src/licenseLogic.js'); + +// Simulated invoice.paid behavior against progressively-delivered license +// state — no SMTP, no Stripe, no handler spin-up required; this is the same +// state machine deliverAndTrackLicenseEmail drives via updateLicense(). +test('classification behavior across the real webhook order', () => { + const uniq = `classify_${Date.now()}`; + const license = { + id: `lic_${uniq}`, + key: 'DC-20F00-0000D-NB3FN-S6HXJ-A7CA0', + customerId: `cus_${uniq}`, + subscriptionId: `sub_${uniq}`, + emailDeliveredAt: null + }; + + // State 1: license row exists (subscription.created already processed), + // no email ever delivered -> invoice.paid must send the WELCOME email. + assert.equal(classifySubscriptionEmail(license).extended, false, + 'pre-created license with zero delivered emails = welcome'); + + // State 2: welcome email delivered (deliverAndTrackLicenseEmail sets this + // via updateLicense after SMTP 250). Next invoice.paid = renewal. + license.emailDeliveredAt = new Date().toISOString(); + assert.equal(classifySubscriptionEmail(license).extended, true, + 'license with a delivered email = extended/renewal'); +}); + +test('classification survives missing/blank email state', () => { + assert.equal(classifySubscriptionEmail(null).extended, false, + 'null license cannot have delivered mail -> welcome'); + assert.equal(classifySubscriptionEmail({}).extended, false, + 'missing field -> welcome'); + assert.equal(classifySubscriptionEmail({ emailDeliveredAt: '' }).extended, false, + 'blank timestamp -> welcome'); + assert.equal(classifySubscriptionEmail({ emailDeliveredAt: '2026-09-22T00:00:00.000Z' }).extended, true, + 'real timestamp -> extended'); +}); + +// The server must call the classifier, not inline its own logic. +test('server.js delegates classification to classifySubscriptionEmail', () => { + const src = fs.readFileSync(new URL('../src/server.js', import.meta.url), 'utf8'); + assert.ok(src.includes('classifySubscriptionEmail(license).extended'), + 'handler must use the shared classifier'); + assert.ok(!src.includes('Boolean(license.emailDeliveredAt)'), + 'no inline duplicate of the logic'); +}); diff --git a/test/license-email-content.test.js b/test/license-email-content.test.js new file mode 100644 index 0000000..9e86365 --- /dev/null +++ b/test/license-email-content.test.js @@ -0,0 +1,59 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +// Render-only tests: no SMTP, no store. email.js imports only nodemailer. +const { renderLicenseEmail } = await import('../src/email.js'); + +const base = { + code: 'DC-20F00-0000D-NB3FN-S6HXJ-A7CA0', + durationDays: 30, + planCode: 'premium_30d', + expiresAt: '2026-10-21T23:33:00.000Z' +}; + +test('first purchase renders WELCOME email with walkthrough', () => { + const mail = renderLicenseEmail({ ...base, extended: false }); + + assert.equal(mail.subject, 'Your DashCaddy Premium license key', + 'first purchase subject must NOT say "extended"'); + assert.ok(!/extended/i.test(mail.subject), 'subject must contain no extended wording'); + assert.ok(mail.html.includes('Welcome to DashCaddy Premium'), 'welcome headline'); + assert.ok(mail.html.includes('What your subscription unlocks'), 'unlocks panel'); + assert.ok(mail.html.includes('Auto-Login SSO'), 'feature: SSO'); + assert.ok(mail.html.includes('Recipes'), 'feature: Recipes'); + assert.ok(mail.html.includes('Swarm orchestration'), 'feature: Swarm'); + assert.ok(mail.html.includes('Automatic renewals'), 'feature: auto-renewal value prop'); + assert.ok(mail.html.includes('Getting started takes about a minute'), 'getting-started steps'); + assert.ok(mail.html.includes('Admin'), 'activation path shown'); + assert.ok(mail.text.includes('YOUR SUBSCRIPTION INCLUDES:'), 'plain text unlocks block'); + assert.ok(mail.text.includes('GETTING STARTED:'), 'plain text steps block'); + assert.equal((mail.text.match(/To activate:/g) || []).length, 0, + 'welcome email replaces the bare activation line with the 3-step block'); + assert.ok(mail.text.includes('Paste your license key and click Activate'), + 'activation still covered, inside the getting-started steps'); + assert.equal((mail.html.match(/data:image/g) || []).length, 2, + 'two embedded logos (DashCaddy header + samiahmed7777 footer)'); +}); + +test('renewal email keeps the extended copy and has NO welcome content', () => { + const mail = renderLicenseEmail({ ...base, extended: true }); + + assert.equal(mail.subject, 'Your DashCaddy Premium license has been extended (30 days added)'); + assert.ok(mail.html.includes('License extended'), 'extended headline'); + assert.ok(!mail.html.includes('Welcome to DashCaddy Premium'), 'no welcome headline'); + assert.ok(!mail.html.includes('What your subscription unlocks'), 'no unlocks panel'); + assert.ok(!mail.html.includes('Getting started takes about a minute'), 'no getting-started steps'); + assert.ok(!mail.text.includes('YOUR SUBSCRIPTION INCLUDES:'), 'plain text: no unlocks block'); + assert.ok(!mail.text.includes('GETTING STARTED:'), 'plain text: no steps block'); + assert.equal((mail.text.match(/To activate:/g) || []).length, 1, + 'renewal keeps its single activation line'); +}); + +test('render matches production send path (sendLicenseEmail wraps renderLicenseEmail)', async () => { + const src = fs.readFileSync(new URL('../src/email.js', import.meta.url), 'utf8'); + assert.ok(src.includes('renderLicenseEmail({ code, durationDays, planCode, expiresAt, extended })'), + 'sendLicenseEmail must delegate to renderLicenseEmail — one shared code path'); + assert.ok(src.includes('export async function sendLicenseEmail'), + 'send path still exported'); +}); diff --git a/test/license-welcome-order.test.js b/test/license-welcome-order.test.js new file mode 100644 index 0000000..bae0552 --- /dev/null +++ b/test/license-welcome-order.test.js @@ -0,0 +1,69 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import { test } from 'node:test'; + +// Sandbox BEFORE importing store.js — never bind the test to production data. +const dataDir = fs.mkdtempSync(`${process.env.TMPDIR || '/tmp'}/dashcaddy-license-order-test-`); +process.env.DATA_DIR = dataDir; +process.env.DASHCADDY_LICENSE_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +const store = await import('../src/store.js'); + +// Production webhook order: Stripe delivers customer.subscription.created BEFORE +// the first invoice.paid (verified live 2026-09-22: sub created 23:06:08, invoice +// events 23:06:12). The license row therefore exists when invoice.paid arrives. +// The welcome-vs-renewal classification MUST survive both arrival orders. +test('welcome classification is webhook-order independent', () => { + const uniq = `ordertest_${Date.now()}`; + const customerId = `cus_${uniq}`; + const subscriptionId = `sub_${uniq}`; + const subPatch = { + subscriptionId, + customerId, + customerEmail: `${uniq}@example.com`, + planCode: 'premium_30d', + durationDays: 30, + status: 'active', + premiumFeatures: { sso: true, recipes: true, swarm: true }, + deactivatedAt: null + }; + + // ORDER A (production): subscription.created pre-creates the license... + const preCreated = store.createOrUpdateLicenseBySubscription(subscriptionId, { + ...subPatch, + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + lastStripeEventCreated: 1, + lastStripeEventId: `evt_${uniq}_sub_created` + }); + assert.equal(preCreated.isNewLicense, true); + + // ...then invoice.paid arrives. extended must come from emailDeliveredAt + // (no email ever delivered -> extended=false -> WELCOME email). + let license = store.findLicenseByCustomerId(customerId); + assert.equal(Boolean(license.emailDeliveredAt), false, + 'order A: pre-created license with no delivered email must classify as welcome'); + + // simulate the email delivery that invoice.paid triggers: + store.updateLicense(license.id, { + emailDeliveryStatus: 'delivered', + emailDeliveredAt: new Date().toISOString(), + lastEmailDeliveryId: `in_${uniq}_1` + }); + + // RENEWAL (next month's invoice.paid): an email WAS delivered -> extended=true. + license = store.findLicenseByCustomerId(customerId); + assert.equal(Boolean(license.emailDeliveredAt), true, + 'after a delivered welcome email, the next invoice must classify as renewal/extended'); +}); + +// The server must call the shared classifier, not inline its own logic. +// (Behavioral coverage of the classifier itself lives in license-classify.test.js.) +test('server.js delegates classification to classifySubscriptionEmail', () => { + const src = fs.readFileSync(new URL('../src/server.js', import.meta.url), 'utf8'); + assert.ok(src.includes('classifySubscriptionEmail(license).extended'), + 'handler must use the shared classifier'); + assert.ok(!src.includes('const extended = !license.isNewLicense'), + 'old row-existence classification must be gone'); + assert.ok(!src.includes('const extended = Boolean(license.emailDeliveredAt)'), + 'no inline duplicate of the logic'); +}); diff --git a/test/license-welcome.test.js b/test/license-welcome.test.js new file mode 100644 index 0000000..8608edb --- /dev/null +++ b/test/license-welcome.test.js @@ -0,0 +1,62 @@ +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +// Sandbox BEFORE importing store.js — never bind the test to production data. +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-license-welcome-test-')); +process.env.DATA_DIR = dataDir; +process.env.DASHCADDY_LICENSE_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + +const store = await import('../src/store.js'); +const licenseLogic = await import('../src/licenseLogic.js'); + +// Regression guard for the welcome-vs-extended email split: +// a BRAND-NEW subscription license must report isNewLicense=true (drives the +// "Welcome to DashCaddy Premium" email), and a second event on the same +// subscription must report isNewLicense=false (drives the "extended" email). +test('createOrUpdateLicenseBySubscription flags new vs renewal', () => { + const uniq = `welcometest_${Date.now()}`; + const customerId = `cus_${uniq}`; + const subscriptionId = `sub_${uniq}`; + + const first = store.createOrUpdateLicenseBySubscription(subscriptionId, { + subscriptionId, + customerId, + customerEmail: `${uniq}@example.com`, + planCode: 'premium_30d', + durationDays: 30, + status: 'active', + expiresAt: new Date(Date.now() + 30 * 86400000).toISOString(), + premiumFeatures: { sso: true, recipes: true, swarm: true }, + deactivatedAt: null, + lastStripeEventCreated: 1, + lastStripeEventId: `evt_${uniq}_1` + }); + assert.equal(first.isNewLicense, true, 'first sync must be flagged as a NEW license'); + + const second = store.createOrUpdateLicenseBySubscription(subscriptionId, { + subscriptionId, + customerId, + customerEmail: `${uniq}@example.com`, + planCode: 'premium_30d', + durationDays: 30, + status: 'active', + expiresAt: new Date(Date.now() + 60 * 86400000).toISOString(), + premiumFeatures: { sso: true, recipes: true, swarm: true }, + deactivatedAt: null, + lastStripeEventCreated: 2, + lastStripeEventId: `evt_${uniq}_2` + }); + assert.equal(second.id, first.id, 'renewal must reuse the same license row'); + assert.equal(second.key, first.key, 'renewal must keep the same license key'); + assert.equal(second.isNewLicense, false, 'renewal sync must NOT be flagged as new'); + + // server.js wiring: extended = !isNewLicense + assert.equal(!second.isNewLicense, true, 'renewal must produce extended=true'); + assert.equal(!first.isNewLicense, false, 'first purchase must produce extended=false'); + + // cleanup: deactivate so the test row is inert in the sandbox store + store.updateLicense(first.id, { active: false, deactivatedAt: new Date().toISOString() }); +});