v1.7.0: piani Basic/Pro/Premium/Annual + quota giornaliera
Pricing model: subscription mensili (Basic 2.99 / Pro 5.99 / Premium 9.99) piu' Annual 49.90 one-time. Feature gating per piano (Basic = solo audio, Pro+ = tutto). Quota giornaliera (10/30/150) con reset mezzanotte Europe/Rome. Server: tabella daily_usage, endpoint /api/usage/consume e /status, webhook LS gestisce subscription_* events + mappatura variant_id -> plan via .env (LS_VARIANT_BASIC|PRO|PREMIUM|ANNUAL). Client: gating tab in sidebar in base ai features del piano, contatore "X/Y oggi" + bottone Upgrade, modal blocco con CTA verso landing.
This commit is contained in:
1 parent
bcd4f22b1e
commit
61459099f9
15 files changed
+1395
-108
No files matched your search
@@ -0,0 +1,28 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE licenses
|
||||
ADD COLUMN plan VARCHAR(16) NULL AFTER status,
|
||||
ADD COLUMN daily_limit INT UNSIGNED NULL AFTER plan,
|
||||
ADD COLUMN subscription_id VARCHAR(64) NULL AFTER daily_limit,
|
||||
ADD COLUMN current_period_end BIGINT UNSIGNED NULL AFTER subscription_id,
|
||||
ADD COLUMN expires_at BIGINT UNSIGNED NULL AFTER current_period_end;
|
||||
|
||||
UPDATE licenses
|
||||
SET plan = 'annual',
|
||||
daily_limit = NULL,
|
||||
expires_at = NULL
|
||||
WHERE plan IS NULL;
|
||||
|
||||
ALTER TABLE licenses
|
||||
ADD KEY idx_licenses_plan (plan),
|
||||
ADD KEY idx_licenses_sub (subscription_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS daily_usage (
|
||||
license_id INT UNSIGNED NOT NULL,
|
||||
day CHAR(10) NOT NULL,
|
||||
count INT UNSIGNED NOT NULL DEFAULT 0,
|
||||
updated_at BIGINT UNSIGNED NOT NULL,
|
||||
PRIMARY KEY (license_id, day),
|
||||
CONSTRAINT fk_daily_usage_license
|
||||
FOREIGN KEY (license_id) REFERENCES licenses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
+66
-8
@@ -1,21 +1,72 @@
|
||||
/**
|
||||
* Invio email transazionali via Resend (https://resend.com).
|
||||
* Usa fetch nativo di Node >= 20 — nessuna dipendenza.
|
||||
* Invio email transazionali via Resend.
|
||||
*
|
||||
* sendLicenseEmail(to, key, { plan, expiresAt, periodEnd })
|
||||
* - plan: codice piano ("basic" | "pro" | "premium" | "annual")
|
||||
* - expiresAt: epoch seconds (annual one-time) o null
|
||||
* - periodEnd: epoch seconds (subscription mensile) o null
|
||||
*
|
||||
* Variabili env richieste:
|
||||
* RESEND_API_KEY dal dashboard Resend
|
||||
* EMAIL_FROM "MusicTools <noreply@djluza.com>" (dominio verificato)
|
||||
*/
|
||||
|
||||
import { getPlan } from "./plans.js";
|
||||
|
||||
const FROM = process.env.EMAIL_FROM || "MusicTools <noreply@djluza.com>";
|
||||
const BASE = process.env.PUBLIC_BASE_URL || "https://musictools.djluza.com";
|
||||
|
||||
export async function sendLicenseEmail(to, licenseKey) {
|
||||
function fmtItalianDate(epochSeconds) {
|
||||
if (!epochSeconds) return "";
|
||||
const d = new Date(Number(epochSeconds) * 1000);
|
||||
return new Intl.DateTimeFormat("it-IT", {
|
||||
timeZone: "Europe/Rome",
|
||||
day: "2-digit",
|
||||
month: "long",
|
||||
year: "numeric",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
function describePlan(planCode, expiresAt, periodEnd) {
|
||||
const p = getPlan(planCode);
|
||||
if (!p) {
|
||||
return {
|
||||
name: "MusicTools",
|
||||
summary: "La tua licenza e' attiva.",
|
||||
};
|
||||
}
|
||||
const features = {
|
||||
audio: "download brani audio",
|
||||
video: "download video",
|
||||
record: "registrazione audio",
|
||||
metadata: "editor metadati",
|
||||
upgrade: "upgrade qualita' libreria",
|
||||
};
|
||||
const featList = p.features.map((f) => features[f] || f).join(", ");
|
||||
const limit = p.daily_limit === null
|
||||
? "senza limiti giornalieri"
|
||||
: `fino a ${p.daily_limit} download al giorno`;
|
||||
let validity = "";
|
||||
if (planCode === "annual" && expiresAt) {
|
||||
validity = `Validita': 1 anno, scade il <strong>${fmtItalianDate(expiresAt)}</strong>.`;
|
||||
} else if (p.is_subscription && periodEnd) {
|
||||
validity = `Prossimo rinnovo: <strong>${fmtItalianDate(periodEnd)}</strong>.`;
|
||||
}
|
||||
return {
|
||||
name: p.name,
|
||||
summary: `Piano <strong>${p.name}</strong> — ${limit}. Funzioni incluse: ${featList}. ${validity}`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function sendLicenseEmail(to, licenseKey, opts = {}) {
|
||||
if (!process.env.RESEND_API_KEY) {
|
||||
console.warn("[email] RESEND_API_KEY non impostata, skip invio a", to);
|
||||
return;
|
||||
}
|
||||
|
||||
const { plan: planCode, expiresAt, periodEnd } = opts;
|
||||
const planInfo = describePlan(planCode, expiresAt, periodEnd);
|
||||
|
||||
const q = `key=${encodeURIComponent(licenseKey)}&email=${encodeURIComponent(to)}`;
|
||||
const macUrl = `${BASE}/api/download/macos?${q}`;
|
||||
const winUrl = `${BASE}/api/download/windows?${q}`;
|
||||
@@ -23,8 +74,11 @@ export async function sendLicenseEmail(to, licenseKey) {
|
||||
const html = `
|
||||
<div style="font-family:-apple-system,Segoe UI,sans-serif;max-width:560px;margin:0 auto;padding:24px;color:#111">
|
||||
<h1 style="color:#1db954;margin:0 0 14px;font-size:22px">Grazie per aver scelto MusicTools!</h1>
|
||||
<p style="font-size:15px;line-height:1.55;margin:0 0 14px">
|
||||
${planInfo.summary}
|
||||
</p>
|
||||
<p style="font-size:15px;line-height:1.55;margin:0 0 18px">
|
||||
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
|
||||
Ecco la tua chiave di licenza. Conservala con cura — ti servira' per attivare l'app.
|
||||
</p>
|
||||
<p style="font-size:22px;letter-spacing:2px;font-family:monospace;background:#f4f4f4;padding:16px;border-radius:10px;text-align:center;margin:0 0 24px;color:#000">
|
||||
${licenseKey}
|
||||
@@ -35,12 +89,12 @@ export async function sendLicenseEmail(to, licenseKey) {
|
||||
<tr>
|
||||
<td style="padding-right:10px">
|
||||
<a href="${macUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
|
||||
🍎 Scarica per Mac
|
||||
Scarica per Mac
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a href="${winUrl}" style="display:inline-block;background:#1db954;color:#fff;text-decoration:none;padding:12px 22px;border-radius:999px;font-weight:700;font-size:14px">
|
||||
🪟 Scarica per Windows
|
||||
Scarica per Windows
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -49,7 +103,7 @@ export async function sendLicenseEmail(to, licenseKey) {
|
||||
<p style="font-size:15px;margin:0 0 8px"><strong>Come attivare:</strong></p>
|
||||
<ol style="font-size:14.5px;line-height:1.6;padding-left:22px">
|
||||
<li>Clicca uno dei bottoni qui sopra per scaricare l'installer</li>
|
||||
<li>Installa l'app (su Mac: tasto destro → Apri → Apri; su Windows: Esegui comunque)</li>
|
||||
<li>Installa l'app (su Mac: tasto destro → Apri → Apri; su Windows: Esegui comunque)</li>
|
||||
<li>Apri MusicTools: la prima schermata ti chiede email e chiave</li>
|
||||
<li>Inserisci <strong>${to}</strong> e la chiave qui sopra</li>
|
||||
</ol>
|
||||
@@ -62,6 +116,10 @@ export async function sendLicenseEmail(to, licenseKey) {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const subject = planInfo.name === "MusicTools"
|
||||
? "La tua licenza MusicTools"
|
||||
: `La tua licenza MusicTools ${planInfo.name}`;
|
||||
|
||||
const resp = await fetch("https://api.resend.com/emails", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
@@ -71,7 +129,7 @@ export async function sendLicenseEmail(to, licenseKey) {
|
||||
body: JSON.stringify({
|
||||
from: FROM,
|
||||
to: [to],
|
||||
subject: "La tua licenza MusicTools",
|
||||
subject,
|
||||
html,
|
||||
}),
|
||||
});
|
||||
|
||||
+253
-38
@@ -1,13 +1,26 @@
|
||||
/**
|
||||
* Webhook Lemon Squeezy.
|
||||
* URL pubblico: https://musictools.djluza.com/api/webhook/lemonsqueezy
|
||||
* Eventi gestiti: order_created, order_refunded.
|
||||
*
|
||||
* Eventi gestiti:
|
||||
* - order_created : crea licenza ANNUAL (one-time) con expires_at = now+365d.
|
||||
* Per le subscription ignora: il record nasce da subscription_created.
|
||||
* - order_refunded : status -> refunded.
|
||||
* - subscription_created : crea licenza con plan, subscription_id, current_period_end.
|
||||
* - subscription_updated : aggiorna current_period_end e plan se cambiato.
|
||||
* - subscription_payment_success : estende current_period_end al nuovo renews_at.
|
||||
* - subscription_payment_failed : (no-op qui) lasciamo scadere current_period_end.
|
||||
* - subscription_cancelled : niente, il periodo corrente resta valido fino a current_period_end.
|
||||
* - subscription_expired : status -> revoked, l'utente perde l'accesso.
|
||||
*
|
||||
* Tutto idempotente: ogni evento si puo' rispedire senza spaccare nulla.
|
||||
*/
|
||||
|
||||
import crypto from "node:crypto";
|
||||
import { one, exec } from "./db.js";
|
||||
import { generateLicenseKey } from "./license.js";
|
||||
import { sendLicenseEmail } from "./email.js";
|
||||
import { planByVariantId, getPlan, ANNUAL_TTL_SECONDS } from "./plans.js";
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -21,8 +34,210 @@ function timingSafe(a, b) {
|
||||
return crypto.timingSafeEqual(A, B);
|
||||
}
|
||||
|
||||
/** Converte una data ISO (es. "2026-07-09T13:24:00Z") in epoch seconds, o null. */
|
||||
function isoToEpoch(iso) {
|
||||
if (!iso) return null;
|
||||
const t = Date.parse(String(iso));
|
||||
if (Number.isNaN(t)) return null;
|
||||
return Math.floor(t / 1000);
|
||||
}
|
||||
|
||||
/** Estrae variant_id dal payload, gestendo le diverse forme degli eventi. */
|
||||
function extractVariantId(payload, attrs) {
|
||||
// subscription_*: attrs.variant_id (diretto)
|
||||
if (attrs.variant_id != null) return String(attrs.variant_id);
|
||||
// order_created: attrs.first_order_item.variant_id
|
||||
const item = attrs.first_order_item;
|
||||
if (item && item.variant_id != null) return String(item.variant_id);
|
||||
// alcune forme passano da relationships
|
||||
const rel = payload?.data?.relationships?.variant?.data?.id;
|
||||
if (rel) return String(rel);
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Email cliente: nei subscription_* puo' essere customer_email. */
|
||||
function extractEmail(attrs) {
|
||||
const e = attrs.user_email || attrs.customer_email || attrs.email || "";
|
||||
return String(e).trim().toLowerCase();
|
||||
}
|
||||
|
||||
// ---- Handlers per famiglia evento -----------------------------------------
|
||||
|
||||
async function handleOrderCreated(payload, attrs, t) {
|
||||
const email = extractEmail(attrs);
|
||||
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||
if (!email || !orderId) {
|
||||
return { status: 400, body: { error: "Missing email or order_id" } };
|
||||
}
|
||||
const variantId = extractVariantId(payload, attrs);
|
||||
const planCode = planByVariantId(variantId);
|
||||
const plan = planCode ? getPlan(planCode) : null;
|
||||
|
||||
// Subscriptions: il record vero arriva con subscription_created.
|
||||
// Se per qualche motivo arriva prima (ordine pagato ma sottoscrizione
|
||||
// non ancora generata), accettiamo l'evento ma non creiamo nulla.
|
||||
if (plan && plan.is_subscription) {
|
||||
return { status: 200, body: { ok: true, deferred: "wait_for_subscription_created" } };
|
||||
}
|
||||
|
||||
// Annual one-time (o piano non mappato -> trattalo come annual di default
|
||||
// cosi' non lasciamo l'utente senza licenza se ha pagato).
|
||||
const planForRecord = plan?.code || "annual";
|
||||
const limit = plan?.daily_limit ?? null;
|
||||
const expiresAt = t + ANNUAL_TTL_SECONDS;
|
||||
const key = generateLicenseKey();
|
||||
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, plan, daily_limit,
|
||||
source, order_id, expires_at, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?)`,
|
||||
[key, email, planForRecord, limit, orderId, expiresAt, t, t],
|
||||
);
|
||||
} catch (e) {
|
||||
if (e?.code === "ER_DUP_ENTRY") {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLicenseEmail(email, key, { plan: planForRecord, expiresAt });
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
|
||||
return { status: 200, body: { ok: true, plan: planForRecord } };
|
||||
}
|
||||
|
||||
async function handleOrderRefunded(payload, attrs, t) {
|
||||
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||
if (!orderId) return { status: 400, body: { error: "Missing order_id" } };
|
||||
await exec(
|
||||
`UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`,
|
||||
[t, orderId],
|
||||
);
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionCreated(payload, attrs, t) {
|
||||
const email = extractEmail(attrs);
|
||||
const subId = String(payload?.data?.id || attrs.subscription_id || "");
|
||||
const variantId = extractVariantId(payload, attrs);
|
||||
const planCode = planByVariantId(variantId);
|
||||
if (!email || !subId) {
|
||||
return { status: 400, body: { error: "Missing email or subscription_id" } };
|
||||
}
|
||||
if (!planCode) {
|
||||
// Niente mapping -> non sappiamo che limiti applicare. Logghiamo e
|
||||
// ritorniamo 200 cosi' LS non rispedisce all'infinito.
|
||||
console.warn("[ls] subscription_created variant non mappato:", variantId, "email=", email);
|
||||
return { status: 200, body: { ok: true, ignored: "unknown_variant" } };
|
||||
}
|
||||
const plan = getPlan(planCode);
|
||||
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
|
||||
const orderId = String(attrs.order_id || "");
|
||||
const key = generateLicenseKey();
|
||||
|
||||
// Idempotenza: se ricevo lo stesso subscription_created due volte,
|
||||
// riuso la licenza esistente (non ne creo un'altra).
|
||||
const existing = await one(
|
||||
"SELECT id, license_key FROM licenses WHERE subscription_id=? LIMIT 1",
|
||||
[subId],
|
||||
);
|
||||
if (existing) {
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET status='active', plan=?, daily_limit=?, current_period_end=?, updated_at=?
|
||||
WHERE id=?`,
|
||||
[planCode, plan.daily_limit, periodEnd, t, existing.id],
|
||||
);
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, plan, daily_limit,
|
||||
source, order_id, subscription_id, current_period_end,
|
||||
created_at, updated_at)
|
||||
VALUES (?, ?, 'active', ?, ?, 'lemonsqueezy', ?, ?, ?, ?, ?)`,
|
||||
[key, email, planCode, plan.daily_limit, orderId || null, subId, periodEnd, t, t],
|
||||
);
|
||||
} catch (e) {
|
||||
if (e?.code === "ER_DUP_ENTRY") {
|
||||
return { status: 200, body: { ok: true, duplicate: true } };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
await sendLicenseEmail(email, key, { plan: planCode, periodEnd });
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
return { status: 200, body: { ok: true, plan: planCode } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionUpdated(payload, attrs, t) {
|
||||
const subId = String(payload?.data?.id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
const variantId = extractVariantId(payload, attrs);
|
||||
const planCode = planByVariantId(variantId);
|
||||
const periodEnd = isoToEpoch(attrs.renews_at) || isoToEpoch(attrs.ends_at);
|
||||
|
||||
if (planCode) {
|
||||
const plan = getPlan(planCode);
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET plan=?, daily_limit=?, current_period_end=?, updated_at=?
|
||||
WHERE subscription_id=?`,
|
||||
[planCode, plan.daily_limit, periodEnd, t, subId],
|
||||
);
|
||||
} else if (periodEnd) {
|
||||
await exec(
|
||||
`UPDATE licenses SET current_period_end=?, updated_at=? WHERE subscription_id=?`,
|
||||
[periodEnd, t, subId],
|
||||
);
|
||||
}
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionPaymentSuccess(payload, attrs, t) {
|
||||
// L'evento di pagamento riferisce alla subscription via attrs.subscription_id
|
||||
const subId = String(attrs.subscription_id || payload?.data?.id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
|
||||
// Quando un rinnovo va a buon fine LS sposta avanti renews_at.
|
||||
// Per essere sicuri leggiamo l'evento PIU' recente (subscription_updated
|
||||
// viene inviato a stretto giro), oppure deduciamo: +30 giorni.
|
||||
const periodEnd = isoToEpoch(attrs.renews_at)
|
||||
|| isoToEpoch(attrs.created_at)
|
||||
|| (t + 30 * 24 * 3600);
|
||||
|
||||
await exec(
|
||||
`UPDATE licenses
|
||||
SET status='active', current_period_end=?, updated_at=?
|
||||
WHERE subscription_id=?`,
|
||||
[periodEnd, t, subId],
|
||||
);
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
async function handleSubscriptionExpired(payload, attrs, t) {
|
||||
const subId = String(payload?.data?.id || attrs.subscription_id || "");
|
||||
if (!subId) return { status: 400, body: { error: "Missing subscription_id" } };
|
||||
await exec(
|
||||
`UPDATE licenses SET status='revoked', updated_at=? WHERE subscription_id=?`,
|
||||
[t, subId],
|
||||
);
|
||||
return { status: 200, body: { ok: true } };
|
||||
}
|
||||
|
||||
// ---- Entry point ----------------------------------------------------------
|
||||
|
||||
export async function webhook(req, res) {
|
||||
// express.raw() salva il body come Buffer in req.body
|
||||
const raw = req.body instanceof Buffer ? req.body.toString("utf-8") : "";
|
||||
const sig = req.get("X-Signature") || "";
|
||||
const secret = process.env.LEMONSQUEEZY_SIGNING_SECRET;
|
||||
@@ -39,45 +254,45 @@ export async function webhook(req, res) {
|
||||
|
||||
const eventName = payload?.meta?.event_name || "";
|
||||
const attrs = payload?.data?.attributes || {};
|
||||
const email = String(attrs.user_email || "").trim().toLowerCase();
|
||||
const orderId = String(payload?.data?.id || attrs.order_number || "");
|
||||
if (!email || !orderId) {
|
||||
return res.status(400).json({ error: "Missing email or order_id" });
|
||||
}
|
||||
|
||||
const t = now();
|
||||
|
||||
if (eventName === "order_created") {
|
||||
const key = generateLicenseKey();
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO licenses
|
||||
(license_key, email, status, source, order_id, created_at, updated_at)
|
||||
VALUES (?, ?, 'active', 'lemonsqueezy', ?, ?, ?)`,
|
||||
[key, email, orderId, t, t],
|
||||
);
|
||||
} catch (e) {
|
||||
// duplicate webhook delivery
|
||||
if (e?.code === "ER_DUP_ENTRY") {
|
||||
return res.json({ ok: true, duplicate: true });
|
||||
}
|
||||
throw e;
|
||||
let out;
|
||||
try {
|
||||
switch (eventName) {
|
||||
case "order_created":
|
||||
out = await handleOrderCreated(payload, attrs, t);
|
||||
break;
|
||||
case "order_refunded":
|
||||
out = await handleOrderRefunded(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_created":
|
||||
out = await handleSubscriptionCreated(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_updated":
|
||||
case "subscription_resumed":
|
||||
case "subscription_unpaused":
|
||||
out = await handleSubscriptionUpdated(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_payment_success":
|
||||
out = await handleSubscriptionPaymentSuccess(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_expired":
|
||||
out = await handleSubscriptionExpired(payload, attrs, t);
|
||||
break;
|
||||
case "subscription_cancelled":
|
||||
case "subscription_paused":
|
||||
case "subscription_payment_failed":
|
||||
case "subscription_payment_refunded":
|
||||
// No-op: il record resta com'e' fino al prossimo evento che cambia stato.
|
||||
out = { status: 200, body: { ok: true, ignored: eventName } };
|
||||
break;
|
||||
default:
|
||||
out = { status: 200, body: { ok: true, ignored: eventName } };
|
||||
}
|
||||
try {
|
||||
await sendLicenseEmail(email, key);
|
||||
} catch (e) {
|
||||
console.error("[email] send failed:", e?.message || e);
|
||||
}
|
||||
return res.json({ ok: true });
|
||||
} catch (e) {
|
||||
console.error("[ls] handler error", eventName, e?.message || e);
|
||||
return res.status(500).json({ error: "Internal error" });
|
||||
}
|
||||
|
||||
if (eventName === "order_refunded") {
|
||||
await exec(
|
||||
`UPDATE licenses SET status='refunded', updated_at=? WHERE order_id=?`,
|
||||
[t, orderId],
|
||||
);
|
||||
return res.json({ ok: true });
|
||||
}
|
||||
|
||||
res.json({ ok: true, ignored: eventName });
|
||||
return res.status(out.status).json(out.body);
|
||||
}
|
||||
+84
-12
@@ -1,6 +1,7 @@
|
||||
import crypto from "node:crypto";
|
||||
import { one, exec } from "./db.js";
|
||||
import { signJwt, verifyJwt } from "./jwt.js";
|
||||
import { getPlan, planSnapshot } from "./plans.js";
|
||||
|
||||
const MAX_ACTIVATIONS = Number(process.env.MAX_ACTIVATIONS || 3);
|
||||
const TOKEN_TTL_DAYS = Number(process.env.TOKEN_TTL_DAYS || 30);
|
||||
@@ -9,18 +10,54 @@ const now = () => Math.floor(Date.now() / 1000);
|
||||
const normEmail = (s) => String(s || "").trim().toLowerCase();
|
||||
const normKey = (s) => String(s || "").trim().toUpperCase();
|
||||
|
||||
/**
|
||||
* Verifica che una licenza sia ancora "utilizzabile" oggi.
|
||||
* Ritorna { ok: true } oppure { ok: false, error: "..." }.
|
||||
*
|
||||
* Casi gestiti:
|
||||
* - annual one-time scaduto (expires_at < now)
|
||||
* - subscription scaduta (current_period_end < now)
|
||||
*/
|
||||
function checkLicenseValidity(license, t = now()) {
|
||||
if (license.status !== "active") {
|
||||
return { ok: false, error: "Licenza non piu' valida (rimborsata o revocata)." };
|
||||
}
|
||||
if (license.expires_at && Number(license.expires_at) < t) {
|
||||
return { ok: false, error: "L'abbonamento annuale e' scaduto. Riacquista per continuare." };
|
||||
}
|
||||
if (license.current_period_end && Number(license.current_period_end) < t) {
|
||||
return { ok: false, error: "L'abbonamento e' scaduto o e' fallito il rinnovo. Verifica su Lemon Squeezy." };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async function issueToken(license, deviceId) {
|
||||
const t = now();
|
||||
return signJwt({
|
||||
const plan = planSnapshot(license.plan);
|
||||
const claims = {
|
||||
sub: String(license.id),
|
||||
key_id: license.license_key,
|
||||
email: license.email,
|
||||
device_id: deviceId,
|
||||
iat: t,
|
||||
exp: t + TOKEN_TTL_DAYS * 86400,
|
||||
}, process.env.JWT_SECRET);
|
||||
};
|
||||
if (plan) {
|
||||
claims.plan = plan.code;
|
||||
claims.plan_name = plan.name;
|
||||
claims.daily_limit = plan.daily_limit;
|
||||
claims.features = plan.features;
|
||||
claims.is_subscription = plan.is_subscription;
|
||||
}
|
||||
if (license.expires_at) claims.expires_at = Number(license.expires_at);
|
||||
if (license.current_period_end) claims.period_end = Number(license.current_period_end);
|
||||
return signJwt(claims, process.env.JWT_SECRET);
|
||||
}
|
||||
|
||||
const LICENSE_COLUMNS =
|
||||
"id, license_key, email, status, plan, daily_limit, " +
|
||||
"subscription_id, current_period_end, expires_at";
|
||||
|
||||
export async function activate(req, res) {
|
||||
const { key, email, device_id, device_name, app_version } = req.body || {};
|
||||
const K = normKey(key), E = normEmail(email);
|
||||
@@ -30,15 +67,15 @@ export async function activate(req, res) {
|
||||
}
|
||||
|
||||
const license = await one(
|
||||
"SELECT id, license_key, email, status FROM licenses WHERE license_key=? AND email=? LIMIT 1",
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses
|
||||
WHERE license_key=? AND email=? LIMIT 1`,
|
||||
[K, E],
|
||||
);
|
||||
if (!license) {
|
||||
return res.status(404).json({ error: "Chiave o email non corrispondono a un acquisto." });
|
||||
}
|
||||
if (license.status !== "active") {
|
||||
return res.status(403).json({ error: "Licenza non piu' valida (rimborsata o revocata)." });
|
||||
}
|
||||
const check = checkLicenseValidity(license);
|
||||
if (!check.ok) return res.status(403).json({ error: check.error });
|
||||
|
||||
const t = now();
|
||||
const existing = await one(
|
||||
@@ -71,7 +108,15 @@ export async function activate(req, res) {
|
||||
}
|
||||
|
||||
const token = await issueToken(license, D);
|
||||
res.json({ token, activated_at: t, email: license.email });
|
||||
const plan = planSnapshot(license.plan);
|
||||
res.json({
|
||||
token,
|
||||
activated_at: t,
|
||||
email: license.email,
|
||||
plan,
|
||||
expires_at: license.expires_at ? Number(license.expires_at) : null,
|
||||
period_end: license.current_period_end ? Number(license.current_period_end) : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validate(req, res) {
|
||||
@@ -85,12 +130,14 @@ export async function validate(req, res) {
|
||||
if (claims.device_id !== D) return res.status(401).json({ error: "device_id mismatch" });
|
||||
|
||||
const license = await one(
|
||||
"SELECT id, license_key, email, status FROM licenses WHERE id=?",
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
|
||||
[claims.sub],
|
||||
);
|
||||
if (!license || license.status !== "active") {
|
||||
return res.status(401).json({ error: "Licenza non attiva" });
|
||||
}
|
||||
if (!license) return res.status(401).json({ error: "Licenza non trovata" });
|
||||
|
||||
const check = checkLicenseValidity(license);
|
||||
if (!check.ok) return res.status(401).json({ error: check.error });
|
||||
|
||||
const act = await one(
|
||||
"SELECT id, revoked_at FROM activations WHERE license_id=? AND device_id=?",
|
||||
[license.id, D],
|
||||
@@ -105,7 +152,14 @@ export async function validate(req, res) {
|
||||
);
|
||||
|
||||
const fresh = await issueToken(license, D);
|
||||
res.json({ token: fresh, email: license.email });
|
||||
const plan = planSnapshot(license.plan);
|
||||
res.json({
|
||||
token: fresh,
|
||||
email: license.email,
|
||||
plan,
|
||||
expires_at: license.expires_at ? Number(license.expires_at) : null,
|
||||
period_end: license.current_period_end ? Number(license.current_period_end) : null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deactivate(req, res) {
|
||||
@@ -126,6 +180,24 @@ export async function deactivate(req, res) {
|
||||
res.json({ ok: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper interno: dato un token JWT valido, ritorna la riga licenze
|
||||
* fresca dal DB (per quota checks, downloads, ecc.). Ritorna null se
|
||||
* il token e' invalido o la licenza non e' piu' attiva.
|
||||
*/
|
||||
export async function licenseFromToken(token) {
|
||||
const claims = verifyJwt(String(token || ""), process.env.JWT_SECRET);
|
||||
if (!claims) return null;
|
||||
const lic = await one(
|
||||
`SELECT ${LICENSE_COLUMNS} FROM licenses WHERE id=?`,
|
||||
[claims.sub],
|
||||
);
|
||||
if (!lic) return null;
|
||||
const check = checkLicenseValidity(lic);
|
||||
if (!check.ok) return null;
|
||||
return { license: lic, claims };
|
||||
}
|
||||
|
||||
// Esposto per uso interno (es. webhook genera chiave nuova)
|
||||
export function generateLicenseKey() {
|
||||
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Source of truth per i piani MusicTools.
|
||||
*
|
||||
* Mappa anche i variant_id di Lemon Squeezy -> plan code, cosi' il
|
||||
* webhook puo' capire quale piano e' stato comprato senza if/else sparsi.
|
||||
*
|
||||
* I variant_id si trovano nel dashboard LS: Products -> click sul prodotto
|
||||
* -> Variants -> "Edit" -> URL contiene /variants/<id>. Vanno messi in
|
||||
* .env (LS_VARIANT_*); qui li leggiamo con fallback null.
|
||||
*
|
||||
* NOTA su 'features': non e' un set di "tab" ma un set di capability
|
||||
* lato server. Il client mostra/nasconde le tab in base a queste.
|
||||
* - "audio" : download brani (tab Brani)
|
||||
* - "video" : download video YouTube/TikTok/IG/FB (tab Video)
|
||||
* - "record" : registrazione audio (tab Registra)
|
||||
* - "metadata" : editor metadati (tab Metadati)
|
||||
* - "upgrade" : upgrade qualita' libreria (tab Upgrade)
|
||||
*
|
||||
* Limiti giornalieri (daily_limit, null = unlimited): contiamo come "uso"
|
||||
* ogni avvio di un job di download (1 chiamata a /api/usage/consume) E
|
||||
* ogni salvataggio metadati. La registrazione e' un'azione bound a 1
|
||||
* file/giorno: pure 1.
|
||||
*/
|
||||
|
||||
export const PLANS = {
|
||||
basic: {
|
||||
code: "basic",
|
||||
name: "Basic",
|
||||
price_eur: 2.99,
|
||||
interval: "monthly",
|
||||
daily_limit: 10,
|
||||
features: ["audio"],
|
||||
is_subscription: true,
|
||||
ls_variant_env: "LS_VARIANT_BASIC",
|
||||
},
|
||||
pro: {
|
||||
code: "pro",
|
||||
name: "Pro",
|
||||
price_eur: 5.99,
|
||||
interval: "monthly",
|
||||
daily_limit: 30,
|
||||
features: ["audio", "video", "record", "metadata", "upgrade"],
|
||||
is_subscription: true,
|
||||
ls_variant_env: "LS_VARIANT_PRO",
|
||||
},
|
||||
premium: {
|
||||
code: "premium",
|
||||
name: "Premium",
|
||||
price_eur: 9.99,
|
||||
interval: "monthly",
|
||||
daily_limit: 150,
|
||||
features: ["audio", "video", "record", "metadata", "upgrade"],
|
||||
is_subscription: true,
|
||||
ls_variant_env: "LS_VARIANT_PREMIUM",
|
||||
},
|
||||
annual: {
|
||||
code: "annual",
|
||||
name: "Annual",
|
||||
price_eur: 49.90,
|
||||
interval: "annual_one_time",
|
||||
daily_limit: null,
|
||||
features: ["audio", "video", "record", "metadata", "upgrade"],
|
||||
is_subscription: false,
|
||||
ls_variant_env: "LS_VARIANT_ANNUAL",
|
||||
},
|
||||
};
|
||||
|
||||
export const PLAN_CODES = Object.keys(PLANS);
|
||||
|
||||
/** Ritorna il plan code mappato a un LS variant_id, o null se non noto. */
|
||||
export function planByVariantId(variantId) {
|
||||
const vid = String(variantId || "").trim();
|
||||
if (!vid) return null;
|
||||
for (const code of PLAN_CODES) {
|
||||
const envName = PLANS[code].ls_variant_env;
|
||||
const mapped = String(process.env[envName] || "").trim();
|
||||
if (mapped && mapped === vid) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getPlan(code) {
|
||||
return PLANS[code] || null;
|
||||
}
|
||||
|
||||
/** Forma stabile per inclusione nei JWT claims / risposte API. */
|
||||
export function planSnapshot(code) {
|
||||
const p = getPlan(code);
|
||||
if (!p) return null;
|
||||
return {
|
||||
code: p.code,
|
||||
name: p.name,
|
||||
daily_limit: p.daily_limit,
|
||||
features: p.features.slice(),
|
||||
is_subscription: p.is_subscription,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasFeature(code, feature) {
|
||||
const p = getPlan(code);
|
||||
return !!(p && p.features.includes(feature));
|
||||
}
|
||||
|
||||
/** Durata annual one-time in secondi (365 giorni). */
|
||||
export const ANNUAL_TTL_SECONDS = 365 * 24 * 3600;
|
||||
@@ -5,6 +5,7 @@ import * as license from "./license.js";
|
||||
import * as updates from "./updates.js";
|
||||
import * as ls from "./lemonsqueezy.js";
|
||||
import * as dl from "./downloads.js";
|
||||
import * as usage from "./usage.js";
|
||||
|
||||
const app = express();
|
||||
|
||||
@@ -42,6 +43,10 @@ app.post("/api/license/activate", license.activate);
|
||||
app.post("/api/license/validate", license.validate);
|
||||
app.post("/api/license/deactivate", license.deactivate);
|
||||
|
||||
// Quota giornaliera
|
||||
app.get("/api/usage/status", usage.status);
|
||||
app.post("/api/usage/consume", usage.consume);
|
||||
|
||||
// Aggiornamenti + download firmato
|
||||
app.get("/api/latest", updates.latest);
|
||||
app.get("/api/download", updates.download);
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Quota giornaliera per piano.
|
||||
*
|
||||
* Il client chiama:
|
||||
* - GET /api/usage/status (Bearer JWT) -> solo lettura, no incremento
|
||||
* - POST /api/usage/consume (Bearer JWT) -> +1 al contatore, ritorna esito
|
||||
*
|
||||
* Il "giorno" e' YYYY-MM-DD nella timezone Europe/Rome. In questo modo
|
||||
* tutti i clienti hanno lo stesso reset (mezzanotte di Roma) — semplifica
|
||||
* il marketing ("10 download al giorno, resettati a mezzanotte").
|
||||
*/
|
||||
|
||||
import { one, exec } from "./db.js";
|
||||
import { licenseFromToken } from "./license.js";
|
||||
import { getPlan, planSnapshot } from "./plans.js";
|
||||
|
||||
const now = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
/** YYYY-MM-DD nella timezone Europe/Rome, partendo da un istante epoch. */
|
||||
export function romeDay(epochSeconds = Math.floor(Date.now() / 1000)) {
|
||||
const d = new Date(epochSeconds * 1000);
|
||||
// Intl con time zone -> formatta in YYYY-MM-DD senza problemi DST.
|
||||
const fmt = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Rome",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
// en-CA -> "2026-06-09"
|
||||
return fmt.format(d);
|
||||
}
|
||||
|
||||
function bearer(req) {
|
||||
const auth = req.get("Authorization") || "";
|
||||
return auth.startsWith("Bearer ") ? auth.slice(7).trim() : "";
|
||||
}
|
||||
|
||||
async function loadAuthorized(req, res) {
|
||||
const token = bearer(req) || String(req.body?.token || "");
|
||||
if (!token) {
|
||||
res.status(401).json({ error: "Missing token" });
|
||||
return null;
|
||||
}
|
||||
const auth = await licenseFromToken(token);
|
||||
if (!auth) {
|
||||
res.status(401).json({ error: "Licenza non valida o scaduta" });
|
||||
return null;
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
/** Risposta compatta usata da entrambi gli endpoint. */
|
||||
function buildResponse(plan, limit, used) {
|
||||
const remaining = limit === null ? null : Math.max(0, limit - used);
|
||||
return {
|
||||
plan,
|
||||
used,
|
||||
limit, // null = illimitato
|
||||
remaining, // null = illimitato
|
||||
day: romeDay(),
|
||||
};
|
||||
}
|
||||
|
||||
/** Quanti download ha gia' fatto la licenza oggi (Europe/Rome). */
|
||||
async function readUsedToday(licenseId, day) {
|
||||
const row = await one(
|
||||
"SELECT count FROM daily_usage WHERE license_id=? AND day=?",
|
||||
[licenseId, day],
|
||||
);
|
||||
return Number(row?.count || 0);
|
||||
}
|
||||
|
||||
export async function status(req, res) {
|
||||
const auth = await loadAuthorized(req, res);
|
||||
if (!auth) return;
|
||||
const { license } = auth;
|
||||
const plan = planSnapshot(license.plan);
|
||||
const limit = plan?.daily_limit ?? null;
|
||||
const day = romeDay();
|
||||
const used = limit === null ? 0 : await readUsedToday(license.id, day);
|
||||
res.json(buildResponse(plan, limit, used));
|
||||
}
|
||||
|
||||
/**
|
||||
* Incrementa di 1 il contatore del giorno corrente per la licenza,
|
||||
* rifiutando con 429 se gia' al limite. Lo facciamo con una UPDATE
|
||||
* condizionale + INSERT-or-no-op cosi' e' atomico anche sotto carico.
|
||||
*/
|
||||
export async function consume(req, res) {
|
||||
const auth = await loadAuthorized(req, res);
|
||||
if (!auth) return;
|
||||
const { license } = auth;
|
||||
const plan = planSnapshot(license.plan);
|
||||
if (!plan) return res.status(403).json({ error: "Piano non riconosciuto" });
|
||||
|
||||
// (Opzionale) il client puo' passare {feature: "video"} per chiarezza,
|
||||
// ma noi gating-amo gia' lato client e basta avere un piano valido qui.
|
||||
const feature = String(req.body?.feature || "").trim();
|
||||
if (feature && !plan.features.includes(feature)) {
|
||||
return res.status(403).json({
|
||||
error: `Il piano ${plan.name} non include questa funzione.`,
|
||||
plan,
|
||||
});
|
||||
}
|
||||
|
||||
const limit = plan.daily_limit;
|
||||
const day = romeDay();
|
||||
const t = now();
|
||||
|
||||
// Unlimited (annual): nessun gate, conta comunque per le statistiche.
|
||||
if (limit === null) {
|
||||
await exec(
|
||||
`INSERT INTO daily_usage (license_id, day, count, updated_at)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON DUPLICATE KEY UPDATE count = count + 1, updated_at = VALUES(updated_at)`,
|
||||
[license.id, day, t],
|
||||
);
|
||||
const used = await readUsedToday(license.id, day);
|
||||
return res.json({ ...buildResponse(plan, limit, used), allowed: true });
|
||||
}
|
||||
|
||||
// Limited: prova incremento condizionato. Se la riga non esiste,
|
||||
// INSERT iniziale (count=1). Se esiste e count<limit -> +1, altrimenti
|
||||
// affectedRows=0 e ritorniamo 429.
|
||||
const upd = await exec(
|
||||
`UPDATE daily_usage
|
||||
SET count = count + 1, updated_at = ?
|
||||
WHERE license_id = ? AND day = ? AND count < ?`,
|
||||
[t, license.id, day, limit],
|
||||
);
|
||||
|
||||
if (upd.affectedRows === 0) {
|
||||
// Due casi: riga non esiste (mai usato oggi) oppure gia' al limite.
|
||||
const existing = await one(
|
||||
"SELECT count FROM daily_usage WHERE license_id=? AND day=?",
|
||||
[license.id, day],
|
||||
);
|
||||
if (!existing) {
|
||||
try {
|
||||
await exec(
|
||||
`INSERT INTO daily_usage (license_id, day, count, updated_at)
|
||||
VALUES (?, ?, 1, ?)`,
|
||||
[license.id, day, t],
|
||||
);
|
||||
return res.json({ ...buildResponse(plan, limit, 1), allowed: true });
|
||||
} catch (e) {
|
||||
if (e?.code !== "ER_DUP_ENTRY") throw e;
|
||||
// race: qualcun altro l'ha inserita -> rileggi
|
||||
}
|
||||
}
|
||||
const used = await readUsedToday(license.id, day);
|
||||
return res.status(429).json({
|
||||
...buildResponse(plan, limit, used),
|
||||
allowed: false,
|
||||
error: `Limite giornaliero raggiunto (${limit}/${limit}). Riprova dopo mezzanotte o passa a un piano superiore.`,
|
||||
});
|
||||
}
|
||||
|
||||
const used = await readUsedToday(license.id, day);
|
||||
res.json({ ...buildResponse(plan, limit, used), allowed: true });
|
||||
}
|
||||
Reference in new issue
Block a user