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,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