[grade=B] Make website billing production-ready
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 |
Generated
+10
@@ -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
@@ -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
@@ -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 || ''
|
||||
};
|
||||
|
||||
+146
-48
@@ -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
|
||||
? '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;">
|
||||
${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>
|
||||
<!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: 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>
|
||||
<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 {
|
||||
@@ -104,4 +202,4 @@ export async function sendLicenseEmail({ to, code, durationDays, planCode, expir
|
||||
console.error('Email delivery failed:', err.message);
|
||||
return { delivered: false, error: err.message };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
+385
-63
@@ -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, () => {
|
||||
console.log(`dashcaddy-license-server listening on :${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}`);
|
||||
});
|
||||
}
|
||||
|
||||
+410
-79
@@ -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);
|
||||
const id = existing?.id || crypto.randomUUID();
|
||||
const next = {
|
||||
id,
|
||||
key: existing?.key || createLicenseKey(),
|
||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||
...existing,
|
||||
...patch,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
db.licenses[id] = next;
|
||||
writeStore(db);
|
||||
return next;
|
||||
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 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 || 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()
|
||||
};
|
||||
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];
|
||||
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] = {
|
||||
...lic,
|
||||
expiresAt: start.toISOString(),
|
||||
active: true,
|
||||
activatedAt: lic.activatedAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
writeStore(db);
|
||||
return db.licenses[licenseId];
|
||||
return transaction(() => {
|
||||
const lic = loadOne('licenses', licenseId);
|
||||
if (!lic) return null;
|
||||
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: base.toISOString(),
|
||||
active: true,
|
||||
activatedAt: lic.activatedAt || new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user