[grade=B] Make website billing production-ready

This commit is contained in:
Krystie
2026-08-22 18:16:12 -07:00
parent 06d8062a5a
commit 2bfb8ea2b5
16 changed files with 1778 additions and 313 deletions
+10
View File
@@ -4,4 +4,14 @@ DASHCADDY_WEBSITE_URL=https://dashcaddy.net
STRIPE_SECRET_KEY=sk_live_your_secret_key_here
STRIPE_PUBLISHABLE_KEY=pk_live_your_publishable_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
DASHCADDY_LICENSE_SECRET=64_hex_characters_shared_with_dashcaddy
DATA_DIR=./data
ADMIN_TOKEN=generate_a_long_random_admin_token
SMTP_HOST=mail.sami-ahmed.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USERNAME=licenses@dashcaddy.net
SMTP_PASSWORD=your_smtp_password
SMTP_FROM=licenses@dashcaddy.net
SMTP_TLS_REJECT_UNAUTHORIZED=true
+6 -5
View File
@@ -34,9 +34,9 @@ Paid subscriptions unlock:
- `swarm`
### Stripe plans
- 1 month — $25
- 1 month — $20
- 3 months — $50
- 6 months — $65
- 6 months — $70
- 12 months — $99
### Subscription policy
@@ -78,7 +78,8 @@ Source of truth for:
## Proposed API surface
### Public endpoints for website
- `POST /api/checkout/session`
- `POST /api/checkout/one-time`
- `POST /api/checkout/subscription`
- `GET /api/public/plans`
### DashCaddy app endpoints
@@ -200,7 +201,7 @@ On cancellation:
The `dashcaddy.net` marketing site should be updated to:
- add Premium pricing section buttons
- each button POSTs to `/api/checkout/session` with a plan code
- each button POSTs to `/api/checkout/{one-time,subscription}` with a plan code
- redirect to returned Stripe Checkout URL
- success page explains license delivery/activation flow
- cancellation page returns user to pricing
@@ -223,7 +224,7 @@ Initial env expected:
1. Scaffold Node service for `dashcaddy-license-server`
2. Add Stripe SDK, Express, and SQLite/Postgres adapter layer
3. Build `/api/public/plans`
4. Build `/api/checkout/session`
4. Build `/api/checkout/one-time` and `/api/checkout/subscription`
5. Build `/api/stripe/webhook`
6. Port/reuse DashCaddy-compatible license generation/validation helpers
7. Build `/api/license/validate`
+2 -2
View File
@@ -3,9 +3,9 @@
## Locked Decisions
### Subscription plans
- 1 month — $25
- 1 month — $20
- 3 months — $50
- 6 months — $65
- 6 months — $70
- 12 months — $99
### Tier model
+69 -45
View File
@@ -1,54 +1,32 @@
# DashCaddy License Server
Stripe-driven license automation for DashCaddy.
Production billing and license fulfillment for purchases made on [dashcaddy.net](https://dashcaddy.net).
## Purpose
Requires **Node.js 22.5 or newer** for the built-in `node:sqlite` transactional store.
This service is the billing and license orchestration layer for DashCaddy.
It receives Stripe webhooks, maps purchases/subscriptions to license entitlements,
and exposes license validation/deactivation endpoints for DashCaddy instances.
## What it does
## Planned responsibilities
- Creates Stripe Checkout sessions for one-time purchases and auto-renewing subscriptions.
- Verifies signed Stripe webhooks with durable event idempotency.
- Creates one stable DashCaddy license key per customer and extends that key on later purchases or renewals.
- Tracks one-time and subscription paid-through components separately, so subscription failure cannot erase valid one-time access.
- Emails the exact key accepted by the validation API and tracks each renewal email per Stripe invoice.
- Keeps subscriptions active through their paid period; failed renewals receive a seven-day grace period.
- Supports one-machine activation and deactivation.
- Migrates the previous JSON store into a transactional SQLite database on first startup.
- Verify Stripe webhook signatures
- Track customers, subscriptions, invoices, and purchases
- Generate or extend DashCaddy licenses
- Expose `/api/license/validate` for DashCaddy activation
- Expose `/api/license/deactivate` for DashCaddy deactivation
- Support renewals, expirations, cancellations, and grace periods
## Plans
## Architecture
| Plan | One-time / renewal amount | Subscription interval |
|---|---:|---:|
| `premium_30d` | $20 | 1 month |
| `premium_90d` | $50 | 3 months |
| `premium_180d` | $70 | 6 months |
| `premium_365d` | $99 | 1 year |
- **Stripe** is billing truth
- **License server database** is entitlement truth
- **DashCaddy app** remains the consumer of license validation
- Existing DashCaddy license logic should be reused, not reinvented
Stripe remains the billing source of truth. SQLite is the entitlement and fulfillment source of truth.
## Next steps
1. Extract/reuse the current DashCaddy license key generation and verification logic
2. Define DB schema for customers, licenses, activations, and Stripe mapping
3. Implement webhook ingestion and event processing
4. Implement validate/deactivate endpoints
5. Add admin tooling for manual recovery and support workflows
## Current implementation status
Implemented now:
- Stripe Checkout session creation
- Stripe webhook ingestion scaffold with subscription/license sync
- File-backed persistence for customers, subscriptions, and licenses
- License validation endpoint
- License deactivation endpoint
- One-machine-at-a-time activation enforcement
Still required before production:
- durable database
- email delivery for license keys
- deployment on Contabo
- Stripe webhook registration
- end-to-end live checkout verification
Server-managed keys use DashCaddy's HMAC-compatible code format for initial activation, but renewed expiry is authoritative online because a stable signed code cannot encode changing renewal dates. The DashCaddy client refreshes server-managed entitlements from the license server and does not create a new activation through offline fallback when that server is configured.
## Environment
@@ -59,16 +37,62 @@ DASHCADDY_WEBSITE_URL=https://dashcaddy.net
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
DASHCADDY_LICENSE_SECRET=64_hex_characters_shared_with_dashcaddy
DATA_DIR=./data
ADMIN_TOKEN=generate-a-long-random-token
SMTP_HOST=mail.sami-ahmed.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USERNAME=licenses@dashcaddy.net
SMTP_PASSWORD=...
SMTP_FROM=licenses@dashcaddy.net
# Keep certificate verification enabled in production.
SMTP_TLS_REJECT_UNAUTHORIZED=true
```
## HTTP endpoints
## Public HTTP endpoints
- `GET /health`
- `GET /api/public/config`
- `GET /api/public/plans`
- `POST /api/checkout/session`
- `POST /api/checkout/one-time`
- `POST /api/checkout/subscription`
- `GET /api/checkout/session/:sessionId`
- `POST /api/stripe/webhook`
- `POST /api/license/validate`
- `POST /api/license/deactivate`
- `GET /api/admin/debug/store`
`GET /api/admin/debug/store` requires `Authorization: Bearer $ADMIN_TOKEN` and is hidden with a 404 when no admin token is configured.
## Checkout request
```json
{
"planCode": "premium_30d",
"customerEmail": "buyer@example.com"
}
```
A successful checkout request returns a Stripe-hosted `url` and a `sessionId`. The website redirects to Stripe. After payment, Stripe calls the webhook, the license is created or extended, SMTP delivery is recorded, and the website polls `/api/checkout/session/:sessionId` to display the same key sent by email.
## Safety properties
- Checkout accepts only valid plan codes and email addresses.
- Browser CORS is restricted to `dashcaddy.net` and `www.dashcaddy.net`.
- Checkout creation is rate limited.
- Webhook event IDs and payment intent IDs are idempotent.
- Store changes use SQLite transactions and WAL durability.
- Repeated checkout sessions are stored independently.
- Admin customer/license data is not public.
- SMTP certificate verification is enabled by default.
An email marked `delivered` means the configured SMTP provider accepted it; final inbox placement remains the receiving mail system's responsibility. The success page also displays the same valid key, so fulfillment does not depend on inbox delivery.
## Test
```bash
npm test
```
The tests cover CORS, invalid checkout input, admin isolation, exact-key lookup and validation, payment idempotency, rate limiting, durable webhook claims, repeat checkout lookup, cancellation, and payment-grace expiry.
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+10
View File
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"express": "^4.21.2",
"nodemailer": "^9.0.5",
"stripe": "^22.0.1"
}
},
@@ -526,6 +527,15 @@
"node": ">= 0.6"
}
},
"node_modules/nodemailer": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.5.tgz",
"integrity": "sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+3 -1
View File
@@ -5,10 +5,12 @@
"type": "module",
"scripts": {
"dev": "node --watch src/server.js",
"start": "node src/server.js"
"start": "node src/server.js",
"test": "node --test"
},
"dependencies": {
"express": "^4.21.2",
"nodemailer": "^9.0.5",
"stripe": "^22.0.1"
}
}
+3 -1
View File
@@ -4,5 +4,7 @@ export const config = {
websiteUrl: process.env.DASHCADDY_WEBSITE_URL || 'https://dashcaddy.net',
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || ''
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
licenseSecret: process.env.DASHCADDY_LICENSE_SECRET || '',
adminToken: process.env.ADMIN_TOKEN || ''
};
+141 -43
View File
@@ -1,38 +1,27 @@
import crypto from 'crypto';
import nodemailer from 'nodemailer';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const LICENSE_SECRET = process.env.DASHCADDY_LICENSE_SECRET || '';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Crockford-ish (no I, O, 0, 1)
/**
* Generate a DashCaddy license code in the format `DC-XXXX-XXXX-XXXX-XXXX`.
* Deterministic for the same (licenseKey, durationDays) tuple — if a customer
* pays again, regenerate the code with the same key + new duration, and the
* code stays the same. That's what "the same key gets more time" means.
*/
export function generateLicenseCode(licenseKey, durationDays) {
if (!LICENSE_SECRET) {
throw new Error('DASHCADDY_LICENSE_SECRET not set');
}
const seed = crypto
.createHmac('sha256', LICENSE_SECRET)
.update(`${licenseKey}|${durationDays}`)
.digest();
const segments = [];
for (let i = 0; i < seed.length && segments.length < 4; i += 2) {
const b1 = seed[i];
const b2 = seed[i + 1] || 0;
segments.push(
ALPHABET[b1 % 32] +
ALPHABET[Math.floor(b1 / 32) % 32] +
ALPHABET[b2 % 32] +
ALPHABET[Math.floor(b2 / 32) % 32]
);
}
return 'DC-' + segments.join('-');
// Cache logos as base64 data URIs so they're inlined in the HTML email
let dashcaddyLogoDataUri = null;
let samiahmedLogoDataUri = null;
function loadLogo(filename) {
const assetsDir = path.join(__dirname, '..', 'assets');
const filepath = path.join(assetsDir, filename);
if (!fs.existsSync(filepath)) return null;
const buf = fs.readFileSync(filepath);
const ext = path.extname(filename).slice(1).toLowerCase();
const mime = ext === 'jpg' || ext === 'jpeg' ? 'image/jpeg' : `image/${ext}`;
return `data:${mime};base64,${buf.toString('base64')}`;
}
let transporter = null;
function getTransporter() {
if (transporter) return transporter;
@@ -44,14 +33,31 @@ function getTransporter() {
user: process.env.SMTP_USERNAME,
pass: process.env.SMTP_PASSWORD
} : undefined,
tls: { rejectUnauthorized: false }
connectionTimeout: 10000,
greetingTimeout: 10000,
socketTimeout: 20000,
tls: { rejectUnauthorized: process.env.SMTP_TLS_REJECT_UNAUTHORIZED !== 'false' }
});
return transporter;
}
function formatDate(iso) {
if (!iso) return 'N/A';
return new Date(iso).toUTCString();
return new Date(iso).toLocaleString('en-US', {
timeZone: 'America/Los_Angeles',
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
hour12: true,
timeZoneName: 'short'
});
}
function planLabel(planCode, durationDays) {
if (durationDays) return `${durationDays} days`;
return planCode;
}
/**
@@ -59,6 +65,18 @@ function formatDate(iso) {
* (initial purchase or renewal extension).
*/
export async function sendLicenseEmail({ to, 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');
const dashcaddyLogo = dashcaddyLogoDataUri
? `<img src="${dashcaddyLogoDataUri}" alt="DashCaddy" width="360" style="display: block; margin: 0 auto;" />`
: `<h1 style="color: #6d28d9; margin: 0; text-align: center;">DashCaddy</h1>`;
const samiahmedLogo = samiahmedLogoDataUri
? `<img src="${samiahmedLogoDataUri}" alt="A product by samiahmed7777" width="110" style="display: block; margin: 0 auto; opacity: 0.85;" />`
: `<span style="font-size: 12px; color: #64748b;">a product by samiahmed7777</span>`;
const subject = extended
? `Your DashCaddy Premium license has been extended (${durationDays} days added)`
: `Your DashCaddy Premium license key`;
@@ -66,28 +84,108 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
extended ? 'Your DashCaddy Premium license has been extended.' : 'Thank you for purchasing DashCaddy Premium.',
'',
`License key: ${code}`,
`Plan: ${planCode}`,
`Plan: ${planLabel(planCode, durationDays)}`,
`License valid until: ${formatDate(expiresAt)}`,
'',
'To activate: paste this license key into your DashCaddy dashboard at Admin → License.',
'',
'Need help? Reply to this email or visit https://dashcaddy.net/about'
'Need help? Reply to this email or visit https://dashcaddy.net/about',
'',
'— DashCaddy'
].join('\n');
const html = `
<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px;">
<h2 style="color: #0f172a;">${extended ? 'License extended' : 'Welcome to DashCaddy Premium'}</h2>
<p>${extended
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body style="margin: 0; padding: 0; background: #f1f5f9; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;">
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background: #f1f5f9; padding: 32px 16px;">
<tr>
<td align="center">
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" style="max-width: 600px; background: #ffffff; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08);">
<!-- HEADER: DashCaddy logo -->
<tr>
<td style="padding: 32px 32px 24px 32px; text-align: center; background: #ffffff;">
${dashcaddyLogo}
</td>
</tr>
<!-- BODY -->
<tr>
<td style="padding: 8px 40px 16px 40px;">
<h2 style="color: #0f172a; font-size: 22px; font-weight: 600; margin: 0 0 12px 0;">
${extended ? 'License extended' : 'Welcome to DashCaddy Premium'}
</h2>
<p style="color: #334155; font-size: 15px; line-height: 1.55; margin: 0 0 24px 0;">
${extended
? 'Your DashCaddy Premium license has been extended. Same key, more time.'
: 'Thank you for purchasing DashCaddy Premium. Your license key is below.'}</p>
<div style="background: #f1f5f9; padding: 16px; border-radius: 8px; margin: 20px 0; font-family: monospace; font-size: 16px; text-align: center; letter-spacing: 2px;">
: 'Thank you for purchasing DashCaddy Premium. Your license key is below.'}
</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="font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 22px; font-weight: 700; color: #4c1d95; letter-spacing: 3px; line-height: 1.4;">
${code}
</div>
<p><strong>Plan:</strong> ${planCode}</p>
<p><strong>License valid until:</strong> ${formatDate(expiresAt)}</p>
<p>To activate: paste this license key into your DashCaddy dashboard at <strong>Admin → License</strong>.</p>
<hr style="margin-top: 30px; border: none; border-top: 1px solid #e2e8f0;" />
<p style="color: #64748b; font-size: 14px;">Need help? Reply to this email or visit <a href="https://dashcaddy.net/about">dashcaddy.net/about</a></p>
<div style="color: #6d28d9; font-size: 12px; text-transform: uppercase; letter-spacing: 2px; margin-top: 10px; font-weight: 600;">
Your License Key
</div>
</div>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="margin: 0 0 24px 0;">
<tr>
<td style="padding: 8px 0; color: #64748b; font-size: 13px; width: 140px;">Plan</td>
<td style="padding: 8px 0; color: #0f172a; font-size: 14px; font-weight: 500;">${planLabel(planCode, durationDays)}</td>
</tr>
<tr>
<td style="padding: 8px 0; color: #64748b; font-size: 13px;">${extended ? 'New expiration' : 'License valid until'}</td>
<td style="padding: 8px 0; color: #0f172a; font-size: 14px; font-weight: 500;">${formatDate(expiresAt)}</td>
</tr>
</table>
<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>.
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0">
<tr>
<td align="center">
<a href="https://dashcaddy.net/docs/premium" style="display: inline-block; background: #6d28d9; color: #ffffff; text-decoration: none; padding: 14px 32px; border-radius: 8px; font-size: 15px; font-weight: 600;">View Activation Guide</a>
</td>
</tr>
</table>
</td>
</tr>
<!-- HELP -->
<tr>
<td style="padding: 16px 40px 32px 40px;">
<hr style="margin: 0 0 24px 0; border: none; border-top: 1px solid #e2e8f0;" />
<p style="color: #64748b; font-size: 13px; line-height: 1.5; margin: 0; text-align: center;">
Need help? Reply to this email or visit <a href="https://dashcaddy.net/about" style="color: #6d28d9; text-decoration: none;">dashcaddy.net/about</a>
</p>
</td>
</tr>
<!-- FOOTER: samiahmed7777 "A product by" -->
<tr>
<td style="background: #f8fafc; padding: 24px 32px; text-align: center; border-top: 1px solid #e2e8f0;">
<p style="color: #94a3b8; font-size: 11px; text-transform: uppercase; letter-spacing: 2px; margin: 0 0 12px 0; font-weight: 600;">
A Product By
</p>
${samiahmedLogo}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
`;
try {
+73
View File
@@ -0,0 +1,73 @@
import crypto from 'crypto';
const VERSION = 1;
const VALID_DURATIONS = new Set([30, 90, 180, 365]);
const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
function secret() {
const value = process.env.DASHCADDY_LICENSE_SECRET || '';
if (!/^[A-Fa-f0-9]{32,}$/.test(value)) {
throw new Error('DASHCADDY_LICENSE_SECRET must be a hex secret of at least 128 bits');
}
return value;
}
function base32Encode(buffer) {
let bits = '';
for (const byte of buffer) bits += byte.toString(2).padStart(8, '0');
while (bits.length % 5 !== 0) bits += '0';
let result = '';
for (let i = 0; i < bits.length; i += 5) result += BASE32[parseInt(bits.slice(i, i + 5), 2)];
return result;
}
function base32Decode(value) {
let bits = '';
for (const char of value.toUpperCase()) {
const index = BASE32.indexOf(char);
if (index < 0) throw new Error(`Invalid base32 character: ${char}`);
bits += index.toString(2).padStart(5, '0');
}
const bytes = [];
for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
return Buffer.from(bytes);
}
export function generateCompatibleLicenseCode(durationDays, codeId, createdTs = Math.floor(Date.now() / 1000)) {
if (!VALID_DURATIONS.has(durationDays)) throw new Error(`Invalid duration: ${durationDays}`);
if (!Number.isInteger(codeId) || codeId < 1 || codeId > 0xFFFFFFFF) throw new Error('Invalid codeId');
const payload = Buffer.alloc(10);
payload.writeUInt16BE(((VERSION & 0x0F) << 12) | (durationDays & 0x0FFF), 0);
payload.writeUInt32BE(codeId, 2);
payload.writeUInt32BE(createdTs, 6);
const signature = crypto.createHmac('sha256', secret()).update(payload).digest().subarray(0, 5);
const encoded = base32Encode(Buffer.concat([payload, signature])).padEnd(25, '0').slice(0, 25);
return `DC-${encoded.match(/.{5}/g).join('-')}`;
}
export function verifyCompatibleLicenseCode(code) {
try {
const cleaned = String(code).replace(/^DC-/, '').replace(/-/g, '');
if (cleaned.length !== 25) return { valid: false, reason: 'Invalid code length' };
const decoded = base32Decode(cleaned).subarray(0, 15);
if (decoded.length < 15) return { valid: false, reason: 'Invalid code payload' };
const payload = decoded.subarray(0, 10);
const signature = decoded.subarray(10, 15);
const expected = crypto.createHmac('sha256', secret()).update(payload).digest().subarray(0, 5);
if (!crypto.timingSafeEqual(signature, expected)) return { valid: false, reason: 'Invalid signature' };
const packed = payload.readUInt16BE(0);
const version = (packed >> 12) & 0x0F;
const durationDays = packed & 0x0FFF;
const codeId = payload.readUInt32BE(2);
const createdTs = payload.readUInt32BE(6);
return {
valid: version === VERSION && VALID_DURATIONS.has(durationDays),
version,
durationDays,
codeId,
createdTs,
};
} catch (error) {
return { valid: false, reason: error.message };
}
}
+42 -68
View File
@@ -1,12 +1,13 @@
import crypto from 'crypto';
import { PREMIUM_FEATURES } from './plans.js';
import { PREMIUM_FEATURES, getPlan } from './plans.js';
import {
createOrUpdateLicenseBySubscription,
findLicenseByKey,
findActiveLicenseByCustomerEmail,
findLicenseByCustomerId,
extendLicenseByDuration,
updateLicense
updateLicense,
grantOneTimeLicenseAtomic,
claimLicenseMachine,
isStripeTransitionStale
} from './store.js';
export function fingerprintMachine(payload = {}) {
@@ -26,45 +27,34 @@ export function fingerprintMachine(payload = {}) {
* the plan duration instead of creating a new one. This is the "renewal
* adds time to the same key" model.
*/
export function syncLicenseFromSubscription({ subscriptionId, customerId, customerEmail, planCode, status, currentPeriodEnd, durationDays }) {
export function syncLicenseFromSubscription({ subscriptionId, customerId, customerEmail, planCode, status, currentPeriodEnd, durationDays, eventCreated, eventId }) {
const premiumFeatures = Object.fromEntries(PREMIUM_FEATURES.map((f) => [f, true]));
// Try to find an existing license by customerId FIRST, then email
const existing = findLicenseByCustomerId(customerId) || findActiveLicenseByCustomerEmail(customerEmail);
if (existing) {
// Extend existing license. We use currentPeriodEnd from Stripe for
// subscriptions (Stripe is the authority on renewal dates), but if
// durationDays is provided we use that to extend from current expiry.
const patch = {
subscriptionId,
customerId,
customerEmail,
planCode,
status,
active: ['active', 'trialing', 'past_due'].includes(status),
premiumFeatures,
machineFingerprint: existing.machineFingerprint || null,
deactivatedAt: null
};
if (currentPeriodEnd) {
patch.expiresAt = currentPeriodEnd;
}
return updateLicense(existing.id, patch);
const existing = findLicenseByCustomerId(customerId);
const incomingEvent = Number(eventCreated || 0);
const previousEvent = Number(existing?.lastStripeEventCreated || 0);
if (existing && isStripeTransitionStale(
{ ...existing, status: existing.subscriptionStatus || existing.status },
incomingEvent,
eventId,
status
)) {
return { ...existing, staleEventIgnored: true };
}
// No existing license — create one with the current period end as the initial expiry
return createOrUpdateLicenseBySubscription(subscriptionId, {
subscriptionId,
customerId,
customerEmail,
planCode,
durationDays: durationDays || getPlan(planCode)?.durationDays,
status,
expiresAt: currentPeriodEnd,
active: ['active', 'trialing', 'past_due'].includes(status),
graceUntil: status === 'past_due' ? existing?.subscriptionGraceUntil || existing?.graceUntil || null : null,
premiumFeatures,
machineFingerprint: null,
deactivatedAt: null
machineFingerprint: existing?.machineFingerprint || null,
deactivatedAt: null,
lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
lastStripeEventId: eventId || existing?.lastStripeEventId || null
});
}
@@ -75,36 +65,13 @@ export function syncLicenseFromSubscription({ subscriptionId, customerId, custom
*/
export function grantOneTimeLicense({ paymentIntentId, customerId, customerEmail, planCode, durationDays }) {
const premiumFeatures = Object.fromEntries(PREMIUM_FEATURES.map((f) => [f, true]));
// Look for existing license by customerId, then email
const existing = findLicenseByCustomerId(customerId) || findActiveLicenseByCustomerEmail(customerEmail);
if (existing) {
// Extend existing license by the plan duration
const extended = extendLicenseByDuration(existing.id, durationDays);
return {
...extended,
extended: true,
addedDays: durationDays
};
}
// No existing license — create one with expiry = now + durationDays
const expiresAt = new Date();
expiresAt.setUTCDate(expiresAt.getUTCDate() + durationDays);
return createOrUpdateLicenseBySubscription(`one-time-${paymentIntentId}`, {
subscriptionId: null,
return grantOneTimeLicenseAtomic({
paymentIntentId,
customerId,
customerEmail,
planCode,
status: 'paid',
expiresAt: expiresAt.toISOString(),
active: true,
premiumFeatures,
machineFingerprint: null,
deactivatedAt: null,
paymentIntentId
durationDays,
premiumFeatures
});
}
@@ -114,28 +81,35 @@ export function validateLicense({ code, machine }) {
return { success: false, message: 'License not found' };
}
const now = Date.now();
let graceActive = false;
if (license.status === 'past_due') {
if (!license.graceUntil) return { success: false, message: 'Payment is past due' };
if (new Date(license.graceUntil).getTime() <= now) {
updateLicense(license.id, { active: false, status: 'grace_expired' });
return { success: false, message: 'Payment grace period has expired' };
}
graceActive = true;
}
if (!license.active) {
return { success: false, message: 'License is not active' };
}
const now = Date.now();
if (license.expiresAt && new Date(license.expiresAt).getTime() < now) {
if (!graceActive && license.expiresAt && new Date(license.expiresAt).getTime() < now) {
return { success: false, message: 'License has expired' };
}
const fingerprint = fingerprintMachine(machine || {});
if (license.machineFingerprint && license.machineFingerprint !== fingerprint) {
const machineClaim = claimLicenseMachine(code, fingerprint);
if (!machineClaim.claimed) {
return { success: false, message: 'License is already active on another machine' };
}
const updated = license.machineFingerprint
? license
: updateLicense(license.id, { machineFingerprint: fingerprint, activatedAt: new Date().toISOString() });
const updated = machineClaim.license;
return {
success: true,
license: {
id: updated.id,
code: updated.key,
tier: 'premium',
expiresAt: updated.expiresAt,
@@ -143,7 +117,7 @@ export function validateLicense({ code, machine }) {
subscriptionStatus: updated.status,
customerEmail: updated.customerEmail
},
message: updated === license ? 'License validated' : 'License activated on this machine'
message: machineClaim.existing ? 'License validated' : 'License activated on this machine'
};
}
+1 -1
View File
@@ -37,7 +37,7 @@ export const PLAN_DEFS = {
}
};
export const PREMIUM_FEATURES = ['sso', 'recipes', 'swarm', 'fleet'];
export const PREMIUM_FEATURES = ['sso', 'recipes', 'swarm'];
export function listPlans() {
return Object.values(PLAN_DEFS);
+384 -62
View File
@@ -1,18 +1,150 @@
import express from 'express';
import crypto from 'crypto';
import { pathToFileURL } from 'url';
import { config } from './config.js';
import { getPlan, listPlans, PREMIUM_FEATURES } from './plans.js';
import { getStripe } from './stripe.js';
import { getStoreSnapshot, upsertCustomer, upsertSubscription, findLicenseByCustomerId } from './store.js';
import {
getStoreSnapshot,
upsertCustomer,
upsertSubscription,
findLicenseByCustomerId,
findCheckoutResult,
claimWebhookEvent,
finishWebhookEvent,
claimBusinessObject,
finishBusinessObject,
cancelLicenseBySubscription,
markLicensePaymentFailed,
updateLicense
} from './store.js';
import {
syncLicenseFromSubscription,
grantOneTimeLicense,
validateLicense,
deactivateLicense
} from './licenseLogic.js';
import { sendLicenseEmail } from './email.js';
const app = express();
app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }));
app.use(express.json());
app.disable('x-powered-by');
app.set('trust proxy', 'loopback');
const allowedOrigins = new Set([
config.websiteUrl.replace(/\/$/, ''),
'https://dashcaddy.net',
'https://www.dashcaddy.net'
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Vary', 'Origin');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, Stripe-Signature');
res.setHeader('Access-Control-Max-Age', '600');
}
if (req.method === 'OPTIONS') {
return origin && allowedOrigins.has(origin) ? res.sendStatus(204) : res.sendStatus(403);
}
return next();
});
app.use('/api/stripe/webhook', express.raw({ type: 'application/json', limit: '1mb' }));
app.use(express.json({ limit: '16kb' }));
const checkoutAttempts = new Map();
const CHECKOUT_WINDOW_MS = 15 * 60 * 1000;
const CHECKOUT_LIMIT = 20;
function checkoutRateLimit(req, res, next) {
const now = Date.now();
const key = req.ip || req.socket.remoteAddress || 'unknown';
const isNewKey = !checkoutAttempts.has(key);
const recent = (checkoutAttempts.get(key) || []).filter((time) => now - time < CHECKOUT_WINDOW_MS);
if (recent.length >= CHECKOUT_LIMIT) {
res.setHeader('Retry-After', String(Math.ceil((CHECKOUT_WINDOW_MS - (now - recent[0])) / 1000)));
return res.status(429).json({ ok: false, error: 'Too many checkout attempts. Try again later.' });
}
recent.push(now);
checkoutAttempts.set(key, recent);
if (isNewKey) {
const timer = setTimeout(() => checkoutAttempts.delete(key), CHECKOUT_WINDOW_MS + 1000);
timer.unref?.();
}
return next();
}
function normalizeCustomerEmail(value) {
const email = String(value || '').trim().toLowerCase();
if (email.length > 254 || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return null;
return email;
}
function stripeObjectId(value) {
if (!value) return null;
return typeof value === 'string' ? value : value.id || null;
}
function getInvoiceSubscriptionId(invoice) {
return stripeObjectId(invoice?.parent?.subscription_details?.subscription)
|| stripeObjectId(invoice?.subscription);
}
function getSubscriptionPeriodEnd(subscription) {
const itemEnds = subscription?.items?.data
?.map((item) => Number(item.current_period_end || 0))
.filter(Boolean) || [];
const timestamp = itemEnds.length ? Math.max(...itemEnds) : Number(subscription?.current_period_end || 0);
return timestamp ? new Date(timestamp * 1000).toISOString() : null;
}
function requireAdmin(req, res, next) {
if (!config.adminToken) return res.status(404).json({ ok: false, error: 'Not found' });
const supplied = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
const expected = Buffer.from(config.adminToken);
const actual = Buffer.from(supplied);
if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
return res.status(401).json({ ok: false, error: 'Unauthorized' });
}
return next();
}
async function deliverAndTrackLicenseEmail({ license, customerEmail, code, durationDays, planCode, extended, deliveryId }, sender = sendLicenseEmail) {
if (deliveryId && license.lastEmailDeliveryId === deliveryId) {
return { delivered: true, via: license.emailDeliveryVia || 'recorded', skipped: true };
}
if (!customerEmail) {
updateLicense(license.id, { emailDeliveryStatus: 'failed', emailDeliveryError: 'missing customer email' });
throw new Error('License fulfillment is missing customer email');
}
updateLicense(license.id, { emailDeliveryStatus: 'pending', emailDeliveryError: null });
try {
const result = await sender({
to: customerEmail,
code,
durationDays,
planCode,
expiresAt: license.expiresAt,
extended
});
if (!result.delivered) throw new Error('SMTP did not confirm delivery');
updateLicense(license.id, {
emailDeliveryStatus: 'delivered',
emailDeliveryVia: result.via || null,
emailDeliveredAt: new Date().toISOString(),
lastEmailDeliveryId: deliveryId || license.lastEmailDeliveryId || null,
emailDeliveryError: null
});
return result;
} catch (error) {
updateLicense(license.id, {
emailDeliveryStatus: 'failed',
emailDeliveryError: String(error.message || error).slice(0, 500)
});
console.error('License email failed', error.message);
throw error;
}
}
app.get('/health', (_req, res) => {
res.json({ ok: true, service: 'dashcaddy-license-server' });
@@ -30,7 +162,34 @@ app.get('/api/public/plans', (_req, res) => {
res.json({ ok: true, tier: 'premium', features: PREMIUM_FEATURES, plans: listPlans() });
});
app.get('/api/admin/debug/store', (_req, res) => {
app.get('/api/checkout/session/:sessionId', (req, res) => {
res.setHeader('Cache-Control', 'no-store');
const sessionId = String(req.params.sessionId || '');
if (!/^cs_[A-Za-z0-9_]+$/.test(sessionId)) {
return res.status(404).json({ status: 'not_found' });
}
const result = findCheckoutResult(sessionId);
if (!result) return res.status(404).json({ status: 'not_found' });
if (!result.license) return res.json({ status: 'processing' });
const plan = getPlan(result.license.planCode);
const expired = result.license.expiresAt && new Date(result.license.expiresAt).getTime() <= Date.now();
const deliveryStatus = result.license.emailDeliveryStatus;
const status = expired
? 'expired'
: deliveryStatus === 'pending' || !deliveryStatus
? 'processing'
: deliveryStatus === 'failed' ? 'pending_email' : 'delivered';
return res.json({
status,
code: result.license.key,
productId: result.license.planCode?.replace(/^premium_/, 'pro-') || null,
durationDays: plan?.durationDays || null,
deliveredVia: result.license.emailDeliveryVia || null
});
});
app.get('/api/admin/debug/store', requireAdmin, (_req, res) => {
res.json({ ok: true, store: getStoreSnapshot() });
});
@@ -39,11 +198,13 @@ app.get('/api/admin/debug/store', (_req, res) => {
* plan's interval. The license is created on the first webhook event and
* extended on every renewal.
*/
app.post('/api/checkout/subscription', async (req, res) => {
app.post('/api/checkout/subscription', checkoutRateLimit, async (req, res) => {
try {
const { planCode, customerEmail } = req.body || {};
const plan = getPlan(planCode);
if (!plan) return res.status(400).json({ ok: false, error: 'Invalid planCode' });
const normalizedEmail = normalizeCustomerEmail(customerEmail);
if (!normalizedEmail) return res.status(400).json({ ok: false, error: 'Valid customerEmail is required' });
const stripe = getStripe();
const session = await stripe.checkout.sessions.create({
@@ -62,22 +223,20 @@ app.post('/api/checkout/subscription', async (req, res) => {
cancel_url: `${config.websiteUrl}/pricing`,
allow_promotion_codes: true,
billing_address_collection: 'required',
customer_email: customerEmail || undefined,
customer_email: normalizedEmail,
metadata: {
source: 'dashcaddy.net',
planCode: plan.code,
tier: plan.tier,
mode: 'subscription',
// Bake the email into session metadata so the webhook can find
// the existing license without a Stripe API call.
customerEmail: customerEmail || ''
customerEmail: normalizedEmail
},
subscription_data: {
metadata: {
source: 'dashcaddy.net',
planCode: plan.code,
tier: plan.tier,
customerEmail: customerEmail || ''
customerEmail: normalizedEmail
}
}
});
@@ -85,7 +244,7 @@ app.post('/api/checkout/subscription', async (req, res) => {
return res.json({ ok: true, url: session.url, sessionId: session.id });
} catch (error) {
console.error('Subscription checkout error:', error);
return res.status(500).json({ ok: false, error: error.message || 'Checkout failed' });
return res.status(500).json({ ok: false, error: 'Checkout is temporarily unavailable' });
}
});
@@ -93,15 +252,18 @@ app.post('/api/checkout/subscription', async (req, res) => {
* One-time purchase checkout. Customer pays once for a license of the
* plan duration. If they buy again, time is added to their existing license.
*/
app.post('/api/checkout/one-time', async (req, res) => {
app.post('/api/checkout/one-time', checkoutRateLimit, async (req, res) => {
try {
const { planCode, customerEmail } = req.body || {};
const plan = getPlan(planCode);
if (!plan) return res.status(400).json({ ok: false, error: 'Invalid planCode' });
const normalizedEmail = normalizeCustomerEmail(customerEmail);
if (!normalizedEmail) return res.status(400).json({ ok: false, error: 'Valid customerEmail is required' });
const stripe = getStripe();
const session = await stripe.checkout.sessions.create({
mode: 'payment',
customer_creation: 'always',
payment_method_types: ['card'],
line_items: [{
price_data: {
@@ -115,14 +277,14 @@ app.post('/api/checkout/one-time', async (req, res) => {
cancel_url: `${config.websiteUrl}/pricing`,
allow_promotion_codes: true,
billing_address_collection: 'required',
customer_email: customerEmail || undefined,
customer_email: normalizedEmail,
metadata: {
source: 'dashcaddy.net',
planCode: plan.code,
tier: plan.tier,
mode: 'one-time',
durationDays: String(plan.durationDays),
customerEmail: customerEmail || ''
customerEmail: normalizedEmail
},
payment_intent_data: {
metadata: {
@@ -131,7 +293,7 @@ app.post('/api/checkout/one-time', async (req, res) => {
tier: plan.tier,
mode: 'one-time',
durationDays: String(plan.durationDays),
customerEmail: customerEmail || ''
customerEmail: normalizedEmail
}
}
});
@@ -139,38 +301,22 @@ app.post('/api/checkout/one-time', async (req, res) => {
return res.json({ ok: true, url: session.url, sessionId: session.id });
} catch (error) {
console.error('One-time checkout error:', error);
return res.status(500).json({ ok: false, error: error.message || 'Checkout failed' });
return res.status(500).json({ ok: false, error: 'Checkout is temporarily unavailable' });
}
});
/**
* Look up an existing license for a customer. Tries customerId first,
* then falls back to email. Returns null if no active license exists.
*/
function findCustomersLicense(customerId, customerEmail) {
if (customerId) {
const byCustomer = findLicenseByCustomerId(customerId);
if (byCustomer) return byCustomer;
}
if (customerEmail) {
const { findActiveLicenseByCustomerEmail } = require('./store.js');
const byEmail = findActiveLicenseByCustomerEmail(customerEmail);
if (byEmail) return byEmail;
}
return null;
}
/**
* Stripe webhook endpoint. Handles subscription lifecycle events AND
* one-time payment completion events. The same handler routes based on
* event type and updates the customer's license accordingly.
*/
app.post('/api/stripe/webhook', async (req, res) => {
let claimedEventId = null;
let claimedBusiness = null;
try {
if (!config.stripeWebhookSecret) {
return res.status(500).json({ ok: false, error: 'Missing STRIPE_WEBHOOK_SECRET' });
}
const signature = req.headers['stripe-signature'];
if (!signature) {
return res.status(400).json({ ok: false, error: 'Missing stripe-signature header' });
@@ -178,6 +324,12 @@ app.post('/api/stripe/webhook', async (req, res) => {
const stripe = getStripe();
const event = stripe.webhooks.constructEvent(req.body, signature, config.stripeWebhookSecret);
const eventClaim = claimWebhookEvent(event.id);
if (!eventClaim.claimed) {
if (eventClaim.status === 'completed') return res.json({ ok: true, duplicate: true });
return res.status(409).json({ ok: false, retry: true, error: 'Webhook event is still processing' });
}
claimedEventId = event.id;
switch (event.type) {
case 'checkout.session.completed': {
@@ -185,28 +337,56 @@ app.post('/api/stripe/webhook', async (req, res) => {
const planCode = session.metadata?.planCode;
const mode = session.metadata?.mode || 'subscription';
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
// Email resolution: session metadata (baked at creation) > session.customer_email > session.customer_details.email
const customerEmail = session.metadata?.customerEmail || session.customer_email || session.customer_details?.email || null;
if (customerId) {
upsertCustomer({ id: String(customerId), email: customerEmail, checkoutSessionId: session.id });
}
// For one-time payments, generate the license on checkout.session.completed
if (mode === 'one-time' && planCode) {
// For one-time payments, generate/extend the license and email it.
if (mode === 'one-time' && (!planCode || session.payment_status !== 'paid')) {
throw new Error('One-time checkout completed without a paid status or plan code');
}
if (mode === 'one-time' && planCode && session.payment_status === 'paid') {
const plan = getPlan(planCode);
if (!plan) throw new Error('Paid checkout references an unknown plan');
if (plan) {
const paymentIntentId = stripeObjectId(session.payment_intent);
if (!paymentIntentId || !customerId || !customerEmail) {
throw new Error('Paid checkout is missing payment intent, customer, or email');
}
const license = grantOneTimeLicense({
paymentIntentId: typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent?.id,
paymentIntentId,
customerId,
customerEmail,
planCode,
durationDays: plan.durationDays
});
// The stored key is already human-readable and is the exact value
// accepted by /api/license/validate. Never email a derived code
// that the validation store cannot resolve.
const code = license.key;
if (!license.idempotent || license.emailDeliveryStatus !== 'delivered') {
const delivery = await deliverAndTrackLicenseEmail({
license,
customerEmail,
code,
durationDays: plan.durationDays,
planCode,
extended: license.extended || false,
deliveryId: paymentIntentId
});
console.log('License email', {
delivered: delivery.delivered,
via: delivery.via,
extended: license.extended
});
}
console.log('One-time license granted', {
sessionId: session.id,
licenseKey: license.key,
customerEmail,
extended: license.extended || false,
addedDays: license.addedDays || plan.durationDays
});
@@ -215,11 +395,74 @@ app.post('/api/stripe/webhook', async (req, res) => {
break;
}
case 'invoice.paid': {
// Subscription renewal. Extend the existing license once per Stripe invoice.
const invoice = event.data.object;
const invoiceClaim = claimBusinessObject('invoice.paid', invoice.id);
if (!invoiceClaim.claimed) {
if (invoiceClaim.status === 'completed') {
console.log('Duplicate invoice.paid ignored', { invoiceId: invoice.id });
break;
}
throw new Error('Invoice is already being processed; retry later');
}
claimedBusiness = { kind: 'invoice.paid', id: invoice.id };
const subscriptionId = getInvoiceSubscriptionId(invoice);
const customerId = typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id;
if (!subscriptionId || !customerId) throw new Error('Paid invoice is missing subscription or customer');
if (subscriptionId && customerId) {
const sub = await stripe.subscriptions.retrieve(subscriptionId);
const planCode = sub.metadata?.planCode || 'premium_30d';
const customerEmail = sub.metadata?.customerEmail || invoice.customer_email || null;
const currentPeriodEnd = getSubscriptionPeriodEnd(sub);
upsertSubscription({
id: subscriptionId,
customerId,
status: sub.status,
planCode,
currentPeriodEnd,
cancelAtPeriodEnd: Boolean(sub.cancel_at_period_end),
lastStripeEventCreated: event.created,
lastStripeEventId: event.id
});
const license = syncLicenseFromSubscription({
subscriptionId,
customerId,
customerEmail,
planCode,
status: sub.status,
currentPeriodEnd,
eventCreated: event.created,
eventId: event.id
});
const code = license.key;
await deliverAndTrackLicenseEmail({
license,
customerEmail,
code,
durationDays: getPlan(planCode)?.durationDays || 30,
planCode,
extended: true,
deliveryId: invoice.id
});
console.log('Subscription renewal extended license', {
subscriptionId,
expiresAt: license.expiresAt
});
}
break;
}
case 'customer.subscription.created':
case 'customer.subscription.updated': {
const subscription = event.data.object;
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
// Email resolution: subscription metadata (baked at creation) > fetch from Stripe if missing
if (!subscription.id || !customerId) throw new Error('Subscription event is missing subscription or customer');
let customerEmail = subscription.metadata?.customerEmail || null;
if (!customerEmail && customerId) {
try {
@@ -230,9 +473,7 @@ app.post('/api/stripe/webhook', async (req, res) => {
}
}
const planCode = subscription.metadata?.planCode || 'premium_30d';
const currentPeriodEnd = subscription.current_period_end
? new Date(subscription.current_period_end * 1000).toISOString()
: null;
const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
upsertSubscription({
id: subscription.id,
@@ -240,7 +481,9 @@ app.post('/api/stripe/webhook', async (req, res) => {
status: subscription.status,
planCode,
currentPeriodEnd,
cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end)
cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end),
lastStripeEventCreated: event.created,
lastStripeEventId: event.id
});
const license = syncLicenseFromSubscription({
@@ -249,35 +492,65 @@ app.post('/api/stripe/webhook', async (req, res) => {
customerEmail,
planCode,
status: subscription.status,
currentPeriodEnd
currentPeriodEnd,
eventCreated: event.created,
eventId: event.id
});
console.log('Subscription license synced', {
subscriptionId: subscription.id,
licenseKey: license.key,
status: license.status,
expiresAt: license.expiresAt,
customerEmail
expiresAt: license.expiresAt
});
break;
}
case 'customer.subscription.deleted': {
const subscription = event.data.object;
// Mark the subscription cancelled. License remains active until
// currentPeriodEnd (Stripe handles non-renewal automatically).
upsertSubscription({
id: subscription.id,
status: 'canceled',
cancelAtPeriodEnd: true
currentPeriodEnd: getSubscriptionPeriodEnd(subscription),
cancelAtPeriodEnd: true,
lastStripeEventCreated: event.created,
lastStripeEventId: event.id
});
const license = cancelLicenseBySubscription(subscription.id, event.created, event.id);
console.log('Subscription cancelled', {
subscriptionId: subscription.id,
licenseActiveUntilExpiry: license?.active ?? null,
expiresAt: license?.expiresAt || null
});
console.log('Subscription cancelled', { subscriptionId: subscription.id });
break;
}
case 'invoice.payment_failed': {
const invoice = event.data.object;
console.warn('Payment failed', { invoiceId: invoice.id, customerId: invoice.customer });
const failureClaim = claimBusinessObject('invoice.payment_failed', invoice.id);
if (!failureClaim.claimed) {
if (failureClaim.status === 'completed') {
console.log('Duplicate invoice.payment_failed ignored', { invoiceId: invoice.id });
break;
}
throw new Error('Failed invoice is already being processed; retry later');
}
claimedBusiness = { kind: 'invoice.payment_failed', id: invoice.id };
const subscriptionId = getInvoiceSubscriptionId(invoice);
if (!subscriptionId) throw new Error('Failed invoice is missing subscription');
const failureAnchor = Number(invoice.due_date || invoice.period_end || invoice.created || event.created);
const graceUntil = new Date((failureAnchor + 7 * 24 * 60 * 60) * 1000).toISOString();
if (subscriptionId) {
upsertSubscription({
id: subscriptionId,
status: 'past_due',
graceUntil,
customerId: typeof invoice.customer === 'string' ? invoice.customer : invoice.customer?.id || null,
lastStripeEventCreated: event.created,
lastStripeEventId: event.id
});
markLicensePaymentFailed(subscriptionId, graceUntil, event.created, event.id);
}
console.warn('Payment failed', { invoiceId: invoice.id, subscriptionId, graceUntil });
break;
}
@@ -285,27 +558,76 @@ app.post('/api/stripe/webhook', async (req, res) => {
console.log('Unhandled Stripe event', event.type);
}
if (claimedBusiness) finishBusinessObject(claimedBusiness.kind, claimedBusiness.id);
finishWebhookEvent(claimedEventId);
return res.json({ ok: true });
} catch (error) {
if (claimedBusiness) {
try { finishBusinessObject(claimedBusiness.kind, claimedBusiness.id, error.message || 'Processing failed'); } catch (_) {}
}
if (claimedEventId) {
try { finishWebhookEvent(claimedEventId, error.message || 'Webhook failed'); } catch (_) {}
}
console.error('Webhook processing error:', error);
return res.status(400).json({ ok: false, error: error.message || 'Webhook failed' });
return res.status(400).json({ ok: false, error: 'Webhook processing failed' });
}
});
app.post('/api/license/validate', async (req, res) => {
const { code, machine } = req.body || {};
const { code, machine, machineId } = req.body || {};
if (!code) return res.status(400).json({ ok: false, error: 'License code is required' });
const result = validateLicense({ code, machine });
return res.status(result.success ? 200 : 400).json(result);
const hasMachineObject = machine && typeof machine === 'object'
&& ['hostname', 'platform', 'arch', 'cpu', 'mac'].some((field) => String(machine[field] || '').trim());
if (!machineId && !hasMachineObject) {
return res.status(400).json({ success: false, error: 'Machine identity is required' });
}
const machinePayload = machine || (machineId ? { hostname: String(machineId) } : {});
const result = validateLicense({ code, machine: machinePayload });
if (!result.success) {
return res.status(400).json({ success: false, error: result.message, message: result.message });
}
const featureList = Object.entries(result.license.features || {})
.filter(([, enabled]) => Boolean(enabled))
.map(([feature]) => feature);
const durationDays = result.license.expiresAt
? Math.max(0, Math.ceil((new Date(result.license.expiresAt).getTime() - Date.now()) / 86400000))
: null;
return res.json({
...result,
codeId: result.license.id || null,
durationDays,
expiresAt: result.license.expiresAt,
features: featureList,
token: null
});
});
app.post('/api/license/deactivate', async (req, res) => {
const { code, machine } = req.body || {};
const { code, machine, machineId } = req.body || {};
if (!code) return res.status(400).json({ ok: false, error: 'License code is required' });
const result = deactivateLicense({ code, machine });
const hasMachineObject = machine && typeof machine === 'object'
&& ['hostname', 'platform', 'arch', 'cpu', 'mac'].some((field) => String(machine[field] || '').trim());
if (!machineId && !hasMachineObject) {
return res.status(400).json({ success: false, error: 'Machine identity is required' });
}
const machinePayload = machine || (machineId ? { hostname: String(machineId) } : {});
const result = deactivateLicense({ code, machine: machinePayload });
return res.status(result.success ? 200 : 400).json(result);
});
app.listen(config.port, () => {
function validateStartupConfig() {
const missing = [];
if (!config.stripeSecretKey) missing.push('STRIPE_SECRET_KEY');
if (!config.stripeWebhookSecret) missing.push('STRIPE_WEBHOOK_SECRET');
if (!/^[A-Fa-f0-9]{32,}$/.test(config.licenseSecret)) missing.push('DASHCADDY_LICENSE_SECRET (hex, at least 128 bits)');
if (missing.length) throw new Error(`Missing or invalid production configuration: ${missing.join(', ')}`);
}
export { app, getInvoiceSubscriptionId, getSubscriptionPeriodEnd, validateStartupConfig, deliverAndTrackLicenseEmail };
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
validateStartupConfig();
app.listen(config.port, () => {
console.log(`dashcaddy-license-server listening on :${config.port}`);
});
});
}
+398 -67
View File
@@ -1,123 +1,454 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { DatabaseSync } from 'node:sqlite';
import { generateCompatibleLicenseCode, verifyCompatibleLicenseCode } from './licenseCode.js';
const DATA_DIR = process.env.DATA_DIR || path.resolve(process.cwd(), 'data');
const DATA_FILE = path.join(DATA_DIR, 'db.json');
const DB_FILE = path.join(DATA_DIR, 'db.sqlite');
const LEGACY_JSON_FILE = path.join(DATA_DIR, 'db.json');
function ensureStore() {
fs.mkdirSync(DATA_DIR, { recursive: true });
if (!fs.existsSync(DATA_FILE)) {
fs.writeFileSync(DATA_FILE, JSON.stringify({ customers: {}, subscriptions: {}, licenses: {} }, null, 2));
fs.mkdirSync(DATA_DIR, { recursive: true });
const db = new DatabaseSync(DB_FILE);
db.exec(`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = FULL;
PRAGMA busy_timeout = 5000;
CREATE TABLE IF NOT EXISTS customers (id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS subscriptions (id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS licenses (id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS checkout_sessions (
id TEXT PRIMARY KEY,
customer_id TEXT,
email TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS webhook_events (
id TEXT PRIMARY KEY,
status TEXT NOT NULL,
error TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS processed_objects (
kind TEXT NOT NULL,
object_id TEXT NOT NULL,
status TEXT NOT NULL,
error TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (kind, object_id)
);
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
`);
const TABLES = new Set(['customers', 'subscriptions', 'licenses']);
const PLAN_DURATIONS = { premium_30d: 30, premium_90d: 90, premium_180d: 180, premium_365d: 365 };
const STRIPE_STATUS_RANK = { grace_expired: 0, past_due: 1, trialing: 2, active: 3, paid: 3, canceled: 4 };
export function isStripeTransitionStale(current, eventCreated = 0, eventId = '', incomingStatus = '') {
const incomingTime = Number(eventCreated || 0);
const previousTime = Number(current?.lastStripeEventCreated || 0);
if (incomingTime && previousTime && incomingTime < previousTime) return true;
if (!incomingTime || !previousTime || incomingTime > previousTime) return false;
const incomingRank = STRIPE_STATUS_RANK[incomingStatus] ?? 0;
const previousRank = STRIPE_STATUS_RANK[current?.status] ?? 0;
if (incomingRank !== previousRank) return incomingRank < previousRank;
const previousId = String(current?.lastStripeEventId || '');
return Boolean(previousId && eventId && String(eventId) <= previousId);
}
function transaction(fn) {
db.exec('BEGIN IMMEDIATE');
try {
const result = fn();
db.exec('COMMIT');
return result;
} catch (error) {
try { db.exec('ROLLBACK'); } catch (_) {}
throw error;
}
}
function readStore() {
ensureStore();
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
function decode(row) {
return row ? JSON.parse(row.data) : null;
}
function writeStore(db) {
ensureStore();
fs.writeFileSync(DATA_FILE, JSON.stringify(db, null, 2));
function loadOne(table, id) {
if (!TABLES.has(table)) throw new Error('Invalid table');
return decode(db.prepare(`SELECT data FROM ${table} WHERE id = ?`).get(id));
}
export function createLicenseKey() {
const raw = crypto.randomBytes(16).toString('hex').toUpperCase();
return `DC-${raw.slice(0,5)}-${raw.slice(5,10)}-${raw.slice(10,15)}-${raw.slice(15,20)}-${raw.slice(20,25)}`;
function loadAll(table) {
if (!TABLES.has(table)) throw new Error('Invalid table');
return db.prepare(`SELECT data FROM ${table}`).all().map(decode);
}
function saveOne(table, id, value) {
if (!TABLES.has(table)) throw new Error('Invalid table');
db.prepare(`INSERT INTO ${table} (id, data) VALUES (?, ?)
ON CONFLICT(id) DO UPDATE SET data = excluded.data`).run(id, JSON.stringify(value));
return value;
}
function migrateLegacyJson() {
const count = Number(db.prepare('SELECT COUNT(*) AS count FROM licenses').get().count);
if (count > 0 || !fs.existsSync(LEGACY_JSON_FILE)) return;
let legacy;
try { legacy = JSON.parse(fs.readFileSync(LEGACY_JSON_FILE, 'utf8')); } catch (_) { return; }
transaction(() => {
let maxCodeId = 0;
for (const [id, value] of Object.entries(legacy.customers || {})) saveOne('customers', id, value);
for (const [id, value] of Object.entries(legacy.subscriptions || {})) saveOne('subscriptions', id, value);
for (const [id, value] of Object.entries(legacy.licenses || {})) {
saveOne('licenses', id, value);
const parsed = verifyCompatibleLicenseCode(value.key);
if (parsed.valid) maxCodeId = Math.max(maxCodeId, parsed.codeId);
}
db.prepare(`INSERT OR REPLACE INTO meta (key, value) VALUES ('license_code_counter', ?)`)
.run(String(maxCodeId));
for (const [id, value] of Object.entries(legacy.webhookEvents || {})) {
db.prepare(`INSERT OR REPLACE INTO webhook_events (id, status, error, updated_at) VALUES (?, ?, ?, ?)`)
.run(id, value.status || 'completed', value.error || null, value.updatedAt || new Date().toISOString());
}
for (const customer of Object.values(legacy.customers || {})) {
if (customer.checkoutSessionId) {
db.prepare(`INSERT OR REPLACE INTO checkout_sessions (id, customer_id, email, created_at) VALUES (?, ?, ?, ?)`)
.run(customer.checkoutSessionId, customer.id || null, customer.email || null, customer.updatedAt || new Date().toISOString());
}
}
});
fs.copyFileSync(LEGACY_JSON_FILE, `${LEGACY_JSON_FILE}.migrated-backup`);
}
migrateLegacyJson();
export function createLicenseKey(durationDays) {
db.prepare(`INSERT OR IGNORE INTO meta (key, value) VALUES ('license_code_counter', '0')`).run();
const row = db.prepare(`UPDATE meta SET value = CAST(value AS INTEGER) + 1
WHERE key = 'license_code_counter' RETURNING value`).get();
const codeId = Number(row.value);
return { key: generateCompatibleLicenseCode(durationDays, codeId), codeId };
}
function recomputeEntitlement(license) {
const now = Date.now();
const oneTimeExpiry = license.oneTimeExpiresAt ? new Date(license.oneTimeExpiresAt).getTime() : 0;
const subscriptionExpiry = license.subscriptionExpiresAt ? new Date(license.subscriptionExpiresAt).getTime() : 0;
const graceExpiry = license.subscriptionGraceUntil ? new Date(license.subscriptionGraceUntil).getTime() : 0;
const oneTimeActive = oneTimeExpiry > now;
const subscriptionStatus = license.subscriptionStatus || license.status;
const subscriptionActive = ['active', 'trialing'].includes(subscriptionStatus) && subscriptionExpiry > now;
const subscriptionGraceActive = subscriptionStatus === 'past_due' && graceExpiry > now;
const canceledPaidThrough = subscriptionStatus === 'canceled' && subscriptionExpiry > now;
const active = oneTimeActive || subscriptionActive || subscriptionGraceActive || canceledPaidThrough;
const effectiveExpiry = Math.max(
oneTimeActive ? oneTimeExpiry : 0,
subscriptionActive || canceledPaidThrough ? subscriptionExpiry : 0,
subscriptionGraceActive ? graceExpiry : 0
);
return {
...license,
active,
status: oneTimeActive ? 'active' : (subscriptionStatus || (active ? 'active' : 'expired')),
expiresAt: effectiveExpiry ? new Date(effectiveExpiry).toISOString() : license.expiresAt
};
}
export function upsertCustomer(customer) {
const db = readStore();
db.customers[customer.id] = { ...(db.customers[customer.id] || {}), ...customer, updatedAt: new Date().toISOString() };
writeStore(db);
return db.customers[customer.id];
return transaction(() => {
const next = { ...(loadOne('customers', customer.id) || {}), ...customer, updatedAt: new Date().toISOString() };
saveOne('customers', customer.id, next);
if (customer.checkoutSessionId) {
db.prepare(`INSERT OR REPLACE INTO checkout_sessions (id, customer_id, email, created_at) VALUES (?, ?, ?, ?)`)
.run(customer.checkoutSessionId, customer.id || null, customer.email || null, new Date().toISOString());
}
return next;
});
}
export function upsertSubscription(subscription) {
const db = readStore();
db.subscriptions[subscription.id] = { ...(db.subscriptions[subscription.id] || {}), ...subscription, updatedAt: new Date().toISOString() };
writeStore(db);
return db.subscriptions[subscription.id];
return transaction(() => {
const existing = loadOne('subscriptions', subscription.id) || {};
const incomingEvent = Number(subscription.lastStripeEventCreated || 0);
const previousEvent = Number(existing.lastStripeEventCreated || 0);
if (isStripeTransitionStale(existing, incomingEvent, subscription.lastStripeEventId, subscription.status)) {
return { ...existing, staleEventIgnored: true };
}
const next = {
...existing,
...subscription,
lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
lastStripeEventId: subscription.lastStripeEventId || existing.lastStripeEventId || null,
updatedAt: new Date().toISOString()
};
return saveOne('subscriptions', subscription.id, next);
});
}
export function createOrUpdateLicenseBySubscription(subscriptionId, patch) {
const db = readStore();
const existing = Object.values(db.licenses).find((lic) => lic.subscriptionId === subscriptionId);
return transaction(() => {
const licenses = loadAll('licenses');
const existing = licenses.find((lic) => lic.subscriptionId === subscriptionId)
|| licenses.find((lic) => patch.customerId && lic.customerId === patch.customerId);
const id = existing?.id || crypto.randomUUID();
const next = {
const durationDays = patch.durationDays || PLAN_DURATIONS[patch.planCode];
const allocated = existing ? null : createLicenseKey(durationDays);
const inferredOneTimeExpiry = existing?.oneTimeExpiresAt
|| (existing?.paymentIntentId || existing?.processedPaymentIntentIds?.length ? existing.expiresAt : null);
const raw = {
id,
key: existing?.key || createLicenseKey(),
key: existing?.key || allocated.key,
codeId: existing?.codeId || allocated.codeId,
createdAt: existing?.createdAt || new Date().toISOString(),
...existing,
...patch,
subscriptionId,
subscriptionStatus: patch.status || existing?.subscriptionStatus,
subscriptionExpiresAt: patch.expiresAt || existing?.subscriptionExpiresAt,
subscriptionGraceUntil: patch.graceUntil ?? existing?.subscriptionGraceUntil ?? null,
oneTimeExpiresAt: inferredOneTimeExpiry,
durationDays: durationDays || existing?.durationDays,
updatedAt: new Date().toISOString()
};
db.licenses[id] = next;
writeStore(db);
return next;
const next = recomputeEntitlement(raw);
return saveOne('licenses', id, next);
});
}
export function grantOneTimeLicenseAtomic({ paymentIntentId, customerId, customerEmail, planCode, durationDays, premiumFeatures }) {
return transaction(() => {
const licenses = loadAll('licenses');
const existing = licenses.find((lic) => lic.customerId === customerId && customerId);
if (existing) {
const processed = Array.isArray(existing.processedPaymentIntentIds) ? existing.processedPaymentIntentIds : [];
if (paymentIntentId && processed.includes(paymentIntentId)) {
return { ...existing, idempotent: true, extended: false, addedDays: 0 };
}
const paidThrough = existing.oneTimeExpiresAt || existing.expiresAt;
const base = paidThrough && new Date(paidThrough).getTime() > Date.now()
? new Date(paidThrough) : new Date();
base.setUTCDate(base.getUTCDate() + durationDays);
const raw = {
...existing,
customerId: customerId || existing.customerId,
customerEmail: customerEmail || existing.customerEmail,
planCode,
oneTimeExpiresAt: base.toISOString(),
premiumFeatures,
paymentIntentId: paymentIntentId || existing.paymentIntentId,
processedPaymentIntentIds: paymentIntentId ? [...processed, paymentIntentId] : processed,
updatedAt: new Date().toISOString()
};
const next = recomputeEntitlement(raw);
saveOne('licenses', existing.id, next);
return { ...next, extended: true, addedDays: durationDays };
}
const expiresAt = new Date();
expiresAt.setUTCDate(expiresAt.getUTCDate() + durationDays);
const id = crypto.randomUUID();
const allocated = createLicenseKey(durationDays);
const next = {
id,
key: allocated.key,
codeId: allocated.codeId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
subscriptionId: null,
customerId,
customerEmail,
planCode,
status: 'active',
expiresAt: expiresAt.toISOString(),
oneTimeExpiresAt: expiresAt.toISOString(),
active: true,
premiumFeatures,
machineFingerprint: null,
deactivatedAt: null,
paymentIntentId,
processedPaymentIntentIds: paymentIntentId ? [paymentIntentId] : []
};
return saveOne('licenses', id, next);
});
}
export function findLicenseByKey(key) {
const db = readStore();
return Object.values(db.licenses).find((lic) => lic.key === key) || null;
return loadAll('licenses').find((lic) => lic.key === key) || null;
}
/**
* Find an active license by customer email. Used to EXTEND an existing
* license when a customer buys again (so renewal adds time to the same key,
* not a new one).
*/
export function findActiveLicenseByCustomerEmail(email) {
if (!email) return null;
const db = readStore();
const normalized = email.toLowerCase();
return Object.values(db.licenses).find((lic) =>
lic.customerEmail && lic.customerEmail.toLowerCase() === normalized && lic.active
) || null;
return loadAll('licenses').find((lic) => lic.customerEmail?.toLowerCase() === normalized && lic.active) || null;
}
/**
* Find a license by Stripe customer ID. Used by webhook handlers to extend
* the customer's existing license on subscription renewal.
*/
export function findLicenseByCustomerId(customerId) {
if (!customerId) return null;
const db = readStore();
return Object.values(db.licenses).find((lic) => lic.customerId === customerId) || null;
return loadAll('licenses').find((lic) => lic.customerId === customerId) || null;
}
export function findCheckoutResult(sessionId) {
if (!sessionId) return null;
const session = db.prepare('SELECT customer_id, email FROM checkout_sessions WHERE id = ?').get(sessionId);
if (!session) return null;
const license = session.customer_id
? loadAll('licenses').find((item) => item.customerId === session.customer_id) || null
: null;
return { session, license };
}
export function claimWebhookEvent(eventId) {
if (!eventId) return { claimed: false, status: 'invalid' };
return transaction(() => {
const existing = db.prepare('SELECT status, updated_at FROM webhook_events WHERE id = ?').get(eventId);
const processingFresh = existing?.status === 'processing'
&& Date.now() - new Date(existing.updated_at).getTime() < 5 * 60 * 1000;
if (existing?.status === 'completed') return { claimed: false, status: 'completed' };
if (processingFresh) return { claimed: false, status: 'processing' };
db.prepare(`INSERT INTO webhook_events (id, status, error, updated_at) VALUES (?, 'processing', NULL, ?)
ON CONFLICT(id) DO UPDATE SET status='processing', error=NULL, updated_at=excluded.updated_at`)
.run(eventId, new Date().toISOString());
return { claimed: true, status: 'processing' };
});
}
export function finishWebhookEvent(eventId, error = null) {
if (!eventId) return;
transaction(() => {
db.prepare(`INSERT INTO webhook_events (id, status, error, updated_at) VALUES (?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET status=excluded.status, error=excluded.error, updated_at=excluded.updated_at`)
.run(eventId, error ? 'failed' : 'completed', error ? String(error).slice(0, 500) : null, new Date().toISOString());
});
}
export function claimBusinessObject(kind, objectId) {
if (!kind || !objectId) return { claimed: false, status: 'invalid' };
return transaction(() => {
const existing = db.prepare('SELECT status, updated_at FROM processed_objects WHERE kind = ? AND object_id = ?')
.get(kind, objectId);
const processingFresh = existing?.status === 'processing'
&& Date.now() - new Date(existing.updated_at).getTime() < 5 * 60 * 1000;
if (existing?.status === 'completed') return { claimed: false, status: 'completed' };
if (processingFresh) return { claimed: false, status: 'processing' };
db.prepare(`INSERT INTO processed_objects (kind, object_id, status, error, updated_at)
VALUES (?, ?, 'processing', NULL, ?)
ON CONFLICT(kind, object_id) DO UPDATE SET status='processing', error=NULL, updated_at=excluded.updated_at`)
.run(kind, objectId, new Date().toISOString());
return { claimed: true, status: 'processing' };
});
}
export function finishBusinessObject(kind, objectId, error = null) {
if (!kind || !objectId) return;
transaction(() => {
db.prepare(`INSERT INTO processed_objects (kind, object_id, status, error, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(kind, object_id) DO UPDATE SET status=excluded.status, error=excluded.error, updated_at=excluded.updated_at`)
.run(kind, objectId, error ? 'failed' : 'completed', error ? String(error).slice(0, 500) : null, new Date().toISOString());
});
}
export function cancelLicenseBySubscription(subscriptionId, eventCreated = 0, eventId = '') {
if (!subscriptionId) return null;
return transaction(() => {
const license = loadAll('licenses').find((item) => item.subscriptionId === subscriptionId);
if (!license) return null;
const incomingEvent = Number(eventCreated || 0);
const previousEvent = Number(license.lastStripeEventCreated || 0);
if (isStripeTransitionStale({ ...license, status: license.subscriptionStatus || license.status }, incomingEvent, eventId, 'canceled')) {
return { ...license, staleEventIgnored: true };
}
const raw = {
...license,
subscriptionStatus: 'canceled',
subscriptionExpiresAt: license.subscriptionExpiresAt || license.expiresAt,
lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
lastStripeEventId: eventId || license.lastStripeEventId || null,
updatedAt: new Date().toISOString()
};
return saveOne('licenses', license.id, recomputeEntitlement(raw));
});
}
export function markLicensePaymentFailed(subscriptionId, graceUntil, eventCreated = 0, eventId = '') {
if (!subscriptionId) return null;
return transaction(() => {
const license = loadAll('licenses').find((item) => item.subscriptionId === subscriptionId);
if (!license) return null;
const incomingEvent = Number(eventCreated || 0);
const previousEvent = Number(license.lastStripeEventCreated || 0);
if (isStripeTransitionStale({ ...license, status: license.subscriptionStatus || license.status }, incomingEvent, eventId, 'past_due')) {
return { ...license, staleEventIgnored: true };
}
const currentGrace = license.subscriptionGraceUntil || license.graceUntil;
const effectiveGraceUntil = currentGrace
&& new Date(currentGrace).getTime() > new Date(graceUntil).getTime()
? currentGrace : graceUntil;
const raw = {
...license,
subscriptionStatus: 'past_due',
subscriptionGraceUntil: effectiveGraceUntil,
graceUntil: effectiveGraceUntil,
lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
lastStripeEventId: eventId || license.lastStripeEventId || null,
updatedAt: new Date().toISOString()
};
return saveOne('licenses', license.id, recomputeEntitlement(raw));
});
}
/**
* Extend an existing license by a number of days. If the license is expired,
* the extension starts from now. Returns the updated license.
*/
export function extendLicenseByDuration(licenseId, days) {
const db = readStore();
const lic = db.licenses[licenseId];
return transaction(() => {
const lic = loadOne('licenses', licenseId);
if (!lic) return null;
const base = lic.expiresAt ? new Date(lic.expiresAt) : new Date();
const now = Date.now();
const start = base.getTime() > now ? base : new Date(now);
start.setUTCDate(start.getUTCDate() + days);
db.licenses[licenseId] = {
const base = lic.expiresAt && new Date(lic.expiresAt).getTime() > Date.now() ? new Date(lic.expiresAt) : new Date();
base.setUTCDate(base.getUTCDate() + days);
return saveOne('licenses', licenseId, {
...lic,
expiresAt: start.toISOString(),
expiresAt: base.toISOString(),
active: true,
activatedAt: lic.activatedAt || new Date().toISOString(),
updatedAt: new Date().toISOString()
};
writeStore(db);
return db.licenses[licenseId];
});
});
}
export function updateLicense(id, patch) {
const db = readStore();
if (!db.licenses[id]) return null;
db.licenses[id] = { ...db.licenses[id], ...patch, updatedAt: new Date().toISOString() };
writeStore(db);
return db.licenses[id];
return transaction(() => {
const existing = loadOne('licenses', id);
if (!existing) return null;
return saveOne('licenses', id, { ...existing, ...patch, updatedAt: new Date().toISOString() });
});
}
export function claimLicenseMachine(key, fingerprint) {
return transaction(() => {
const license = loadAll('licenses').find((item) => item.key === key);
if (!license) return { claimed: false, reason: 'not_found', license: null };
if (license.machineFingerprint && license.machineFingerprint !== fingerprint) {
return { claimed: false, reason: 'different_machine', license };
}
if (license.machineFingerprint === fingerprint) {
return { claimed: true, existing: true, license };
}
const next = {
...license,
machineFingerprint: fingerprint,
activatedAt: new Date().toISOString(),
updatedAt: new Date().toISOString()
};
saveOne('licenses', license.id, next);
return { claimed: true, existing: false, license: next };
});
}
export function getStoreSnapshot() {
return readStore();
const toMap = (items) => Object.fromEntries(items.map((item) => [item.id, item]));
const webhookEvents = Object.fromEntries(db.prepare('SELECT id, status, error, updated_at FROM webhook_events').all()
.map((item) => [item.id, { status: item.status, error: item.error, updatedAt: item.updated_at }]));
return {
customers: toMap(loadAll('customers')),
subscriptions: toMap(loadAll('subscriptions')),
licenses: toMap(loadAll('licenses')),
webhookEvents
};
}
+618
View File
@@ -0,0 +1,618 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-license-test-'));
process.env.DATA_DIR = dataDir;
process.env.DASHCADDY_WEBSITE_URL = 'https://dashcaddy.net';
process.env.DASHCADDY_LICENSE_SECRET = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef';
delete process.env.ADMIN_TOKEN;
fs.writeFileSync(path.join(dataDir, 'db.json'), JSON.stringify({
customers: { cus_legacy: { id: 'cus_legacy', email: 'legacy@example.com', checkoutSessionId: 'cs_legacy_123' } },
subscriptions: {},
licenses: {
lic_legacy: {
id: 'lic_legacy',
key: 'DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE',
customerId: 'cus_legacy',
customerEmail: 'legacy@example.com',
planCode: 'premium_30d',
status: 'paid',
active: true,
expiresAt: new Date(Date.now() + 86400000).toISOString(),
},
},
}, null, 2));
const { app, getInvoiceSubscriptionId, getSubscriptionPeriodEnd, deliverAndTrackLicenseEmail } = await import('../src/server.js');
const store = await import('../src/store.js');
const licenseLogic = await import('../src/licenseLogic.js');
const { verifyCompatibleLicenseCode } = await import('../src/licenseCode.js');
const server = app.listen(0, '127.0.0.1');
await new Promise((resolve) => server.once('listening', resolve));
const base = `http://127.0.0.1:${server.address().port}`;
test.after(() => {
server.close();
fs.rmSync(dataDir, { recursive: true, force: true });
});
test('legacy JSON entitlements migrate into transactional SQLite', () => {
const migrated = store.findCheckoutResult('cs_legacy_123');
assert.equal(migrated.license.key, 'DC-AAAAA-BBBBB-CCCCC-DDDDD-EEEEE');
assert.equal(fs.existsSync(path.join(dataDir, 'db.json.migrated-backup')), true);
});
test('browser preflight permits dashcaddy.net and rejects unrelated origins', async () => {
const allowed = await fetch(`${base}/api/checkout/one-time`, {
method: 'OPTIONS',
headers: {
Origin: 'https://dashcaddy.net',
'Access-Control-Request-Method': 'POST',
'Access-Control-Request-Headers': 'content-type',
},
});
assert.equal(allowed.status, 204);
assert.equal(allowed.headers.get('access-control-allow-origin'), 'https://dashcaddy.net');
const denied = await fetch(`${base}/api/checkout/one-time`, {
method: 'OPTIONS',
headers: { Origin: 'https://evil.example', 'Access-Control-Request-Method': 'POST' },
});
assert.equal(denied.status, 403);
assert.equal(denied.headers.get('access-control-allow-origin'), null);
});
test('checkout rejects invalid email before contacting Stripe', async () => {
const response = await fetch(`${base}/api/checkout/one-time`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Origin: 'https://dashcaddy.net' },
body: JSON.stringify({ planCode: 'premium_30d', customerEmail: 'not-an-email' }),
});
assert.equal(response.status, 400);
assert.deepEqual(await response.json(), { ok: false, error: 'Valid customerEmail is required' });
});
test('admin store is closed by default', async () => {
const response = await fetch(`${base}/api/admin/debug/store`);
assert.equal(response.status, 404);
});
test('checkout lookup returns the exact stored key accepted by validation', async () => {
store.upsertCustomer({ id: 'cus_test_lookup', email: 'buyer@example.com', checkoutSessionId: 'cs_test_lookup_123' });
const license = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_test_lookup',
customerId: 'cus_test_lookup',
customerEmail: 'buyer@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
store.updateLicense(license.id, { emailDeliveryStatus: 'delivered', emailDeliveryVia: 'smtp' });
const lookup = await fetch(`${base}/api/checkout/session/cs_test_lookup_123`, {
headers: { Origin: 'https://dashcaddy.net' },
});
assert.equal(lookup.status, 200);
const result = await lookup.json();
assert.equal(result.status, 'delivered');
assert.equal(result.code, license.key);
assert.match(result.code, /^DC-(?:[0-9A-Z]{5}-){4}[0-9A-Z]{5}$/);
const offline = verifyCompatibleLicenseCode(result.code);
assert.equal(offline.valid, true);
assert.equal(offline.durationDays, 30);
assert.equal(result.productId, 'pro-30d');
const validate = await fetch(`${base}/api/license/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: result.code, machineId: 'product-machine-1' }),
});
assert.equal(validate.status, 200);
const validation = await validate.json();
assert.equal(validation.success, true);
assert.equal(validation.expiresAt, license.expiresAt);
assert.ok(Array.isArray(validation.features));
assert.ok(validation.features.includes('sso'));
const wrongMachine = await fetch(`${base}/api/license/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: result.code, machineId: 'product-machine-2' }),
});
assert.equal(wrongMachine.status, 400);
assert.match((await wrongMachine.json()).error, /another machine/);
});
test('license activation rejects missing machine identity', async () => {
const license = store.createOrUpdateLicenseBySubscription('sub_missing_machine', {
subscriptionId: 'sub_missing_machine',
customerId: 'cus_missing_machine',
customerEmail: 'machine@example.com',
planCode: 'premium_30d',
status: 'active',
expiresAt: new Date(Date.now() + 86400000).toISOString(),
active: true,
});
const response = await fetch(`${base}/api/license/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: license.key }),
});
assert.equal(response.status, 400);
assert.match((await response.json()).error, /Machine identity is required/);
});
test('duplicate Stripe payment intent never extends a one-time license twice', () => {
const first = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_idempotency_1',
customerId: 'cus_idempotency',
customerEmail: 'idempotency@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
const firstExpiry = first.expiresAt;
const duplicate = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_idempotency_1',
customerId: 'cus_idempotency',
customerEmail: 'idempotency@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
assert.equal(duplicate.idempotent, true);
assert.equal(duplicate.expiresAt, firstExpiry);
const secondPayment = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_idempotency_2',
customerId: 'cus_idempotency',
customerEmail: 'idempotency@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
assert.equal(secondPayment.extended, true);
assert.ok(new Date(secondPayment.expiresAt).getTime() > new Date(firstExpiry).getTime());
});
test('concurrent duplicate payment delivery is atomic across processes', async () => {
const script = `
import { grantOneTimeLicense } from './src/licenseLogic.js';
grantOneTimeLicense({
paymentIntentId: 'pi_concurrent_same',
customerId: 'cus_concurrent_same',
customerEmail: 'concurrent@example.com',
planCode: 'premium_30d',
durationDays: 30
});
`;
const run = () => new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
cwd: path.resolve(import.meta.dirname, '..'),
env: { ...process.env, DATA_DIR: dataDir },
stdio: ['ignore', 'ignore', 'pipe'],
});
let stderr = '';
child.stderr.on('data', chunk => { stderr += chunk; });
child.on('exit', code => code === 0 ? resolve() : reject(new Error(stderr || `child exited ${code}`)));
});
await Promise.all([run(), run()]);
const license = store.findLicenseByCustomerId('cus_concurrent_same');
assert.deepEqual(license.processedPaymentIntentIds, ['pi_concurrent_same']);
const days = (new Date(license.expiresAt).getTime() - Date.now()) / 86400000;
assert.ok(days > 29 && days < 31);
});
test('checkout creation is rate limited per client IP', async () => {
let last;
for (let i = 0; i < 21; i++) {
last = await fetch(`${base}/api/checkout/one-time`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Forwarded-For': '203.0.113.55',
},
body: JSON.stringify({ planCode: 'invalid-rate-test', customerEmail: 'buyer@example.com' }),
});
}
assert.equal(last.status, 429);
assert.ok(Number(last.headers.get('retry-after')) > 0);
});
test('webhook event claim is durable and single-use', () => {
assert.equal(store.claimWebhookEvent('evt_test_durable_1').claimed, true);
assert.equal(store.claimWebhookEvent('evt_test_durable_1').status, 'processing');
store.finishWebhookEvent('evt_test_durable_1');
assert.equal(store.claimWebhookEvent('evt_test_durable_1').status, 'completed');
});
test('pinned Stripe API fixtures resolve subscription and paid-through period', () => {
const invoice = {
id: 'in_fixture_1',
parent: { subscription_details: { subscription: { id: 'sub_fixture_1' } } },
};
const subscription = {
id: 'sub_fixture_1',
items: { data: [{ current_period_end: 1789990000 }, { current_period_end: 1790000000 }] },
};
assert.equal(getInvoiceSubscriptionId(invoice), 'sub_fixture_1');
assert.equal(getSubscriptionPeriodEnd(subscription), new Date(1790000000 * 1000).toISOString());
});
test('invoice-level claim prevents distinct events from renewing one invoice twice', () => {
assert.equal(store.claimBusinessObject('invoice.paid', 'in_same_invoice').claimed, true);
assert.equal(store.claimBusinessObject('invoice.paid', 'in_same_invoice').status, 'processing');
store.finishBusinessObject('invoice.paid', 'in_same_invoice');
assert.equal(store.claimBusinessObject('invoice.paid', 'in_same_invoice').status, 'completed');
});
test('subscription cancellation keeps access only until existing expiry', () => {
const license = store.createOrUpdateLicenseBySubscription('sub_cancel_test', {
subscriptionId: 'sub_cancel_test',
customerId: 'cus_cancel_test',
customerEmail: 'cancel@example.com',
planCode: 'premium_30d',
status: 'active',
expiresAt: new Date(Date.now() + 86400000).toISOString(),
active: true,
});
const canceled = store.cancelLicenseBySubscription('sub_cancel_test');
assert.equal(canceled.id, license.id);
assert.equal(canceled.status, 'canceled');
assert.equal(canceled.active, true);
});
test('subscription cancellation fails closed when paid-through expiry is absent', () => {
store.createOrUpdateLicenseBySubscription('sub_cancel_no_expiry', {
subscriptionId: 'sub_cancel_no_expiry',
customerId: 'cus_cancel_no_expiry',
planCode: 'premium_30d',
status: 'active',
expiresAt: null,
active: true,
});
assert.equal(store.cancelLicenseBySubscription('sub_cancel_no_expiry').active, false);
});
test('checkout sessions remain independently retrievable for repeat customers', () => {
store.upsertCustomer({ id: 'cus_repeat', email: 'repeat@example.com', checkoutSessionId: 'cs_repeat_first' });
store.upsertCustomer({ id: 'cus_repeat', email: 'repeat@example.com', checkoutSessionId: 'cs_repeat_second' });
store.createOrUpdateLicenseBySubscription('sub_repeat', {
subscriptionId: 'sub_repeat',
customerId: 'cus_repeat',
customerEmail: 'repeat@example.com',
planCode: 'premium_90d',
status: 'active',
expiresAt: new Date(Date.now() + 86400000).toISOString(),
active: true,
});
assert.ok(store.findCheckoutResult('cs_repeat_first')?.license);
assert.ok(store.findCheckoutResult('cs_repeat_second')?.license);
});
test('expired payment grace blocks license validation', () => {
const license = store.createOrUpdateLicenseBySubscription('sub_grace_expired', {
subscriptionId: 'sub_grace_expired',
customerId: 'cus_grace_expired',
customerEmail: 'grace@example.com',
planCode: 'premium_30d',
status: 'active',
expiresAt: new Date(Date.now() + 86400000).toISOString(),
active: true,
});
store.markLicensePaymentFailed('sub_grace_expired', new Date(Date.now() - 1000).toISOString());
const result = licenseLogic.validateLicense({ code: license.key, machine: { hostname: 'grace-host' } });
assert.equal(result.success, false);
assert.match(result.message, /grace period has expired/);
});
test('past-due subscription updates preserve an existing grace deadline', () => {
const graceUntil = new Date(Date.now() + 3 * 86400000).toISOString();
store.createOrUpdateLicenseBySubscription('sub_grace_order', {
subscriptionId: 'sub_grace_order',
customerId: 'cus_grace_order',
customerEmail: 'grace-order@example.com',
planCode: 'premium_30d',
status: 'active',
expiresAt: new Date(Date.now() + 86400000).toISOString(),
active: true,
});
store.markLicensePaymentFailed('sub_grace_order', graceUntil);
const updated = licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_grace_order',
customerId: 'cus_grace_order',
customerEmail: 'grace-order@example.com',
planCode: 'premium_30d',
status: 'past_due',
currentPeriodEnd: new Date(Date.now() + 86400000).toISOString(),
});
assert.equal(updated.graceUntil, graceUntil);
});
test('newer failure event cannot shorten an existing grace deadline', () => {
const laterGrace = new Date(Date.now() + 7 * 86400000).toISOString();
const earlierGrace = new Date(Date.now() + 2 * 86400000).toISOString();
store.createOrUpdateLicenseBySubscription('sub_grace_monotonic', {
subscriptionId: 'sub_grace_monotonic',
customerId: 'cus_grace_monotonic',
customerEmail: 'grace-monotonic@example.com',
planCode: 'premium_30d',
status: 'active',
expiresAt: new Date(Date.now() + 86400000).toISOString(),
active: true,
});
store.markLicensePaymentFailed('sub_grace_monotonic', laterGrace, 100, 'evt_failure_1');
const second = store.markLicensePaymentFailed('sub_grace_monotonic', earlierGrace, 101, 'evt_failure_2');
assert.equal(second.graceUntil, laterGrace);
});
test('stale failure cannot regress a newer paid entitlement', () => {
const paidExpiry = new Date(Date.now() + 30 * 86400000).toISOString();
const paid = licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_monotonic_paid',
customerId: 'cus_monotonic_paid',
customerEmail: 'monotonic@example.com',
planCode: 'premium_30d',
status: 'active',
currentPeriodEnd: paidExpiry,
eventCreated: 200,
});
const staleFailure = store.markLicensePaymentFailed(
'sub_monotonic_paid',
new Date(Date.now() + 7 * 86400000).toISOString(),
100,
);
assert.equal(staleFailure.staleEventIgnored, true);
assert.equal(store.findLicenseByCustomerId('cus_monotonic_paid').status, 'active');
assert.equal(store.findLicenseByCustomerId('cus_monotonic_paid').expiresAt, paid.expiresAt);
});
test('older subscription update cannot replace newer expiry or status', () => {
const newerExpiry = new Date(Date.now() + 90 * 86400000).toISOString();
licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_monotonic_update',
customerId: 'cus_monotonic_update',
customerEmail: 'update-order@example.com',
planCode: 'premium_90d',
status: 'active',
currentPeriodEnd: newerExpiry,
eventCreated: 500,
});
const stale = licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_monotonic_update',
customerId: 'cus_monotonic_update',
customerEmail: 'update-order@example.com',
planCode: 'premium_30d',
status: 'past_due',
currentPeriodEnd: new Date(Date.now() + 10 * 86400000).toISOString(),
eventCreated: 400,
});
assert.equal(stale.staleEventIgnored, true);
const current = store.findLicenseByCustomerId('cus_monotonic_update');
assert.equal(current.status, 'active');
assert.equal(current.expiresAt, newerExpiry);
});
test('idempotent entitlement can resume incomplete email fulfillment', () => {
const first = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_resume_email',
customerId: 'cus_resume_email',
customerEmail: 'resume@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
store.updateLicense(first.id, { emailDeliveryStatus: 'pending' });
const retry = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_resume_email',
customerId: 'cus_resume_email',
customerEmail: 'resume@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
assert.equal(retry.idempotent, true);
assert.equal(retry.emailDeliveryStatus, 'pending');
assert.equal(retry.expiresAt, first.expiresAt);
});
test('equal-timestamp event tie-breaker cannot regress paid state', () => {
const expiry = new Date(Date.now() + 30 * 86400000).toISOString();
licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_equal_timestamp',
customerId: 'cus_equal_timestamp',
customerEmail: 'equal@example.com',
planCode: 'premium_30d',
status: 'active',
currentPeriodEnd: expiry,
eventCreated: 900,
eventId: 'evt_z_newer_tie',
});
const staleFailure = store.markLicensePaymentFailed(
'sub_equal_timestamp',
new Date(Date.now() + 7 * 86400000).toISOString(),
900,
'evt_a_older_tie',
);
assert.equal(staleFailure.staleEventIgnored, true);
assert.equal(store.findLicenseByCustomerId('cus_equal_timestamp').status, 'active');
});
test('future payment grace keeps access after the paid period expires', () => {
const license = store.createOrUpdateLicenseBySubscription('sub_grace_active', {
subscriptionId: 'sub_grace_active',
customerId: 'cus_grace_active',
customerEmail: 'grace-active@example.com',
planCode: 'premium_30d',
status: 'past_due',
expiresAt: new Date(Date.now() - 1000).toISOString(),
graceUntil: new Date(Date.now() + 86400000).toISOString(),
active: true,
});
const result = licenseLogic.validateLicense({ code: license.key, machine: { hostname: 'grace-active-host' } });
assert.equal(result.success, true);
});
test('subscription retry preserves recorded email delivery and stable key', () => {
const first = store.createOrUpdateLicenseBySubscription('sub_email_delivered', {
subscriptionId: 'sub_email_delivered',
customerId: 'cus_email_delivered',
customerEmail: 'delivered@example.com',
planCode: 'premium_30d',
status: 'active',
expiresAt: new Date(Date.now() + 86400000).toISOString(),
active: true,
emailDeliveryStatus: 'delivered',
emailDeliveredAt: new Date().toISOString(),
lastStripeEventCreated: 100,
lastStripeEventId: 'evt_email_first',
});
const retry = licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_email_delivered',
customerId: 'cus_email_delivered',
customerEmail: 'delivered@example.com',
planCode: 'premium_30d',
status: 'active',
currentPeriodEnd: new Date(Date.now() + 2 * 86400000).toISOString(),
eventCreated: 101,
eventId: 'evt_email_retry',
});
assert.equal(retry.key, first.key);
assert.equal(retry.emailDeliveryStatus, 'delivered');
});
test('subscription failure and cancellation cannot revoke one-time paid-through time', () => {
const oneTime = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_mixed_mode',
customerId: 'cus_mixed_mode',
customerEmail: 'mixed@example.com',
planCode: 'premium_90d',
durationDays: 90,
});
const subscription = licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_mixed_mode',
customerId: 'cus_mixed_mode',
customerEmail: 'mixed@example.com',
planCode: 'premium_30d',
status: 'active',
currentPeriodEnd: new Date(Date.now() + 30 * 86400000).toISOString(),
eventCreated: 1000,
eventId: 'evt_mixed_active',
});
assert.equal(subscription.key, oneTime.key);
assert.equal(subscription.oneTimeExpiresAt, oneTime.oneTimeExpiresAt);
const failed = store.markLicensePaymentFailed(
'sub_mixed_mode',
new Date(Date.now() - 1000).toISOString(),
1001,
'evt_mixed_failed',
);
assert.equal(failed.subscriptionStatus, 'past_due');
assert.equal(failed.status, 'active');
assert.equal(licenseLogic.validateLicense({ code: failed.key, machine: { hostname: 'mixed-host' } }).success, true);
const canceled = store.cancelLicenseBySubscription('sub_mixed_mode', 1002, 'evt_mixed_canceled');
assert.equal(canceled.subscriptionStatus, 'canceled');
assert.equal(canceled.active, true);
assert.equal(canceled.key, oneTime.key);
});
test('renewal email delivery is tracked per Stripe invoice', async () => {
const license = licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_invoice_email',
customerId: 'cus_invoice_email',
customerEmail: 'invoice-email@example.com',
planCode: 'premium_30d',
status: 'active',
currentPeriodEnd: new Date(Date.now() + 30 * 86400000).toISOString(),
eventCreated: 2000,
eventId: 'evt_invoice_email',
});
let sent = 0;
const sender = async () => { sent += 1; return { delivered: true, via: 'test' }; };
const args = {
customerEmail: 'invoice-email@example.com',
code: license.key,
durationDays: 30,
planCode: 'premium_30d',
extended: true,
};
await deliverAndTrackLicenseEmail({ ...args, license, deliveryId: 'in_invoice_1' }, sender);
let fresh = store.findLicenseByCustomerId('cus_invoice_email');
await deliverAndTrackLicenseEmail({ ...args, license: fresh, deliveryId: 'in_invoice_1' }, sender);
assert.equal(sent, 1);
fresh = store.findLicenseByCustomerId('cus_invoice_email');
await deliverAndTrackLicenseEmail({ ...args, license: fresh, deliveryId: 'in_invoice_2' }, sender);
assert.equal(sent, 2);
assert.equal(store.findLicenseByCustomerId('cus_invoice_email').lastEmailDeliveryId, 'in_invoice_2');
});
test('matching email never transfers a license across Stripe customer IDs', () => {
const first = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_owner_a',
customerId: 'cus_owner_a',
customerEmail: 'shared@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
const second = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_owner_b',
customerId: 'cus_owner_b',
customerEmail: 'shared@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
assert.notEqual(second.key, first.key);
assert.equal(store.findLicenseByCustomerId('cus_owner_a').key, first.key);
assert.equal(store.findLicenseByCustomerId('cus_owner_b').key, second.key);
store.upsertCustomer({ id: 'cus_owner_a', email: 'shared@example.com', checkoutSessionId: 'cs_owner_a' });
store.upsertCustomer({ id: 'cus_owner_b', email: 'shared@example.com', checkoutSessionId: 'cs_owner_b' });
assert.equal(store.findCheckoutResult('cs_owner_a').license.key, first.key);
assert.equal(store.findCheckoutResult('cs_owner_b').license.key, second.key);
const secondSubscription = licenseLogic.syncLicenseFromSubscription({
subscriptionId: 'sub_owner_b',
customerId: 'cus_owner_b',
customerEmail: 'shared@example.com',
planCode: 'premium_30d',
status: 'active',
currentPeriodEnd: new Date(Date.now() + 30 * 86400000).toISOString(),
eventCreated: 3000,
eventId: 'evt_owner_b',
});
assert.equal(secondSubscription.key, second.key);
assert.notEqual(secondSubscription.key, first.key);
});
test('concurrent first activation allows exactly one machine', async () => {
const license = licenseLogic.grantOneTimeLicense({
paymentIntentId: 'pi_machine_race',
customerId: 'cus_machine_race',
customerEmail: 'machine-race@example.com',
planCode: 'premium_30d',
durationDays: 30,
});
const script = `
import { validateLicense } from './src/licenseLogic.js';
const result = validateLicense({ code: process.env.TEST_LICENSE_CODE, machine: { hostname: process.env.TEST_MACHINE } });
process.stdout.write(JSON.stringify(result));
`;
const run = (machine) => new Promise((resolve, reject) => {
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
cwd: path.resolve('.'),
env: { ...process.env, DATA_DIR: dataDir, TEST_LICENSE_CODE: license.key, TEST_MACHINE: machine },
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout.on('data', chunk => { stdout += chunk; });
child.stderr.on('data', chunk => { stderr += chunk; });
child.on('exit', (code) => code === 0 ? resolve(JSON.parse(stdout)) : reject(new Error(stderr)));
});
const results = await Promise.all([run('machine-race-a'), run('machine-race-b')]);
assert.equal(results.filter((item) => item.success).length, 1);
assert.equal(results.filter((item) => !item.success).length, 1);
});