[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).
This commit is contained in:
Krystie committed 2026-09-21 17:08:41 -07:00
1 parent 2bfb8ea2b5
commit cac10769b5
8 files changed
+304 -6

No files matched your search

+42 -3
View File
@@ -64,7 +64,7 @@ function planLabel(planCode, durationDays) {
* Send the license key email to the customer. Called after every payment * Send the license key email to the customer. Called after every payment
* (initial purchase or renewal extension). * (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 // Lazy-load logos as inline data URIs
if (!dashcaddyLogoDataUri) dashcaddyLogoDataUri = loadLogo('dashcaddy-logo.jpg'); if (!dashcaddyLogoDataUri) dashcaddyLogoDataUri = loadLogo('dashcaddy-logo.jpg');
if (!samiahmedLogoDataUri) samiahmedLogoDataUri = loadLogo('samiahmed7777-logo.png'); if (!samiahmedLogoDataUri) samiahmedLogoDataUri = loadLogo('samiahmed7777-logo.png');
@@ -87,7 +87,20 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
`Plan: ${planLabel(planCode, durationDays)}`, `Plan: ${planLabel(planCode, durationDays)}`,
`License valid until: ${formatDate(expiresAt)}`, `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', '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
<p style="color: #334155; font-size: 15px; line-height: 1.55; margin: 0 0 24px 0;"> <p style="color: #334155; font-size: 15px; line-height: 1.55; margin: 0 0 24px 0;">
${extended ${extended
? 'Your DashCaddy Premium license has been extended. Same key, more time.' ? '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.'}
</p> </p>
<div style="background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%); padding: 24px; border-radius: 10px; margin: 0 0 24px 0; text-align: center; border: 1px solid #ddd6fe;"> <div style="background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%); padding: 24px; border-radius: 10px; margin: 0 0 24px 0; text-align: center; border: 1px solid #ddd6fe;">
@@ -135,6 +148,25 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
</div> </div>
</div> </div>
${extended ? '' : `
<div style="background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 20px 24px; margin: 0 0 24px 0;">
<p style="margin: 0 0 10px 0; color: #0f172a; font-size: 15px; font-weight: 600;">What your subscription unlocks</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="color: #334155; font-size: 14px; line-height: 1.6;">
<tr><td style="padding: 3px 0; width: 22px; vertical-align: top; color: #6d28d9; font-weight: 700;">&bull;</td><td style="padding: 3px 0; vertical-align: top;"><strong>Auto-Login SSO</strong> &mdash; one-click secure sign-in for every app you deploy.</td></tr>
<tr><td style="padding: 3px 0; vertical-align: top; color: #6d28d9; font-weight: 700;">&bull;</td><td style="padding: 3px 0; vertical-align: top;"><strong>Recipes</strong> &mdash; deploy complete multi-app stacks in one click.</td></tr>
<tr><td style="padding: 3px 0; vertical-align: top; color: #6d28d9; font-weight: 700;">&bull;</td><td style="padding: 3px 0; vertical-align: top;"><strong>Swarm orchestration</strong> &mdash; run containers across multiple servers as one fleet.</td></tr>
<tr><td style="padding: 3px 0; vertical-align: top; color: #6d28d9; font-weight: 700;">&bull;</td><td style="padding: 3px 0; vertical-align: top;"><strong>Automatic renewals</strong> &mdash; your license extends itself every cycle. No new keys, nothing to re-paste.</td></tr>
</table>
</div>
<p style="color: #334155; font-size: 15px; line-height: 1.55; margin: 0 0 10px 0;"><strong>Getting started takes about a minute:</strong></p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="color: #334155; font-size: 14px; line-height: 1.6; margin: 0 0 24px 0;">
<tr><td style="padding: 3px 0; width: 22px; vertical-align: top; color: #6d28d9; font-weight: 700;">1.</td><td style="padding: 3px 0; vertical-align: top;">Open your DashCaddy dashboard and go to <strong>Admin &rarr; License</strong>.</td></tr>
<tr><td style="padding: 3px 0; vertical-align: top; color: #6d28d9; font-weight: 700;">2.</td><td style="padding: 3px 0; vertical-align: top;">Paste the license key above and click <strong>Activate</strong>.</td></tr>
<tr><td style="padding: 3px 0; vertical-align: top; color: #6d28d9; font-weight: 700;">3.</td><td style="padding: 3px 0; vertical-align: top;">That&rsquo;s it &mdash; SSO, Recipes, and Swarm unlock instantly. Questions? Just reply to this email.</td></tr>
</table>
`}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 0 0 24px 0;"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 0 0 24px 0;">
<tr> <tr>
<td style="padding: 8px 0; color: #64748b; font-size: 13px; width: 140px;">Plan</td> <td style="padding: 8px 0; color: #64748b; font-size: 13px; width: 140px;">Plan</td>
@@ -146,9 +178,11 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
</tr> </tr>
</table> </table>
${extended ? `
<p style="color: #334155; font-size: 15px; line-height: 1.55; margin: 0 0 24px 0;"> <p style="color: #334155; font-size: 15px; line-height: 1.55; margin: 0 0 24px 0;">
To activate, paste this license key into your DashCaddy dashboard at <strong>Admin → License</strong>. To activate, paste this license key into your DashCaddy dashboard at <strong>Admin → License</strong>.
</p> </p>
` : ''}
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"> <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr> <tr>
@@ -188,6 +222,11 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
</html> </html>
`; `;
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 { try {
const t = getTransporter(); const t = getTransporter();
await t.sendMail({ await t.sendMail({
+11
View File
@@ -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 * Grant a one-time license purchase. If the customer already has an active
* license, EXTEND the existing license by durationDays instead of creating * license, EXTEND the existing license by durationDays instead of creating
+6 -2
View File
@@ -20,6 +20,7 @@ import {
} from './store.js'; } from './store.js';
import { import {
syncLicenseFromSubscription, syncLicenseFromSubscription,
classifySubscriptionEmail,
grantOneTimeLicense, grantOneTimeLicense,
validateLicense, validateLicense,
deactivateLicense deactivateLicense
@@ -439,6 +440,9 @@ app.post('/api/stripe/webhook', async (req, res) => {
}); });
const code = license.key; const code = license.key;
// Order-independent welcome-vs-renewal classification (see
// classifySubscriptionEmail in licenseLogic.js).
const extended = classifySubscriptionEmail(license).extended;
await deliverAndTrackLicenseEmail({ await deliverAndTrackLicenseEmail({
license, license,
@@ -446,11 +450,11 @@ app.post('/api/stripe/webhook', async (req, res) => {
code, code,
durationDays: getPlan(planCode)?.durationDays || 30, durationDays: getPlan(planCode)?.durationDays || 30,
planCode, planCode,
extended: true, extended,
deliveryId: invoice.id deliveryId: invoice.id
}); });
console.log('Subscription renewal extended license', { console.log(extended ? 'Subscription renewal extended license' : 'First subscription payment - welcome email sent', {
subscriptionId, subscriptionId,
expiresAt: license.expiresAt expiresAt: license.expiresAt
}); });
+2 -1
View File
@@ -210,7 +210,8 @@ export function createOrUpdateLicenseBySubscription(subscriptionId, patch) {
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
const next = recomputeEntitlement(raw); const next = recomputeEntitlement(raw);
return saveOne('licenses', id, next); const saved = saveOne('licenses', id, next);
return { ...saved, isNewLicense: !existing };
}); });
} }
+53
View File
@@ -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');
});
+59
View File
@@ -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');
});
+69
View File
@@ -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');
});
+62
View File
@@ -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() });
});