Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bfb8ea2b5 | ||
|
|
06d8062a5a | ||
|
|
b7dab4c0b2 | ||
|
|
ff722aa4d8 |
@@ -4,4 +4,14 @@ DASHCADDY_WEBSITE_URL=https://dashcaddy.net
|
|||||||
STRIPE_SECRET_KEY=sk_live_your_secret_key_here
|
STRIPE_SECRET_KEY=sk_live_your_secret_key_here
|
||||||
STRIPE_PUBLISHABLE_KEY=pk_live_your_publishable_key_here
|
STRIPE_PUBLISHABLE_KEY=pk_live_your_publishable_key_here
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
||||||
|
DASHCADDY_LICENSE_SECRET=64_hex_characters_shared_with_dashcaddy
|
||||||
DATA_DIR=./data
|
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
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
@@ -34,9 +34,9 @@ Paid subscriptions unlock:
|
|||||||
- `swarm`
|
- `swarm`
|
||||||
|
|
||||||
### Stripe plans
|
### Stripe plans
|
||||||
- 1 month — $25
|
- 1 month — $20
|
||||||
- 3 months — $50
|
- 3 months — $50
|
||||||
- 6 months — $65
|
- 6 months — $70
|
||||||
- 12 months — $99
|
- 12 months — $99
|
||||||
|
|
||||||
### Subscription policy
|
### Subscription policy
|
||||||
@@ -78,7 +78,8 @@ Source of truth for:
|
|||||||
## Proposed API surface
|
## Proposed API surface
|
||||||
|
|
||||||
### Public endpoints for website
|
### Public endpoints for website
|
||||||
- `POST /api/checkout/session`
|
- `POST /api/checkout/one-time`
|
||||||
|
- `POST /api/checkout/subscription`
|
||||||
- `GET /api/public/plans`
|
- `GET /api/public/plans`
|
||||||
|
|
||||||
### DashCaddy app endpoints
|
### DashCaddy app endpoints
|
||||||
@@ -200,7 +201,7 @@ On cancellation:
|
|||||||
|
|
||||||
The `dashcaddy.net` marketing site should be updated to:
|
The `dashcaddy.net` marketing site should be updated to:
|
||||||
- add Premium pricing section buttons
|
- 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
|
- redirect to returned Stripe Checkout URL
|
||||||
- success page explains license delivery/activation flow
|
- success page explains license delivery/activation flow
|
||||||
- cancellation page returns user to pricing
|
- cancellation page returns user to pricing
|
||||||
@@ -223,7 +224,7 @@ Initial env expected:
|
|||||||
1. Scaffold Node service for `dashcaddy-license-server`
|
1. Scaffold Node service for `dashcaddy-license-server`
|
||||||
2. Add Stripe SDK, Express, and SQLite/Postgres adapter layer
|
2. Add Stripe SDK, Express, and SQLite/Postgres adapter layer
|
||||||
3. Build `/api/public/plans`
|
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`
|
5. Build `/api/stripe/webhook`
|
||||||
6. Port/reuse DashCaddy-compatible license generation/validation helpers
|
6. Port/reuse DashCaddy-compatible license generation/validation helpers
|
||||||
7. Build `/api/license/validate`
|
7. Build `/api/license/validate`
|
||||||
|
|||||||
+2
-2
@@ -3,9 +3,9 @@
|
|||||||
## Locked Decisions
|
## Locked Decisions
|
||||||
|
|
||||||
### Subscription plans
|
### Subscription plans
|
||||||
- 1 month — $25
|
- 1 month — $20
|
||||||
- 3 months — $50
|
- 3 months — $50
|
||||||
- 6 months — $65
|
- 6 months — $70
|
||||||
- 12 months — $99
|
- 12 months — $99
|
||||||
|
|
||||||
### Tier model
|
### Tier model
|
||||||
|
|||||||
@@ -1,54 +1,32 @@
|
|||||||
# DashCaddy License Server
|
# 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.
|
## What it does
|
||||||
It receives Stripe webhooks, maps purchases/subscriptions to license entitlements,
|
|
||||||
and exposes license validation/deactivation endpoints for DashCaddy instances.
|
|
||||||
|
|
||||||
## 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
|
## Plans
|
||||||
- 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
|
|
||||||
|
|
||||||
## 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
|
Stripe remains the billing source of truth. SQLite is the entitlement and fulfillment source of truth.
|
||||||
- **License server database** is entitlement truth
|
|
||||||
- **DashCaddy app** remains the consumer of license validation
|
|
||||||
- Existing DashCaddy license logic should be reused, not reinvented
|
|
||||||
|
|
||||||
## Next steps
|
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.
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
|
|
||||||
@@ -59,16 +37,62 @@ DASHCADDY_WEBSITE_URL=https://dashcaddy.net
|
|||||||
STRIPE_SECRET_KEY=sk_live_...
|
STRIPE_SECRET_KEY=sk_live_...
|
||||||
STRIPE_PUBLISHABLE_KEY=pk_live_...
|
STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||||
|
DASHCADDY_LICENSE_SECRET=64_hex_characters_shared_with_dashcaddy
|
||||||
DATA_DIR=./data
|
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 /health`
|
||||||
- `GET /api/public/config`
|
- `GET /api/public/config`
|
||||||
- `GET /api/public/plans`
|
- `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/stripe/webhook`
|
||||||
- `POST /api/license/validate`
|
- `POST /api/license/validate`
|
||||||
- `POST /api/license/deactivate`
|
- `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
+38
-27
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"nodemailer": "^9.0.5",
|
||||||
"stripe": "^22.0.1"
|
"stripe": "^22.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -32,9 +33,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.4",
|
"version": "1.20.6",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz",
|
||||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
"integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bytes": "~3.1.2",
|
"bytes": "~3.1.2",
|
||||||
@@ -45,7 +46,7 @@
|
|||||||
"http-errors": "~2.0.1",
|
"http-errors": "~2.0.1",
|
||||||
"iconv-lite": "~0.4.24",
|
"iconv-lite": "~0.4.24",
|
||||||
"on-finished": "~2.4.1",
|
"on-finished": "~2.4.1",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"raw-body": "~2.5.3",
|
"raw-body": "~2.5.3",
|
||||||
"type-is": "~1.6.18",
|
"type-is": "~1.6.18",
|
||||||
"unpipe": "~1.0.0"
|
"unpipe": "~1.0.0"
|
||||||
@@ -205,9 +206,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/es-object-atoms": {
|
"node_modules/es-object-atoms": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -232,14 +233,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express": {
|
"node_modules/express": {
|
||||||
"version": "4.22.1",
|
"version": "4.22.2",
|
||||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "~1.3.8",
|
"accepts": "~1.3.8",
|
||||||
"array-flatten": "1.1.1",
|
"array-flatten": "1.1.1",
|
||||||
"body-parser": "~1.20.3",
|
"body-parser": "~1.20.5",
|
||||||
"content-disposition": "~0.5.4",
|
"content-disposition": "~0.5.4",
|
||||||
"content-type": "~1.0.4",
|
"content-type": "~1.0.4",
|
||||||
"cookie": "~0.7.1",
|
"cookie": "~0.7.1",
|
||||||
@@ -258,7 +259,7 @@
|
|||||||
"parseurl": "~1.3.3",
|
"parseurl": "~1.3.3",
|
||||||
"path-to-regexp": "~0.1.12",
|
"path-to-regexp": "~0.1.12",
|
||||||
"proxy-addr": "~2.0.7",
|
"proxy-addr": "~2.0.7",
|
||||||
"qs": "~6.14.0",
|
"qs": "~6.15.1",
|
||||||
"range-parser": "~1.2.1",
|
"range-parser": "~1.2.1",
|
||||||
"safe-buffer": "5.2.1",
|
"safe-buffer": "5.2.1",
|
||||||
"send": "~0.19.0",
|
"send": "~0.19.0",
|
||||||
@@ -384,9 +385,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/hasown": {
|
"node_modules/hasown": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||||
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
|
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -526,6 +527,15 @@
|
|||||||
"node": ">= 0.6"
|
"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": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
@@ -579,12 +589,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.14.2",
|
"version": "6.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
"integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==",
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"side-channel": "^1.1.0"
|
"es-define-property": "^1.0.1",
|
||||||
|
"side-channel": "^1.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
@@ -695,14 +706,14 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/side-channel": {
|
"node_modules/side-channel": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
|
||||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
"object-inspect": "^1.13.3",
|
"object-inspect": "^1.13.4",
|
||||||
"side-channel-list": "^1.0.0",
|
"side-channel-list": "^1.0.1",
|
||||||
"side-channel-map": "^1.0.1",
|
"side-channel-map": "^1.0.1",
|
||||||
"side-channel-weakmap": "^1.0.2"
|
"side-channel-weakmap": "^1.0.2"
|
||||||
},
|
},
|
||||||
@@ -776,9 +787,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/stripe": {
|
"node_modules/stripe": {
|
||||||
"version": "22.0.2",
|
"version": "22.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.5.0.tgz",
|
||||||
"integrity": "sha512-2/BLrQ3oB1zlNfeL/LfHFjTGx6EQn0j+ztrrTJHuDjV5VVIpk92oSDaxyKLUr3pG3dnee2LZqhFUv2Bf0G1/3g==",
|
"integrity": "sha512-QVwMwriC0bbySx6R4dpsvJ0W//GojC1kwWVS6rPSoVqDUIZX4Hy3TaUrd2AZeXEAaKbfWIjQjvo3vKAReHZ0vQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
|
|||||||
+3
-1
@@ -5,10 +5,12 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"start": "node src/server.js"
|
"start": "node src/server.js",
|
||||||
|
"test": "node --test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"nodemailer": "^9.0.5",
|
||||||
"stripe": "^22.0.1"
|
"stripe": "^22.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-1
@@ -4,5 +4,7 @@ export const config = {
|
|||||||
websiteUrl: process.env.DASHCADDY_WEBSITE_URL || 'https://dashcaddy.net',
|
websiteUrl: process.env.DASHCADDY_WEBSITE_URL || 'https://dashcaddy.net',
|
||||||
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||||
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_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 || ''
|
||||||
};
|
};
|
||||||
|
|||||||
+205
@@ -0,0 +1,205 @@
|
|||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
transporter = nodemailer.createTransport({
|
||||||
|
host: process.env.SMTP_HOST || '127.0.0.1',
|
||||||
|
port: parseInt(process.env.SMTP_PORT || '25', 10),
|
||||||
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
|
auth: process.env.SMTP_USERNAME ? {
|
||||||
|
user: process.env.SMTP_USERNAME,
|
||||||
|
pass: process.env.SMTP_PASSWORD
|
||||||
|
} : undefined,
|
||||||
|
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).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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the license key email to the customer. Called after every payment
|
||||||
|
* (initial purchase or renewal extension).
|
||||||
|
*/
|
||||||
|
export async function sendLicenseEmail({ to, code, durationDays, planCode, expiresAt, extended = false }) {
|
||||||
|
// 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`;
|
||||||
|
const text = [
|
||||||
|
extended ? 'Your DashCaddy Premium license has been extended.' : 'Thank you for purchasing DashCaddy Premium.',
|
||||||
|
'',
|
||||||
|
`License key: ${code}`,
|
||||||
|
`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',
|
||||||
|
'',
|
||||||
|
'— DashCaddy'
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!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 {
|
||||||
|
const t = getTransporter();
|
||||||
|
await t.sendMail({
|
||||||
|
from: process.env.SMTP_FROM || 'licenses@dashcaddy.net',
|
||||||
|
to,
|
||||||
|
subject,
|
||||||
|
text,
|
||||||
|
html
|
||||||
|
});
|
||||||
|
return { delivered: true, via: 'smtp' };
|
||||||
|
} catch (err) {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
-15
@@ -1,6 +1,14 @@
|
|||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
import { PREMIUM_FEATURES } from './plans.js';
|
import { PREMIUM_FEATURES, getPlan } from './plans.js';
|
||||||
import { createOrUpdateLicenseBySubscription, findLicenseByKey, updateLicense } from './store.js';
|
import {
|
||||||
|
createOrUpdateLicenseBySubscription,
|
||||||
|
findLicenseByKey,
|
||||||
|
findLicenseByCustomerId,
|
||||||
|
updateLicense,
|
||||||
|
grantOneTimeLicenseAtomic,
|
||||||
|
claimLicenseMachine,
|
||||||
|
isStripeTransitionStale
|
||||||
|
} from './store.js';
|
||||||
|
|
||||||
export function fingerprintMachine(payload = {}) {
|
export function fingerprintMachine(payload = {}) {
|
||||||
const parts = [
|
const parts = [
|
||||||
@@ -13,19 +21,57 @@ export function fingerprintMachine(payload = {}) {
|
|||||||
return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
|
return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function syncLicenseFromSubscription({ subscriptionId, customerId, customerEmail, planCode, status, currentPeriodEnd }) {
|
/**
|
||||||
|
* Sync a license from a Stripe subscription event. If the customer already
|
||||||
|
* has a license (by customerId or email), EXTEND the existing license by
|
||||||
|
* 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, eventCreated, eventId }) {
|
||||||
const premiumFeatures = Object.fromEntries(PREMIUM_FEATURES.map((f) => [f, true]));
|
const premiumFeatures = Object.fromEntries(PREMIUM_FEATURES.map((f) => [f, true]));
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
|
||||||
return createOrUpdateLicenseBySubscription(subscriptionId, {
|
return createOrUpdateLicenseBySubscription(subscriptionId, {
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
customerId,
|
customerId,
|
||||||
customerEmail,
|
customerEmail,
|
||||||
planCode,
|
planCode,
|
||||||
|
durationDays: durationDays || getPlan(planCode)?.durationDays,
|
||||||
status,
|
status,
|
||||||
expiresAt: currentPeriodEnd,
|
expiresAt: currentPeriodEnd,
|
||||||
active: ['active', 'trialing', 'past_due'].includes(status),
|
graceUntil: status === 'past_due' ? existing?.subscriptionGraceUntil || existing?.graceUntil || null : null,
|
||||||
premiumFeatures,
|
premiumFeatures,
|
||||||
machineFingerprint: null,
|
machineFingerprint: existing?.machineFingerprint || null,
|
||||||
deactivatedAt: null
|
deactivatedAt: null,
|
||||||
|
lastStripeEventCreated: incomingEvent ? Math.max(incomingEvent, previousEvent) : previousEvent || null,
|
||||||
|
lastStripeEventId: eventId || existing?.lastStripeEventId || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grant a one-time license purchase. If the customer already has an active
|
||||||
|
* license, EXTEND the existing license by durationDays instead of creating
|
||||||
|
* a new one. This is the "buy again adds time to the same key" model.
|
||||||
|
*/
|
||||||
|
export function grantOneTimeLicense({ paymentIntentId, customerId, customerEmail, planCode, durationDays }) {
|
||||||
|
const premiumFeatures = Object.fromEntries(PREMIUM_FEATURES.map((f) => [f, true]));
|
||||||
|
return grantOneTimeLicenseAtomic({
|
||||||
|
paymentIntentId,
|
||||||
|
customerId,
|
||||||
|
customerEmail,
|
||||||
|
planCode,
|
||||||
|
durationDays,
|
||||||
|
premiumFeatures
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,28 +81,35 @@ export function validateLicense({ code, machine }) {
|
|||||||
return { success: false, message: 'License not found' };
|
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) {
|
if (!license.active) {
|
||||||
return { success: false, message: 'License is not active' };
|
return { success: false, message: 'License is not active' };
|
||||||
}
|
}
|
||||||
|
if (!graceActive && license.expiresAt && new Date(license.expiresAt).getTime() < now) {
|
||||||
const now = Date.now();
|
|
||||||
if (license.expiresAt && new Date(license.expiresAt).getTime() < now) {
|
|
||||||
return { success: false, message: 'License has expired' };
|
return { success: false, message: 'License has expired' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const fingerprint = fingerprintMachine(machine || {});
|
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' };
|
return { success: false, message: 'License is already active on another machine' };
|
||||||
}
|
}
|
||||||
|
const updated = machineClaim.license;
|
||||||
const updated = license.machineFingerprint
|
|
||||||
? license
|
|
||||||
: updateLicense(license.id, { machineFingerprint: fingerprint, activatedAt: new Date().toISOString() });
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
license: {
|
license: {
|
||||||
|
id: updated.id,
|
||||||
code: updated.key,
|
code: updated.key,
|
||||||
tier: 'premium',
|
tier: 'premium',
|
||||||
expiresAt: updated.expiresAt,
|
expiresAt: updated.expiresAt,
|
||||||
@@ -64,7 +117,7 @@ export function validateLicense({ code, machine }) {
|
|||||||
subscriptionStatus: updated.status,
|
subscriptionStatus: updated.status,
|
||||||
customerEmail: updated.customerEmail
|
customerEmail: updated.customerEmail
|
||||||
},
|
},
|
||||||
message: updated === license ? 'License validated' : 'License activated on this machine'
|
message: machineClaim.existing ? 'License validated' : 'License activated on this machine'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+30
-18
@@ -1,35 +1,35 @@
|
|||||||
export const PLAN_DEFS = {
|
export const PLAN_DEFS = {
|
||||||
premium_1m: {
|
premium_30d: {
|
||||||
code: 'premium_1m',
|
code: 'premium_30d',
|
||||||
label: 'Premium, 1 Month',
|
label: 'Premium, 30 Days',
|
||||||
durationMonths: 1,
|
durationDays: 30,
|
||||||
amountUsd: 25,
|
amountUsd: 20,
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
intervalCount: 1,
|
intervalCount: 1,
|
||||||
tier: 'premium'
|
tier: 'premium'
|
||||||
},
|
},
|
||||||
premium_3m: {
|
premium_90d: {
|
||||||
code: 'premium_3m',
|
code: 'premium_90d',
|
||||||
label: 'Premium, 3 Months',
|
label: 'Premium, 90 Days',
|
||||||
durationMonths: 3,
|
durationDays: 90,
|
||||||
amountUsd: 50,
|
amountUsd: 50,
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
intervalCount: 3,
|
intervalCount: 3,
|
||||||
tier: 'premium'
|
tier: 'premium'
|
||||||
},
|
},
|
||||||
premium_6m: {
|
premium_180d: {
|
||||||
code: 'premium_6m',
|
code: 'premium_180d',
|
||||||
label: 'Premium, 6 Months',
|
label: 'Premium, 180 Days',
|
||||||
durationMonths: 6,
|
durationDays: 180,
|
||||||
amountUsd: 65,
|
amountUsd: 70,
|
||||||
interval: 'month',
|
interval: 'month',
|
||||||
intervalCount: 6,
|
intervalCount: 6,
|
||||||
tier: 'premium'
|
tier: 'premium'
|
||||||
},
|
},
|
||||||
premium_12m: {
|
premium_365d: {
|
||||||
code: 'premium_12m',
|
code: 'premium_365d',
|
||||||
label: 'Premium, 12 Months',
|
label: 'Premium, 365 Days',
|
||||||
durationMonths: 12,
|
durationDays: 365,
|
||||||
amountUsd: 99,
|
amountUsd: 99,
|
||||||
interval: 'year',
|
interval: 'year',
|
||||||
intervalCount: 1,
|
intervalCount: 1,
|
||||||
@@ -46,3 +46,15 @@ export function listPlans() {
|
|||||||
export function getPlan(planCode) {
|
export function getPlan(planCode) {
|
||||||
return PLAN_DEFS[planCode] || null;
|
return PLAN_DEFS[planCode] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add days to a license's expiry. If expiry is in the past, count from now.
|
||||||
|
* Returns the new ISO expiry timestamp.
|
||||||
|
*/
|
||||||
|
export function extendExpiry(currentIso, days) {
|
||||||
|
const base = currentIso ? new Date(currentIso) : new Date();
|
||||||
|
const now = Date.now();
|
||||||
|
const start = base.getTime() > now ? base : new Date(now);
|
||||||
|
start.setUTCDate(start.getUTCDate() + days);
|
||||||
|
return start.toISOString();
|
||||||
|
}
|
||||||
|
|||||||
+516
-35
@@ -1,35 +1,210 @@
|
|||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
import { pathToFileURL } from 'url';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
import { getPlan, listPlans, PREMIUM_FEATURES } from './plans.js';
|
import { getPlan, listPlans, PREMIUM_FEATURES } from './plans.js';
|
||||||
import { getStripe } from './stripe.js';
|
import { getStripe } from './stripe.js';
|
||||||
import { getStoreSnapshot, upsertCustomer, upsertSubscription } from './store.js';
|
import {
|
||||||
import { syncLicenseFromSubscription, validateLicense, deactivateLicense } from './licenseLogic.js';
|
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();
|
const app = express();
|
||||||
app.use('/api/stripe/webhook', express.raw({ type: 'application/json' }));
|
app.disable('x-powered-by');
|
||||||
app.use(express.json());
|
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) => {
|
app.get('/health', (_req, res) => {
|
||||||
res.json({ ok: true, service: 'dashcaddy-license-server' });
|
res.json({ ok: true, service: 'dashcaddy-license-server' });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/public/config', (_req, res) => {
|
app.get('/api/public/config', (_req, res) => {
|
||||||
res.json({ ok: true, publishableKeyPresent: Boolean(config.stripePublishableKey), websiteUrl: config.websiteUrl });
|
res.json({
|
||||||
|
ok: true,
|
||||||
|
publishableKey: config.stripePublishableKey || null,
|
||||||
|
websiteUrl: config.websiteUrl
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/api/public/plans', (_req, res) => {
|
app.get('/api/public/plans', (_req, res) => {
|
||||||
res.json({ ok: true, tier: 'premium', features: PREMIUM_FEATURES, plans: listPlans() });
|
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() });
|
res.json({ ok: true, store: getStoreSnapshot() });
|
||||||
});
|
});
|
||||||
|
|
||||||
app.post('/api/checkout/session', async (req, res) => {
|
/**
|
||||||
|
* Subscription checkout. Customer subscribes and is auto-renewed on the
|
||||||
|
* plan's interval. The license is created on the first webhook event and
|
||||||
|
* extended on every renewal.
|
||||||
|
*/
|
||||||
|
app.post('/api/checkout/subscription', checkoutRateLimit, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { planCode, customerEmail } = req.body || {};
|
const { planCode, customerEmail } = req.body || {};
|
||||||
const plan = getPlan(planCode);
|
const plan = getPlan(planCode);
|
||||||
if (!plan) return res.status(400).json({ ok: false, error: 'Invalid 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 stripe = getStripe();
|
||||||
const session = await stripe.checkout.sessions.create({
|
const session = await stripe.checkout.sessions.create({
|
||||||
@@ -44,28 +219,104 @@ app.post('/api/checkout/session', async (req, res) => {
|
|||||||
},
|
},
|
||||||
quantity: 1
|
quantity: 1
|
||||||
}],
|
}],
|
||||||
success_url: `${config.websiteUrl}/success?session_id={CHECKOUT_SESSION_ID}`,
|
success_url: `${config.websiteUrl}/success?session_id={CHECKOUT_SESSION_ID}&tier=premium`,
|
||||||
cancel_url: `${config.websiteUrl}/pricing`,
|
cancel_url: `${config.websiteUrl}/pricing`,
|
||||||
allow_promotion_codes: true,
|
allow_promotion_codes: true,
|
||||||
billing_address_collection: 'required',
|
billing_address_collection: 'required',
|
||||||
customer_email: customerEmail || undefined,
|
customer_email: normalizedEmail,
|
||||||
metadata: { source: 'dashcaddy.net', planCode: plan.code, tier: plan.tier },
|
metadata: {
|
||||||
subscription_data: { metadata: { source: 'dashcaddy.net', planCode: plan.code, tier: plan.tier } }
|
source: 'dashcaddy.net',
|
||||||
|
planCode: plan.code,
|
||||||
|
tier: plan.tier,
|
||||||
|
mode: 'subscription',
|
||||||
|
customerEmail: normalizedEmail
|
||||||
|
},
|
||||||
|
subscription_data: {
|
||||||
|
metadata: {
|
||||||
|
source: 'dashcaddy.net',
|
||||||
|
planCode: plan.code,
|
||||||
|
tier: plan.tier,
|
||||||
|
customerEmail: normalizedEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.json({ ok: true, url: session.url, sessionId: session.id });
|
return res.json({ ok: true, url: session.url, sessionId: session.id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Checkout session error:', 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' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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', 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: {
|
||||||
|
currency: 'usd',
|
||||||
|
product_data: { name: plan.label, description: 'DashCaddy Premium one-time license' },
|
||||||
|
unit_amount: plan.amountUsd * 100
|
||||||
|
},
|
||||||
|
quantity: 1
|
||||||
|
}],
|
||||||
|
success_url: `${config.websiteUrl}/success?session_id={CHECKOUT_SESSION_ID}&tier=premium`,
|
||||||
|
cancel_url: `${config.websiteUrl}/pricing`,
|
||||||
|
allow_promotion_codes: true,
|
||||||
|
billing_address_collection: 'required',
|
||||||
|
customer_email: normalizedEmail,
|
||||||
|
metadata: {
|
||||||
|
source: 'dashcaddy.net',
|
||||||
|
planCode: plan.code,
|
||||||
|
tier: plan.tier,
|
||||||
|
mode: 'one-time',
|
||||||
|
durationDays: String(plan.durationDays),
|
||||||
|
customerEmail: normalizedEmail
|
||||||
|
},
|
||||||
|
payment_intent_data: {
|
||||||
|
metadata: {
|
||||||
|
source: 'dashcaddy.net',
|
||||||
|
planCode: plan.code,
|
||||||
|
tier: plan.tier,
|
||||||
|
mode: 'one-time',
|
||||||
|
durationDays: String(plan.durationDays),
|
||||||
|
customerEmail: normalizedEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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: 'Checkout is temporarily unavailable' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) => {
|
app.post('/api/stripe/webhook', async (req, res) => {
|
||||||
|
let claimedEventId = null;
|
||||||
|
let claimedBusiness = null;
|
||||||
try {
|
try {
|
||||||
if (!config.stripeWebhookSecret) {
|
if (!config.stripeWebhookSecret) {
|
||||||
return res.status(500).json({ ok: false, error: 'Missing STRIPE_WEBHOOK_SECRET' });
|
return res.status(500).json({ ok: false, error: 'Missing STRIPE_WEBHOOK_SECRET' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const signature = req.headers['stripe-signature'];
|
const signature = req.headers['stripe-signature'];
|
||||||
if (!signature) {
|
if (!signature) {
|
||||||
return res.status(400).json({ ok: false, error: 'Missing stripe-signature header' });
|
return res.status(400).json({ ok: false, error: 'Missing stripe-signature header' });
|
||||||
@@ -73,26 +324,156 @@ app.post('/api/stripe/webhook', async (req, res) => {
|
|||||||
|
|
||||||
const stripe = getStripe();
|
const stripe = getStripe();
|
||||||
const event = stripe.webhooks.constructEvent(req.body, signature, config.stripeWebhookSecret);
|
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) {
|
switch (event.type) {
|
||||||
case 'checkout.session.completed': {
|
case 'checkout.session.completed': {
|
||||||
const session = event.data.object;
|
const session = event.data.object;
|
||||||
if (session.customer) {
|
const planCode = session.metadata?.planCode;
|
||||||
upsertCustomer({ id: String(session.customer), email: session.customer_email || null, checkoutSessionId: session.id });
|
const mode = session.metadata?.mode || 'subscription';
|
||||||
|
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
|
||||||
|
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/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,
|
||||||
|
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,
|
||||||
|
extended: license.extended || false,
|
||||||
|
addedDays: license.addedDays || plan.durationDays
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'customer.subscription.created':
|
case 'customer.subscription.created':
|
||||||
case 'customer.subscription.updated':
|
case 'customer.subscription.updated': {
|
||||||
case 'customer.subscription.deleted': {
|
|
||||||
const subscription = event.data.object;
|
const subscription = event.data.object;
|
||||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id;
|
||||||
const customerEmail = subscription.customer_email || null;
|
if (!subscription.id || !customerId) throw new Error('Subscription event is missing subscription or customer');
|
||||||
const planCode = subscription.metadata?.planCode || 'premium_1m';
|
let customerEmail = subscription.metadata?.customerEmail || null;
|
||||||
const currentPeriodEnd = subscription.current_period_end
|
if (!customerEmail && customerId) {
|
||||||
? new Date(subscription.current_period_end * 1000).toISOString()
|
try {
|
||||||
: null;
|
const customer = await stripe.customers.retrieve(customerId);
|
||||||
|
customerEmail = customer.email || null;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to fetch customer email', { customerId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const planCode = subscription.metadata?.planCode || 'premium_30d';
|
||||||
|
const currentPeriodEnd = getSubscriptionPeriodEnd(subscription);
|
||||||
|
|
||||||
upsertSubscription({
|
upsertSubscription({
|
||||||
id: subscription.id,
|
id: subscription.id,
|
||||||
@@ -100,7 +481,9 @@ app.post('/api/stripe/webhook', async (req, res) => {
|
|||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
planCode,
|
planCode,
|
||||||
currentPeriodEnd,
|
currentPeriodEnd,
|
||||||
cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end)
|
cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end),
|
||||||
|
lastStripeEventCreated: event.created,
|
||||||
|
lastStripeEventId: event.id
|
||||||
});
|
});
|
||||||
|
|
||||||
const license = syncLicenseFromSubscription({
|
const license = syncLicenseFromSubscription({
|
||||||
@@ -109,16 +492,65 @@ app.post('/api/stripe/webhook', async (req, res) => {
|
|||||||
customerEmail,
|
customerEmail,
|
||||||
planCode,
|
planCode,
|
||||||
status: subscription.status,
|
status: subscription.status,
|
||||||
currentPeriodEnd
|
currentPeriodEnd,
|
||||||
|
eventCreated: event.created,
|
||||||
|
eventId: event.id
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log('License synced from subscription', { subscriptionId: subscription.id, licenseKey: license.key, status: license.status });
|
console.log('Subscription license synced', {
|
||||||
|
subscriptionId: subscription.id,
|
||||||
|
status: license.status,
|
||||||
|
expiresAt: license.expiresAt
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'customer.subscription.deleted': {
|
||||||
|
const subscription = event.data.object;
|
||||||
|
upsertSubscription({
|
||||||
|
id: subscription.id,
|
||||||
|
status: 'canceled',
|
||||||
|
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
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
case 'invoice.payment_failed': {
|
case 'invoice.payment_failed': {
|
||||||
const invoice = event.data.object;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,27 +558,76 @@ app.post('/api/stripe/webhook', async (req, res) => {
|
|||||||
console.log('Unhandled Stripe event', event.type);
|
console.log('Unhandled Stripe event', event.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (claimedBusiness) finishBusinessObject(claimedBusiness.kind, claimedBusiness.id);
|
||||||
|
finishWebhookEvent(claimedEventId);
|
||||||
return res.json({ ok: true });
|
return res.json({ ok: true });
|
||||||
} catch (error) {
|
} 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);
|
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) => {
|
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' });
|
if (!code) return res.status(400).json({ ok: false, error: 'License code is required' });
|
||||||
const result = validateLicense({ code, machine });
|
const hasMachineObject = machine && typeof machine === 'object'
|
||||||
return res.status(result.success ? 200 : 400).json(result);
|
&& ['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) => {
|
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' });
|
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);
|
return res.status(result.success ? 200 : 400).json(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(config.port, () => {
|
function validateStartupConfig() {
|
||||||
|
const missing = [];
|
||||||
|
if (!config.stripeSecretKey) missing.push('STRIPE_SECRET_KEY');
|
||||||
|
if (!config.stripeWebhookSecret) missing.push('STRIPE_WEBHOOK_SECRET');
|
||||||
|
if (!/^[A-Fa-f0-9]{32,}$/.test(config.licenseSecret)) missing.push('DASHCADDY_LICENSE_SECRET (hex, at least 128 bits)');
|
||||||
|
if (missing.length) throw new Error(`Missing or invalid production configuration: ${missing.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { app, getInvoiceSubscriptionId, getSubscriptionPeriodEnd, validateStartupConfig, deliverAndTrackLicenseEmail };
|
||||||
|
|
||||||
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
validateStartupConfig();
|
||||||
|
app.listen(config.port, () => {
|
||||||
console.log(`dashcaddy-license-server listening on :${config.port}`);
|
console.log(`dashcaddy-license-server listening on :${config.port}`);
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|||||||
+415
-37
@@ -1,76 +1,454 @@
|
|||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import crypto from 'crypto';
|
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_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 });
|
||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
const db = new DatabaseSync(DB_FILE);
|
||||||
if (!fs.existsSync(DATA_FILE)) {
|
db.exec(`
|
||||||
fs.writeFileSync(DATA_FILE, JSON.stringify({ customers: {}, subscriptions: {}, licenses: {} }, null, 2));
|
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() {
|
function decode(row) {
|
||||||
ensureStore();
|
return row ? JSON.parse(row.data) : null;
|
||||||
return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function writeStore(db) {
|
function loadOne(table, id) {
|
||||||
ensureStore();
|
if (!TABLES.has(table)) throw new Error('Invalid table');
|
||||||
fs.writeFileSync(DATA_FILE, JSON.stringify(db, null, 2));
|
return decode(db.prepare(`SELECT data FROM ${table} WHERE id = ?`).get(id));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createLicenseKey() {
|
function loadAll(table) {
|
||||||
const raw = crypto.randomBytes(16).toString('hex').toUpperCase();
|
if (!TABLES.has(table)) throw new Error('Invalid table');
|
||||||
return `DC-${raw.slice(0,5)}-${raw.slice(5,10)}-${raw.slice(10,15)}-${raw.slice(15,20)}-${raw.slice(20,25)}`;
|
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) {
|
export function upsertCustomer(customer) {
|
||||||
const db = readStore();
|
return transaction(() => {
|
||||||
db.customers[customer.id] = { ...(db.customers[customer.id] || {}), ...customer, updatedAt: new Date().toISOString() };
|
const next = { ...(loadOne('customers', customer.id) || {}), ...customer, updatedAt: new Date().toISOString() };
|
||||||
writeStore(db);
|
saveOne('customers', customer.id, next);
|
||||||
return db.customers[customer.id];
|
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) {
|
export function upsertSubscription(subscription) {
|
||||||
const db = readStore();
|
return transaction(() => {
|
||||||
db.subscriptions[subscription.id] = { ...(db.subscriptions[subscription.id] || {}), ...subscription, updatedAt: new Date().toISOString() };
|
const existing = loadOne('subscriptions', subscription.id) || {};
|
||||||
writeStore(db);
|
const incomingEvent = Number(subscription.lastStripeEventCreated || 0);
|
||||||
return db.subscriptions[subscription.id];
|
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) {
|
export function createOrUpdateLicenseBySubscription(subscriptionId, patch) {
|
||||||
const db = readStore();
|
return transaction(() => {
|
||||||
const existing = Object.values(db.licenses).find((lic) => lic.subscriptionId === subscriptionId);
|
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 id = existing?.id || crypto.randomUUID();
|
||||||
const next = {
|
const durationDays = patch.durationDays || PLAN_DURATIONS[patch.planCode];
|
||||||
|
const allocated = existing ? null : createLicenseKey(durationDays);
|
||||||
|
const inferredOneTimeExpiry = existing?.oneTimeExpiresAt
|
||||||
|
|| (existing?.paymentIntentId || existing?.processedPaymentIntentIds?.length ? existing.expiresAt : null);
|
||||||
|
const raw = {
|
||||||
id,
|
id,
|
||||||
key: existing?.key || createLicenseKey(),
|
key: existing?.key || allocated.key,
|
||||||
|
codeId: existing?.codeId || allocated.codeId,
|
||||||
createdAt: existing?.createdAt || new Date().toISOString(),
|
createdAt: existing?.createdAt || new Date().toISOString(),
|
||||||
...existing,
|
...existing,
|
||||||
...patch,
|
...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()
|
updatedAt: new Date().toISOString()
|
||||||
};
|
};
|
||||||
db.licenses[id] = next;
|
const next = recomputeEntitlement(raw);
|
||||||
writeStore(db);
|
return saveOne('licenses', id, next);
|
||||||
return 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) {
|
export function findLicenseByKey(key) {
|
||||||
const db = readStore();
|
return loadAll('licenses').find((lic) => lic.key === key) || null;
|
||||||
return Object.values(db.licenses).find((lic) => lic.key === key) || null;
|
}
|
||||||
|
|
||||||
|
export function findActiveLicenseByCustomerEmail(email) {
|
||||||
|
if (!email) return null;
|
||||||
|
const normalized = email.toLowerCase();
|
||||||
|
return loadAll('licenses').find((lic) => lic.customerEmail?.toLowerCase() === normalized && lic.active) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findLicenseByCustomerId(customerId) {
|
||||||
|
if (!customerId) return 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extendLicenseByDuration(licenseId, days) {
|
||||||
|
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) {
|
export function updateLicense(id, patch) {
|
||||||
const db = readStore();
|
return transaction(() => {
|
||||||
if (!db.licenses[id]) return null;
|
const existing = loadOne('licenses', id);
|
||||||
db.licenses[id] = { ...db.licenses[id], ...patch, updatedAt: new Date().toISOString() };
|
if (!existing) return null;
|
||||||
writeStore(db);
|
return saveOne('licenses', id, { ...existing, ...patch, updatedAt: new Date().toISOString() });
|
||||||
return db.licenses[id];
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
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