diff --git a/src/licenseLogic.js b/src/licenseLogic.js new file mode 100644 index 0000000..6fdfb23 --- /dev/null +++ b/src/licenseLogic.js @@ -0,0 +1,88 @@ +import crypto from 'crypto'; +import { PREMIUM_FEATURES } from './plans.js'; +import { createOrUpdateLicenseBySubscription, findLicenseByKey, updateLicense } from './store.js'; + +export function fingerprintMachine(payload = {}) { + const parts = [ + payload.hostname || '', + payload.platform || '', + payload.arch || '', + payload.cpu || '', + payload.mac || '' + ]; + return crypto.createHash('sha256').update(parts.join('|')).digest('hex').slice(0, 16); +} + +export function syncLicenseFromSubscription({ subscriptionId, customerId, customerEmail, planCode, status, currentPeriodEnd }) { + const premiumFeatures = Object.fromEntries(PREMIUM_FEATURES.map((f) => [f, true])); + return createOrUpdateLicenseBySubscription(subscriptionId, { + subscriptionId, + customerId, + customerEmail, + planCode, + status, + expiresAt: currentPeriodEnd, + active: ['active', 'trialing', 'past_due'].includes(status), + premiumFeatures, + machineFingerprint: null, + deactivatedAt: null + }); +} + +export function validateLicense({ code, machine }) { + const license = findLicenseByKey(code); + if (!license) { + return { success: false, message: 'License not found' }; + } + + if (!license.active) { + return { success: false, message: 'License is not active' }; + } + + const now = Date.now(); + if (license.expiresAt && new Date(license.expiresAt).getTime() < now) { + return { success: false, message: 'License has expired' }; + } + + const fingerprint = fingerprintMachine(machine || {}); + + if (license.machineFingerprint && license.machineFingerprint !== fingerprint) { + return { success: false, message: 'License is already active on another machine' }; + } + + const updated = license.machineFingerprint + ? license + : updateLicense(license.id, { machineFingerprint: fingerprint, activatedAt: new Date().toISOString() }); + + return { + success: true, + license: { + code: updated.key, + tier: 'premium', + expiresAt: updated.expiresAt, + features: updated.premiumFeatures, + subscriptionStatus: updated.status, + customerEmail: updated.customerEmail + }, + message: updated === license ? 'License validated' : 'License activated on this machine' + }; +} + +export function deactivateLicense({ code, machine }) { + const license = findLicenseByKey(code); + if (!license) { + return { success: false, message: 'License not found' }; + } + + const fingerprint = fingerprintMachine(machine || {}); + if (license.machineFingerprint && license.machineFingerprint !== fingerprint) { + return { success: false, message: 'License is active on a different machine' }; + } + + const updated = updateLicense(license.id, { + machineFingerprint: null, + deactivatedAt: new Date().toISOString() + }); + + return { success: true, message: 'License deactivated', license: updated }; +} diff --git a/src/server.js b/src/server.js index 858f47c..f17ca74 100644 --- a/src/server.js +++ b/src/server.js @@ -2,9 +2,10 @@ import express from 'express'; import { config } from './config.js'; import { getPlan, listPlans, PREMIUM_FEATURES } from './plans.js'; import { getStripe } from './stripe.js'; +import { getStoreSnapshot, upsertCustomer, upsertSubscription } from './store.js'; +import { syncLicenseFromSubscription, validateLicense, deactivateLicense } from './licenseLogic.js'; const app = express(); - app.use('/api/stripe/webhook', express.raw({ type: 'application/json' })); app.use(express.json()); @@ -13,70 +14,43 @@ app.get('/health', (_req, res) => { }); app.get('/api/public/config', (_req, res) => { - res.json({ - ok: true, - publishableKeyPresent: Boolean(config.stripePublishableKey), - websiteUrl: config.websiteUrl - }); + res.json({ ok: true, publishableKeyPresent: Boolean(config.stripePublishableKey), websiteUrl: config.websiteUrl }); }); 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) => { + res.json({ ok: true, store: getStoreSnapshot() }); }); app.post('/api/checkout/session', 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' }); - } + if (!plan) return res.status(400).json({ ok: false, error: 'Invalid planCode' }); const stripe = getStripe(); - const session = await stripe.checkout.sessions.create({ mode: 'subscription', payment_method_types: ['card'], - line_items: [ - { - price_data: { - currency: 'usd', - product_data: { - name: plan.label, - description: 'DashCaddy Premium subscription' - }, - recurring: { - interval: plan.interval, - interval_count: plan.intervalCount - }, - unit_amount: plan.amountUsd * 100 - }, - quantity: 1 - } - ], + line_items: [{ + price_data: { + currency: 'usd', + product_data: { name: plan.label, description: 'DashCaddy Premium subscription' }, + recurring: { interval: plan.interval, interval_count: plan.intervalCount }, + unit_amount: plan.amountUsd * 100 + }, + quantity: 1 + }], success_url: `${config.websiteUrl}/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: `${config.websiteUrl}/pricing`, allow_promotion_codes: true, billing_address_collection: 'required', customer_email: customerEmail || undefined, - metadata: { - source: 'dashcaddy.net', - planCode: plan.code, - tier: plan.tier - }, - subscription_data: { - metadata: { - source: 'dashcaddy.net', - planCode: plan.code, - tier: plan.tier - } - } + metadata: { source: 'dashcaddy.net', planCode: plan.code, tier: plan.tier }, + subscription_data: { metadata: { source: 'dashcaddy.net', planCode: plan.code, tier: plan.tier } } }); return res.json({ ok: true, url: session.url, sessionId: session.id }); @@ -88,18 +62,89 @@ app.post('/api/checkout/session', async (req, res) => { app.post('/api/stripe/webhook', async (req, res) => { try { - return res.json({ ok: true, message: 'Webhook endpoint scaffolded, handler next' }); + if (!config.stripeWebhookSecret) { + return res.status(500).json({ ok: false, error: 'Missing STRIPE_WEBHOOK_SECRET' }); + } + + const signature = req.headers['stripe-signature']; + if (!signature) { + return res.status(400).json({ ok: false, error: 'Missing stripe-signature header' }); + } + + const stripe = getStripe(); + const event = stripe.webhooks.constructEvent(req.body, signature, config.stripeWebhookSecret); + + switch (event.type) { + case 'checkout.session.completed': { + const session = event.data.object; + if (session.customer) { + upsertCustomer({ id: String(session.customer), email: session.customer_email || null, checkoutSessionId: session.id }); + } + break; + } + + case 'customer.subscription.created': + case 'customer.subscription.updated': + case 'customer.subscription.deleted': { + const subscription = event.data.object; + const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer?.id; + const customerEmail = subscription.customer_email || null; + const planCode = subscription.metadata?.planCode || 'premium_1m'; + const currentPeriodEnd = subscription.current_period_end + ? new Date(subscription.current_period_end * 1000).toISOString() + : null; + + upsertSubscription({ + id: subscription.id, + customerId, + status: subscription.status, + planCode, + currentPeriodEnd, + cancelAtPeriodEnd: Boolean(subscription.cancel_at_period_end) + }); + + const license = syncLicenseFromSubscription({ + subscriptionId: subscription.id, + customerId, + customerEmail, + planCode, + status: subscription.status, + currentPeriodEnd + }); + + console.log('License synced from subscription', { subscriptionId: subscription.id, licenseKey: license.key, status: license.status }); + break; + } + + case 'invoice.payment_failed': { + const invoice = event.data.object; + console.warn('Payment failed', { invoiceId: invoice.id, customerId: invoice.customer }); + break; + } + + default: + console.log('Unhandled Stripe event', event.type); + } + + return res.json({ ok: true }); } catch (error) { - return res.status(500).json({ ok: false, error: error.message || 'Webhook failed' }); + console.error('Webhook processing error:', error); + return res.status(400).json({ ok: false, error: error.message || 'Webhook failed' }); } }); -app.post('/api/license/validate', async (_req, res) => { - return res.status(501).json({ ok: false, error: 'License validation not implemented yet' }); +app.post('/api/license/validate', async (req, res) => { + const { code, machine } = req.body || {}; + if (!code) return res.status(400).json({ ok: false, error: 'License code is required' }); + const result = validateLicense({ code, machine }); + return res.status(result.success ? 200 : 400).json(result); }); -app.post('/api/license/deactivate', async (_req, res) => { - return res.status(501).json({ ok: false, error: 'License deactivation not implemented yet' }); +app.post('/api/license/deactivate', async (req, res) => { + const { code, machine } = req.body || {}; + if (!code) return res.status(400).json({ ok: false, error: 'License code is required' }); + const result = deactivateLicense({ code, machine }); + return res.status(result.success ? 200 : 400).json(result); }); app.listen(config.port, () => { diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..7af5fb4 --- /dev/null +++ b/src/store.js @@ -0,0 +1,76 @@ +import fs from 'fs'; +import path from 'path'; +import crypto from 'crypto'; + +const DATA_DIR = process.env.DATA_DIR || path.resolve(process.cwd(), 'data'); +const DATA_FILE = path.join(DATA_DIR, 'db.json'); + +function ensureStore() { + fs.mkdirSync(DATA_DIR, { recursive: true }); + if (!fs.existsSync(DATA_FILE)) { + fs.writeFileSync(DATA_FILE, JSON.stringify({ customers: {}, subscriptions: {}, licenses: {} }, null, 2)); + } +} + +function readStore() { + ensureStore(); + return JSON.parse(fs.readFileSync(DATA_FILE, 'utf8')); +} + +function writeStore(db) { + ensureStore(); + fs.writeFileSync(DATA_FILE, JSON.stringify(db, null, 2)); +} + +export function createLicenseKey() { + const raw = crypto.randomBytes(16).toString('hex').toUpperCase(); + return `DC-${raw.slice(0,5)}-${raw.slice(5,10)}-${raw.slice(10,15)}-${raw.slice(15,20)}-${raw.slice(20,25)}`; +} + +export function upsertCustomer(customer) { + const db = readStore(); + db.customers[customer.id] = { ...(db.customers[customer.id] || {}), ...customer, updatedAt: new Date().toISOString() }; + writeStore(db); + return db.customers[customer.id]; +} + +export function upsertSubscription(subscription) { + const db = readStore(); + db.subscriptions[subscription.id] = { ...(db.subscriptions[subscription.id] || {}), ...subscription, updatedAt: new Date().toISOString() }; + writeStore(db); + return db.subscriptions[subscription.id]; +} + +export function createOrUpdateLicenseBySubscription(subscriptionId, patch) { + const db = readStore(); + const existing = Object.values(db.licenses).find((lic) => lic.subscriptionId === subscriptionId); + const id = existing?.id || crypto.randomUUID(); + const next = { + id, + key: existing?.key || createLicenseKey(), + createdAt: existing?.createdAt || new Date().toISOString(), + ...existing, + ...patch, + updatedAt: new Date().toISOString() + }; + db.licenses[id] = next; + writeStore(db); + return next; +} + +export function findLicenseByKey(key) { + const db = readStore(); + return Object.values(db.licenses).find((lic) => lic.key === key) || null; +} + +export function updateLicense(id, patch) { + const db = readStore(); + if (!db.licenses[id]) return null; + db.licenses[id] = { ...db.licenses[id], ...patch, updatedAt: new Date().toISOString() }; + writeStore(db); + return db.licenses[id]; +} + +export function getStoreSnapshot() { + return readStore(); +}