From f6f5c2aa9728ecadce08f87b1a6ecf3b557d67bb Mon Sep 17 00:00:00 2001 From: Haxurus <72351758+haxurus@users.noreply.github.com> Date: Tue, 13 Oct 2026 08:52:48 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20Discord=20Developer=20Policy=20comp?= =?UTF-8?q?liance=20=E2=80=94=20Discord=20subscriptions,=20no=20typing/raw?= =?UTF-8?q?=20loggers,=20removed-server=20cleanup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Paid plans are sold only as Discord Premium Apps guild subscriptions. SKU IDs are set in the super console; the API reads the active entitlements through the bot (read-only REST) every two minutes and switches plans on, extends or ends them. An outage never downgrades. Manual plans are free trials/gifts. - Coupons and multi-subscription discounts removed (API, super console, pricing page); existing coupon rows deleted, table kept for rollback. - Typing and raw Gateway loggers removed, with their stored events and the GuildMessageTyping intent. - Servers the bot is removed from are deleted 30 days later (cascade), also when removed while the bot was offline. - Incident response procedure in SECURITY.md; privacy and terms updated (Discord as seller, no AI training, 30-day deletion); 70 event types. Co-Authored-By: Claude Opus 5.5 --- README.md | 15 +- SECURITY.md | 23 +- apps/api/src/discord-billing.ts | 88 +++++ apps/api/src/discord-plans.ts | 123 +++++++ apps/api/src/discord.ts | 7 + apps/api/src/index.ts | 21 +- apps/api/src/plan-routes.ts | 135 +++---- apps/api/src/plan-rules.ts | 7 +- apps/api/src/plan-service.ts | 71 ++-- apps/api/test/discord-plans.test.ts | 70 ++++ apps/api/test/plans.test.ts | 45 +-- apps/bot/src/capability-policy-check.ts | 1 + apps/bot/src/embed-format.ts | 2 +- apps/bot/src/embed-i18n.ts | 2 - apps/bot/src/handlers.ts | 31 +- apps/bot/src/index.ts | 31 +- apps/bot/src/internal-api.ts | 43 ++- apps/bot/src/store.ts | 42 ++- apps/bot/test/embed-format.test.ts | 2 +- apps/web/app/(en)/en/opengraph-image.tsx | 2 +- apps/web/app/(it)/it/opengraph-image.tsx | 2 +- apps/web/app/_components/GuildDashboard.tsx | 34 +- apps/web/app/_components/LegalPage.tsx | 30 +- apps/web/app/_components/PricingView.tsx | 124 +++---- apps/web/app/_components/PublicHome.tsx | 8 +- apps/web/app/_components/SuperConsole.tsx | 328 ++++++------------ apps/web/app/_components/plans.ts | 34 +- apps/web/app/globals.css | 35 +- apps/web/app/i18n.ts | 3 - docs/LISTINGS.md | 4 +- .../migration.sql | 23 ++ packages/db/prisma/schema.prisma | 12 + packages/shared/src/index.ts | 2 - packages/shared/src/plans.ts | 65 +--- 34 files changed, 774 insertions(+), 691 deletions(-) create mode 100644 apps/api/src/discord-billing.ts create mode 100644 apps/api/src/discord-plans.ts create mode 100644 apps/api/test/discord-plans.test.ts create mode 100644 packages/db/prisma/migrations/20261013120000_developer_policy/migration.sql diff --git a/README.md b/README.md index 419fb24..37b0115 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ Sentinel watches what happens on a Discord server — bans, kicks, role and perm ## Features -**72 event types**, each with its own logger: +**70 event types**, each with its own logger: | Area | Events | |---|---| @@ -41,7 +41,7 @@ Sentinel watches what happens on a Discord server — bans, kicks, role and perm | Voice & activity | join, leave, move, mute/deaf/stream/video, Stage, scheduled events, soundboard, voice effects | | Content | emoji, stickers | | Automation & apps | AutoMod rules and actions, slash commands, application command permissions, Discord Audit Log | -| System & advanced | server availability, optional typing and raw Gateway capture | +| System | server availability and Gateway diagnostics | **Per-logger control** @@ -86,6 +86,7 @@ A logging bot sees a lot, so Sentinel is designed to limit what a compromised co - **Read-only Discord capability policy.** The bot process allows only REST reads, posting log messages, leaving a server and changing its own nickname and banner in a server (Brand plan; any other member field is rejected). Bans, kicks, timeouts, role/channel/message changes, webhooks and AutoMod changes are blocked in-process, regardless of the permissions given to the bot role. A build-time check (`policy:check`) rejects code that tries to bypass it. - **Separated secrets.** The Discord token exists only in the bot container; the web container has no application secrets; secrets live in root-only files on the host. - **Encryption at rest.** Message content, attachments, embeds and event details are encrypted with AES-256-GCM. +- **Data minimisation.** No online status, activities or typing are recorded. History expires with the plan's retention, and when the bot is removed from a server all of that server's data and settings are deleted 30 days later unless it is added back. - **Least-privilege database.** Distinct PostgreSQL roles for API and bot; the bot cannot read sessions, panel audit or super-admin tables. CI verifies these grants on every change. - **Isolation.** Segmented Docker networks, private authenticated Redis, read-only non-root containers with `cap_drop: ALL` and resource limits, no Docker socket, egress blocked towards host/LAN. - **Web hardening.** HttpOnly/Secure session cookies (HMAC-hashed in the database, rotated on login), OAuth `state`, Origin-based CSRF checks, strict CSP, per-session rate limiting, input validation, guild ownership checks on every channel/role ID. @@ -181,20 +182,22 @@ Anyone can add the hosted bot: **Public Bot** must be enabled in the Developer P | Price | — | €1.99/month · €19.99/year | €4.99/month · €49.99/year | €8.99/month · €89.99/year | | Standard loggers | ✓ | ✓ | ✓ | ✓ | | High-volume loggers (messages, reactions, polls, interactions, Audit Log) | | ✓ | ✓ | ✓ | -| Advanced loggers (typing, raw Gateway, diagnostics) | | | ✓ | ✓ | +| Gateway diagnostics and server availability loggers | | | ✓ | ✓ | | Retention | 15 days | 30 days | 60 days | 90 days | | Events stored per day | 2,000 | 5,000 | 15,000 | 25,000 | | Log channels | 3 | 8 | 20 | 20 | | Priority support | | ✓ | ✓ | ✓ | | Custom bot name and banner in the server | | | | ✓ | -Customers paying for several servers get 15% off with 2 subscriptions, 20% with 3 and 25% with 5 or more. The full list of limits lives in [`packages/shared/src/plans.ts`](packages/shared/src/plans.ts). When a plan is lowered or expires, loggers outside it are switched off and settings above its limits are clamped; nothing is deleted. Online payments are not enabled yet: the pricing page asks people to get in touch, and plans, expiry dates and coupons are managed by hand. +The full list of limits lives in [`packages/shared/src/plans.ts`](packages/shared/src/plans.ts). When a plan is lowered or expires, loggers outside it are switched off and settings above its limits are clamped; nothing is deleted. + +**Payments.** Paid plans are sold only as **Discord Premium Apps guild subscriptions**, at the same prices as the pricing page, as the [Discord Developer Policy](https://support-dev.discord.com/hc/articles/8563934450327-Discord-Developer-Policy) requires. There are no coupons or multi-server discounts, because Discord could not offer the same price. The SKU IDs are set in the super console; every two minutes the API reads the application's active entitlements through the bot (a read-only REST call) and updates each server's plan: a purchase switches the plan on, a renewal extends it and an ended subscription brings the server back to Free. If the entitlements cannot be read, nothing changes. Until SKUs are configured the pricing page shows the plans as "coming soon on Discord". **Super console** (`SUPER_ADMIN_USER_ID` only, enforced by the API): -- connected servers with their plan (level, billing period, expiry, coupon, internal note), leave or block a server; +- connected servers with their plan (level, source — Discord subscription or manual —, expiry, internal note), leave or block a server; manual plans are free trials, partner or complimentary plans; - public contact details shown on the pricing page; -- coupons and discounts (percentage or fixed amount, plans, billing period, validity, redemptions, optional promotion on the pricing page); +- **Discord subscriptions**: the SKU ID of each plan and billing period, the last sync and a manual "sync now"; - the **status channel**: one server and channel, chosen among those the bot can see, that receives bot startup, errors, warnings, reached quotas and servers added, removed or rejected — these notices are no longer posted in individual servers; - installation blacklist and an audit log of every action, including automatic plan expiries. diff --git a/SECURITY.md b/SECURITY.md index 85112b8..80d422f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -6,13 +6,22 @@ Do not publish tokens, credentials, database dumps, sensitive logs, or working e Report vulnerabilities privately through GitHub: **[Report a vulnerability](https://github.com/haxurus/Sentinel/security/advisories/new)** (Security → Advisories). If you cannot use GitHub, write to haxurus@icloud.com. Please allow time for a fix before any public disclosure. -In case of a suspected compromise: - -1. temporarily disable automatic deployment by setting `ENABLE_VPS_DEPLOY=false`; -2. rotate affected Discord tokens, OAuth secrets, and deployment keys; -3. revoke compromised sessions/persisted credentials; -4. inspect host/container logs; -5. restore from a known-good release and backup. +## Incident response + +What the operator of the hosted instance does when data may have been accessed without authorisation, or a system that processes Discord data may be compromised. The [Discord Developer Terms of Service](https://support-dev.discord.com/hc/articles/8562894815383-Discord-Developer-Terms-of-Service) (section 5c) require Discord and affected users to be notified promptly; the GDPR adds the deadlines below. + +1. **Contain — immediately.** + - Disable automatic deployment (`ENABLE_VPS_DEPLOY=false`) and, if needed, stop the affected containers or roll back to a known-good release. + - Rotate whatever may be exposed: the Discord bot token and OAuth client secret (Developer Portal), the internal API key, database and Redis passwords, deployment SSH keys. Rotating `log_data_encryption_key` requires re-encrypting stored data: do it only if the key itself may have leaked. + - Revoke dashboard sessions (delete the `PanelSession` rows) so everyone signs in again. +2. **Assess — within 24 hours.** Use host, container, Nginx and database logs to establish what was accessed, which servers and users are affected, since when, and whether the encrypted fields or the key were exposed. Keep a written timeline and the evidence. +3. **Notify Discord — promptly, as soon as the incident is confirmed.** Through the [Developer Support form](https://support-dev.discord.com/hc/requests/new), with what happened, the affected data and servers, the actions taken and the remediation in progress. Send updates until it is closed. +4. **Notify users — without undue delay.** + - Administrators of the affected servers: an announcement on the [support server](https://discord.gg/DDBGWP5fMX) and on the website and, where possible, a message in each affected server's default log channel. + - If the breach is likely to result in a high risk for the people involved, inform them directly as well (GDPR art. 34). + - Personal data breach: notify the Garante per la protezione dei dati personali **within 72 hours** of becoming aware of it, unless it is unlikely to result in a risk (GDPR art. 33). Record every breach internally, even when it is not notified. +5. **Recover.** Restore from a known-good release and backup, re-enable deployment and watch the logs closely for the following days. +6. **Learn.** Write a short post-mortem (cause, impact, fixes) and track the fixes as issues; publish a GitHub security advisory when the cause is a vulnerability in Sentinel itself, so self-hosted instances can update. ## Secrets diff --git a/apps/api/src/discord-billing.ts b/apps/api/src/discord-billing.ts new file mode 100644 index 0000000..8b1c45b --- /dev/null +++ b/apps/api/src/discord-billing.ts @@ -0,0 +1,88 @@ +import { prisma } from '@sentinel/db'; +import { getEntitlements, type Entitlement } from './discord.js'; +import { decidePlan, parseSkus, plansFromEntitlements, skuIndex } from './discord-plans.js'; +import { applyPlanLimits, systemAudit } from './plan-service.js'; + +export { PAID_TIERS, SKU_PERIODS, parseSkus, storeLinks } from './discord-plans.js'; + +type Logger = { warn: (obj: object, msg: string) => void; error: (obj: object, msg: string) => void }; + +export type SyncResult = { at: string; ok: boolean; entitlements: number; changed: number; error?: string }; +let lastSync: SyncResult | null = null; +export const lastDiscordSync = () => lastSync; + +export async function configuredSkus() { + const config = await prisma.instanceConfig.findUnique({ where: { id: 1 }, select: { discordSkus: true } }); + return parseSkus(config?.discordSkus); +} + +/** + * Brings every server's plan in line with the Discord subscriptions. If the + * entitlements cannot be read nothing changes: an outage never downgrades. + */ +export async function syncDiscordPlans(log: Logger): Promise { + const skus = await configuredSkus(); + if (!skuIndex(skus).size) { + lastSync = { at: new Date().toISOString(), ok: true, entitlements: 0, changed: 0 }; + return lastSync; + } + + let entitlements: Entitlement[]; + try { + entitlements = await getEntitlements(); + } catch (error) { + log.warn({ err: error }, 'Unable to read Discord entitlements'); + lastSync = { at: new Date().toISOString(), ok: false, entitlements: 0, changed: 0, error: 'ENTITLEMENTS_UNAVAILABLE' }; + return lastSync; + } + + const now = new Date(); + const discordPlans = plansFromEntitlements(entitlements, skus, now); + const rows = await prisma.guildSettings.findMany({ + where: { OR: [{ planSource: 'DISCORD' }, { guildId: { in: [...discordPlans.keys()] } }] }, + select: { guildId: true, planTier: true, planExpiresAt: true, planSource: true, planEntitlementId: true, planBilling: true } + }); + + let changed = 0; + for (const row of rows) { + const decision = decidePlan(row, discordPlans.get(row.guildId), now); + if (decision.kind === 'keep') continue; + try { + if (decision.kind === 'discord') { + const { plan } = decision; + await prisma.guildSettings.update({ + where: { guildId: row.guildId }, + data: { + planTier: plan.tier, + planBilling: plan.billing, + planExpiresAt: plan.expiresAt, + planSource: 'DISCORD', + planEntitlementId: plan.entitlementId, + planCouponCode: null, + planUpdatedAt: now, + premiumEnabled: true + } + }); + const applied = decision.downgrade ? await applyPlanLimits(row.guildId, plan.tier, log) : null; + if (row.planSource !== 'DISCORD' || row.planTier !== plan.tier || row.planEntitlementId !== plan.entitlementId) { + await systemAudit('plan.discord_subscription', 'GUILD', row.guildId, { + previousTier: row.planTier, tier: plan.tier, billing: plan.billing, entitlementId: plan.entitlementId, applied + }); + } + } else { + await prisma.guildSettings.update({ + where: { guildId: row.guildId }, + data: { planTier: 'FREE', planBilling: null, planExpiresAt: null, planSource: 'MANUAL', planEntitlementId: null, planUpdatedAt: now, premiumEnabled: false } + }); + const applied = await applyPlanLimits(row.guildId, 'FREE', log); + await systemAudit('plan.discord_ended', 'GUILD', row.guildId, { previousTier: row.planTier, entitlementId: row.planEntitlementId, applied }); + } + changed += 1; + } catch (error) { + log.error({ err: error, guildId: row.guildId }, 'Unable to sync Discord plan'); + } + } + + lastSync = { at: now.toISOString(), ok: true, entitlements: entitlements.length, changed }; + return lastSync; +} diff --git a/apps/api/src/discord-plans.ts b/apps/api/src/discord-plans.ts new file mode 100644 index 0000000..87f2ca6 --- /dev/null +++ b/apps/api/src/discord-plans.ts @@ -0,0 +1,123 @@ +import { effectiveTier, tierLevel } from '@sentinel/shared'; +import type { Entitlement } from './discord.js'; + +/** + * Paid plans are sold only as Discord Premium Apps guild subscriptions. Each + * plan and billing period maps to a SKU configured in the super console; the + * API reads the application's entitlements through the bot and keeps every + * server's plan in line with them. + */ + +export const PAID_TIERS = ['TIER1', 'TIER2', 'TIER3'] as const; +export const SKU_PERIODS = ['MONTHLY', 'YEARLY'] as const; +export type PaidTier = (typeof PAID_TIERS)[number]; +export type SkuPeriod = (typeof SKU_PERIODS)[number]; +export type DiscordSkus = Partial>>>; + +/** + * A subscription keeps the plan for a few days after the end of the paid + * period, so a late renewal or a failed sync never switches loggers off. When + * the entitlement disappears, sync downgrades the server straight away. + */ +export const RENEWAL_GRACE_MS = 3 * 86_400_000; + +const SNOWFLAKE = /^\d{17,20}$/; + +/** Reads the stored JSON defensively: unknown keys and bad IDs are ignored. */ +export function parseSkus(value: unknown): DiscordSkus { + const result: DiscordSkus = {}; + if (!value || typeof value !== 'object' || Array.isArray(value)) return result; + for (const tier of PAID_TIERS) { + const periods = (value as Record)[tier]; + if (!periods || typeof periods !== 'object') continue; + for (const period of SKU_PERIODS) { + const id = (periods as Record)[period]; + if (typeof id === 'string' && SNOWFLAKE.test(id)) result[tier] = { ...result[tier], [period]: id }; + } + } + return result; +} + +export const skuIndex = (skus: DiscordSkus) => { + const index = new Map(); + for (const tier of PAID_TIERS) { + for (const period of SKU_PERIODS) { + const id = skus[tier]?.[period]; + if (id) index.set(id, { tier, period }); + } + } + return index; +}; + +/** Store pages of the configured SKUs, where people subscribe in Discord. */ +export function storeLinks(skus: DiscordSkus, applicationId: string) { + const links = {} as Record>; + for (const tier of PAID_TIERS) { + links[tier] = { MONTHLY: null, YEARLY: null }; + for (const period of SKU_PERIODS) { + const id = skus[tier]?.[period]; + if (id) links[tier][period] = `https://discord.com/application-directory/${applicationId}/store/${id}`; + } + } + const enabled = PAID_TIERS.some((tier) => SKU_PERIODS.some((period) => links[tier][period])); + return { enabled, store: `https://discord.com/application-directory/${applicationId}/store`, links }; +} + +export type DiscordPlan = { tier: PaidTier; billing: SkuPeriod; entitlementId: string; expiresAt: Date | null }; + +/** + * The best active subscription of each server: the highest plan, and for the + * same plan the one that lasts longer. Entitlements of unknown SKUs, user + * subscriptions and ended ones are ignored. + */ +export function plansFromEntitlements(entitlements: Entitlement[], skus: DiscordSkus, now = new Date()) { + const index = skuIndex(skus); + const best = new Map(); + for (const entitlement of entitlements) { + const sku = index.get(entitlement.skuId); + if (!sku || !entitlement.guildId) continue; + const endsAt = entitlement.endsAt ? new Date(entitlement.endsAt) : null; + if (endsAt && (Number.isNaN(endsAt.getTime()) || endsAt.getTime() <= now.getTime())) continue; + const candidate: DiscordPlan = { + tier: sku.tier, + billing: sku.period, + entitlementId: entitlement.id, + expiresAt: endsAt ? new Date(endsAt.getTime() + RENEWAL_GRACE_MS) : null + }; + const current = best.get(entitlement.guildId); + const longer = (a: Date | null, b: Date | null) => a === null ? b !== null : b !== null && a.getTime() > b.getTime(); + if (!current || tierLevel(candidate.tier) > tierLevel(current.tier) || + (candidate.tier === current.tier && longer(candidate.expiresAt, current.expiresAt))) { + best.set(entitlement.guildId, candidate); + } + } + return best; +} + +type StoredPlan = { planTier: string; planExpiresAt: Date | null; planSource: string; planEntitlementId: string | null; planBilling: string | null }; + +export type PlanDecision = + | { kind: 'keep' } + | { kind: 'discord'; plan: DiscordPlan; downgrade: boolean } + | { kind: 'ended' }; + +/** + * What sync does with one server. A Discord subscription replaces a manual + * plan unless the manual one is higher and still running (a gift or trial + * above what was bought); a Discord plan without an active subscription ends. + */ +export function decidePlan(stored: StoredPlan, discord: DiscordPlan | undefined, now = new Date()): PlanDecision { + const current = effectiveTier(stored, now); + if (discord) { + if (stored.planSource !== 'DISCORD' && tierLevel(current) > tierLevel(discord.tier)) return { kind: 'keep' }; + const same = stored.planSource === 'DISCORD' && + stored.planTier === discord.tier && + stored.planBilling === discord.billing && + stored.planEntitlementId === discord.entitlementId && + (stored.planExpiresAt?.getTime() ?? null) === (discord.expiresAt?.getTime() ?? null); + if (same) return { kind: 'keep' }; + return { kind: 'discord', plan: discord, downgrade: tierLevel(discord.tier) < tierLevel(current) }; + } + if (stored.planSource === 'DISCORD') return { kind: 'ended' }; + return { kind: 'keep' }; +} diff --git a/apps/api/src/discord.ts b/apps/api/src/discord.ts index dca8fa1..95a0aa1 100644 --- a/apps/api/src/discord.ts +++ b/apps/api/src/discord.ts @@ -70,6 +70,13 @@ export async function setBotBranding(guildId: string, profile: { nick?: string | return api<{ ok: true; guildId: string }>(`/guilds/${id(guildId)}/branding`, 'POST', profile, 15_000); } +export type Entitlement = { id: string; skuId: string; guildId: string | null; type: number; startsAt: string | null; endsAt: string | null }; + +/** Active Discord Premium Apps entitlements of the application. */ +export async function getEntitlements() { + return api('/entitlements', 'GET', undefined, 15_000); +} + export async function sendStatusTest() { return api<{ ok: true } | { ok: false; error: string }>('/status/test', 'POST', {}, 10_000); } diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 732f8a3..4300afa 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -5,7 +5,7 @@ import helmet from '@fastify/helmet'; import rateLimit from '@fastify/rate-limit'; import { z } from 'zod'; import { prisma } from '@sentinel/db'; -import { EVENT_CATALOG, SETTINGS_TIER, tierAllows } from '@sentinel/shared'; +import { EVENT_CATALOG, tierAllows } from '@sentinel/shared'; import { config } from './config.js'; import { createSession, destroySession, isSuperAdminUserId, randomToken, requireGuild, requireSession, requireSuperAdmin, resolveGuildAccess, type OAuthGuild } from './auth.js'; import { getBotGuilds, getGuildResources, leaveBotGuild } from './discord.js'; @@ -15,6 +15,7 @@ import { unprotectJson } from './security.js'; import { exportJsonChunks, findForeignReference, isValidTimeZone, rateLimitKey } from './helpers.js'; import { planOverview, registerPlanRoutes } from './plan-routes.js'; import { guildTier, sweepExpiredPlans } from './plan-service.js'; +import { syncDiscordPlans } from './discord-billing.js'; import { filterEntryCount, logChannelSet, publicGuildSettings, requiredTierForKey } from './plan-rules.js'; import { Readable } from 'node:stream'; @@ -94,6 +95,12 @@ const runPlanSweep = () => sweepExpiredPlans(app.log).catch((error) => app.log.e void runPlanSweep(); setInterval(() => void runPlanSweep(), 10 * 60 * 1000).unref(); +// Discord subscriptions: new purchases, renewals and cancellations reach the +// plans within a couple of minutes. The first run waits for the bot to log in. +const runDiscordSync = () => syncDiscordPlans(app.log).catch((error) => app.log.error({ err: error }, 'Discord plan sync failed')); +setTimeout(() => void runDiscordSync(), 30_000).unref(); +setInterval(() => void runDiscordSync(), 2 * 60 * 1000).unref(); + app.get('/health/live', async () => ({ ok: true, service: 'api' })); app.get('/health', async (_request, reply) => { try { @@ -247,9 +254,8 @@ app.get('/api/super/overview', async (request, reply) => { audit: audit.map((row) => ({ ...row, details: unprotectJson(row.details) })), config: beta.config, statusChannel: beta.statusChannel, - coupons: beta.coupons, planCatalog: beta.planCatalog, - multiSubscriptionDiscounts: beta.multiSubscriptionDiscounts + discord: beta.discord }; }); @@ -364,9 +370,7 @@ const settingsSchema = z.object({ embedColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), embedFooter: z.string().trim().max(200).optional(), messageSnapshotEnabled: z.boolean().optional(), - storeMessageContent: z.boolean().optional(), - rawGatewayEnabled: z.boolean().optional(), - typingLoggingEnabled: z.boolean().optional() + storeMessageContent: z.boolean().optional() }); const routeSchema = z.object({ @@ -430,10 +434,7 @@ app.put('/api/guilds/:guildId/settings', async (request, reply) => { const parsed = settingsSchema.safeParse(request.body); if (!parsed.success) return reply.code(400).send({ error: 'INVALID_BODY', details: parsed.error.flatten() }); - const { tier, limits } = await guildTier(guildId); - for (const [key, required] of Object.entries(SETTINGS_TIER) as Array<[keyof typeof SETTINGS_TIER, typeof SETTINGS_TIER[keyof typeof SETTINGS_TIER]]>) { - if (parsed.data[key] === true && !tierAllows(tier, required)) return reply.code(403).send({ error: 'PLAN_REQUIRED', required }); - } + const { limits } = await guildTier(guildId); if (parsed.data.defaultRetentionDays !== undefined && parsed.data.defaultRetentionDays > limits.retentionDays) { return reply.code(403).send({ error: 'PLAN_LIMIT', limit: 'retentionDays', max: limits.retentionDays }); } diff --git a/apps/api/src/plan-routes.ts b/apps/api/src/plan-routes.ts index 42e37f4..abf6074 100644 --- a/apps/api/src/plan-routes.ts +++ b/apps/api/src/plan-routes.ts @@ -1,21 +1,15 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { z } from 'zod'; import { prisma } from '@sentinel/db'; -import { - BILLING_PERIODS, - MULTI_SUBSCRIPTION_DISCOUNTS, - EVENT_CATALOG, - PLANS, - PLAN_TIERS, - normalizeCouponCode, - requiredTierForEvent -} from '@sentinel/shared'; +import { EVENT_CATALOG, PLANS, PLAN_TIERS, requiredTierForEvent } from '@sentinel/shared'; import { requireGuild, requireSession, requireSuperAdmin } from './auth.js'; import { panelAudit } from './audit.js'; import { superAdminAudit } from './super-audit.js'; import { getGuildResources, refreshStatusChannel, sendStatusTest, setBotBranding, type BotApiError } from './discord.js'; import { findForeignReference } from './helpers.js'; import { PlanError, changeGuildPlan, guildPlanUsage, guildTier } from './plan-service.js'; +import { config as appConfig } from './config.js'; +import { PAID_TIERS, SKU_PERIODS, lastDiscordSync, parseSkus, storeLinks, syncDiscordPlans } from './discord-billing.js'; const snowflake = z.string().regex(/^\d{17,20}$/); const nullableText = (max: number) => z.string().trim().max(max).nullable().optional() @@ -32,11 +26,8 @@ const eventTiers = () => ({ TIER2: EVENT_CATALOG.filter((event) => requiredTierForEvent(event) === 'TIER2').map((event) => event.key) }); -const couponIsLive = (coupon: { active: boolean; validFrom: Date | null; validUntil: Date | null; maxRedemptions: number | null; redemptions: number }, now = new Date()) => - coupon.active && - (!coupon.validFrom || coupon.validFrom <= now) && - (!coupon.validUntil || coupon.validUntil >= now) && - (coupon.maxRedemptions === null || coupon.redemptions < coupon.maxRedemptions); +/** Where each paid plan can be bought in Discord (null until configured). */ +const discordStore = (skus: unknown) => storeLinks(parseSkus(skus), appConfig.clientId); const sendPlanError = (reply: FastifyReply, error: unknown) => { if (error instanceof PlanError) return reply.code(error.status).send({ error: error.code }); @@ -44,8 +35,8 @@ const sendPlanError = (reply: FastifyReply, error: unknown) => { }; /** - * Public plan catalogue, coupons, plan management, public contacts and the - * instance status channel. + * Public plan catalogue, Discord subscriptions, plan management, public + * contacts and the instance status channel. */ export async function registerPlanRoutes(app: FastifyInstance) { // ---------------------------------------------------------------- public @@ -60,24 +51,12 @@ export async function registerPlanRoutes(app: FastifyInstance) { }); const loadPublicInfo = async () => { - const [config, coupons] = await Promise.all([ - instanceConfig(), - prisma.coupon.findMany({ where: { public: true, active: true }, orderBy: { createdAt: 'desc' }, take: 10 }) - ]); + const config = await instanceConfig(); return { contacts: { email: config.contactEmail, discord: config.contactDiscord, url: config.contactUrl }, plans: publicPlans(), - multiSubscriptionDiscounts: MULTI_SUBSCRIPTION_DISCOUNTS, eventTiers: eventTiers(), - promotions: coupons.filter((coupon) => couponIsLive(coupon)).map((coupon) => ({ - code: coupon.code, - description: coupon.description, - percentOff: coupon.percentOff, - amountOffCents: coupon.amountOffCents, - tiers: coupon.tiers, - billing: coupon.billing, - validUntil: coupon.validUntil - })) + discord: discordStore(config.discordSkus) }; }; @@ -102,58 +81,38 @@ export async function registerPlanRoutes(app: FastifyInstance) { return config; }); - // -------------------------------------------------------- super: coupons - const couponBody = z.object({ - description: nullableText(200), - percentOff: z.number().int().min(1).max(100).nullable().optional(), - amountOffCents: z.number().int().min(1).max(100_000).nullable().optional(), - tiers: z.array(z.enum(['TIER1', 'TIER2', 'TIER3'])).max(3).optional(), - billing: z.enum(['ANY', 'MONTHLY', 'YEARLY']).optional(), - maxRedemptions: z.number().int().min(1).max(100_000).nullable().optional(), - validFrom: z.coerce.date().nullable().optional(), - validUntil: z.coerce.date().nullable().optional(), - active: z.boolean().optional(), - public: z.boolean().optional() - }); - const discountValid = (value: { percentOff?: number | null; amountOffCents?: number | null }) => - Boolean(value.percentOff) !== Boolean(value.amountOffCents); - - app.post('/api/super/coupons', async (request, reply) => { + // ------------------------------------------ super: Discord subscriptions + app.put('/api/super/discord-skus', async (request, reply) => { const session = await requireSuperAdmin(request, reply); if (!session) return; - const parsed = couponBody.extend({ code: z.string().trim().regex(/^[A-Za-z0-9_-]{3,32}$/) }).safeParse(request.body); - if (!parsed.success || !discountValid(parsed.data)) return reply.code(400).send({ error: 'INVALID_COUPON' }); - const code = normalizeCouponCode(parsed.data.code); - if (await prisma.coupon.findUnique({ where: { code }, select: { id: true } })) return reply.code(409).send({ error: 'COUPON_EXISTS' }); - const coupon = await prisma.coupon.create({ data: { ...parsed.data, code, createdByUserId: session.userId } }); - await superAdminAudit(request, session, 'coupon.create', 'COUPON', code, { percentOff: coupon.percentOff, amountOffCents: coupon.amountOffCents }); - return coupon; - }); - - app.put('/api/super/coupons/:id', async (request, reply) => { - const session = await requireSuperAdmin(request, reply); - if (!session) return; - const { id } = request.params as { id: string }; - const parsed = couponBody.safeParse(request.body); - if (!parsed.success) return reply.code(400).send({ error: 'INVALID_COUPON' }); - const current = await prisma.coupon.findUnique({ where: { id } }); - if (!current) return reply.code(404).send({ error: 'NOT_FOUND' }); - const next = { percentOff: current.percentOff, amountOffCents: current.amountOffCents, ...parsed.data }; - if (!discountValid(next)) return reply.code(400).send({ error: 'INVALID_COUPON' }); - const coupon = await prisma.coupon.update({ where: { id }, data: parsed.data }); - await superAdminAudit(request, session, 'coupon.update', 'COUPON', coupon.code, parsed.data); - return coupon; + const sku = z.union([z.literal(''), snowflake]).nullable().optional(); + const period = z.object({ MONTHLY: sku, YEARLY: sku }).partial(); + const parsed = z.object({ TIER1: period, TIER2: period, TIER3: period }).partial().safeParse(request.body); + if (!parsed.success) return reply.code(400).send({ error: 'INVALID_SKU' }); + const skus: Record> = {}; + const seen = new Set(); + for (const tier of PAID_TIERS) { + for (const billing of SKU_PERIODS) { + const id = parsed.data[tier]?.[billing]; + if (!id) continue; + if (seen.has(id)) return reply.code(400).send({ error: 'DUPLICATE_SKU' }); + seen.add(id); + skus[tier] = { ...skus[tier], [billing]: id }; + } + } + await prisma.instanceConfig.upsert({ where: { id: 1 }, update: { discordSkus: skus }, create: { id: 1, discordSkus: skus } }); + publicInfoCache = null; + await superAdminAudit(request, session, 'discord.skus', null, null, skus); + return { skus, sync: await syncDiscordPlans(request.log) }; }); - app.delete('/api/super/coupons/:id', async (request, reply) => { + app.post('/api/super/discord-sync', async (request, reply) => { const session = await requireSuperAdmin(request, reply); if (!session) return; - const { id } = request.params as { id: string }; - const coupon = await prisma.coupon.findUnique({ where: { id } }); - if (!coupon) return reply.code(404).send({ error: 'NOT_FOUND' }); - await prisma.coupon.delete({ where: { id } }); - await superAdminAudit(request, session, 'coupon.delete', 'COUPON', coupon.code); - return { ok: true }; + const result = await syncDiscordPlans(request.log); + await superAdminAudit(request, session, 'discord.sync', null, null, result); + if (!result.ok) return reply.code(502).send({ error: 'DISCORD_SYNC_FAILED' }); + return result; }); // ---------------------------------------------------------- super: plans @@ -163,17 +122,13 @@ export async function registerPlanRoutes(app: FastifyInstance) { const { guildId } = request.params as { guildId: string }; const parsed = z.object({ tier: z.enum(PLAN_TIERS), - billing: z.enum(BILLING_PERIODS).nullable().optional(), expiresAt: z.coerce.date().nullable().optional(), - couponCode: z.string().trim().max(32).nullable().optional(), note: nullableText(500) }).safeParse(request.body); if (!parsed.success) return reply.code(400).send({ error: 'INVALID_BODY' }); const change = { tier: parsed.data.tier, - billing: parsed.data.billing ?? null, expiresAt: parsed.data.expiresAt ?? null, - couponCode: parsed.data.couponCode || null, note: parsed.data.note }; if (change.tier !== 'FREE' && change.expiresAt && change.expiresAt.getTime() <= Date.now()) { @@ -251,9 +206,9 @@ export async function registerPlanRoutes(app: FastifyInstance) { const { guildId } = request.params as { guildId: string }; const session = await requireGuild(request, reply, guildId); if (!session) return; - const usage = await guildPlanUsage(guildId); + const [usage, config] = await Promise.all([guildPlanUsage(guildId), instanceConfig()]); if (!usage) return reply.code(404).send({ error: 'BOT_NOT_IN_GUILD' }); - return { ...usage, eventTiers: eventTiers() }; + return { ...usage, eventTiers: eventTiers(), discord: discordStore(config.discordSkus) }; }); // Banners are resized in the browser to stay under the edge proxy's 64 KB @@ -296,16 +251,22 @@ export async function registerPlanRoutes(app: FastifyInstance) { }); } -/** Super console overview additions: plans, coupons, settings. */ +/** Super console overview additions: plans, Discord subscriptions, settings. */ export async function planOverview(botGuildIds: string[]) { - const [config, coupons, statusChannel, plans] = await Promise.all([ + const [config, statusChannel, plans] = await Promise.all([ instanceConfig(), - prisma.coupon.findMany({ orderBy: { createdAt: 'desc' } }), prisma.statusChannel.findUnique({ where: { id: 1 } }), prisma.guildSettings.findMany({ where: { guildId: { in: botGuildIds } }, - select: { guildId: true, planTier: true, planBilling: true, planExpiresAt: true, planCouponCode: true, planNote: true, planUpdatedAt: true } + select: { guildId: true, planTier: true, planBilling: true, planExpiresAt: true, planSource: true, planNote: true, planUpdatedAt: true } }) ]); - return { config, statusChannel, coupons, plans, planCatalog: publicPlans(), multiSubscriptionDiscounts: MULTI_SUBSCRIPTION_DISCOUNTS }; + const { discordSkus, ...settings } = config; + return { + config: settings, + statusChannel, + plans, + planCatalog: publicPlans(), + discord: { skus: parseSkus(discordSkus), applicationId: appConfig.clientId, lastSync: lastDiscordSync(), ...discordStore(discordSkus) } + }; } diff --git a/apps/api/src/plan-rules.ts b/apps/api/src/plan-rules.ts index 8675a09..7798972 100644 --- a/apps/api/src/plan-rules.ts +++ b/apps/api/src/plan-rules.ts @@ -1,4 +1,4 @@ -import { EVENT_CATALOG, SETTINGS_TIER, planLimits, requiredTierForEvent, tierAllows, type PlanTier } from '@sentinel/shared'; +import { EVENT_CATALOG, planLimits, requiredTierForEvent, tierAllows, type PlanTier } from '@sentinel/shared'; export type PlanRoute = { eventKey: string; @@ -15,8 +15,6 @@ export type PlanRoute = { export type PlanSettings = { defaultLogChannelId: string | null; defaultRetentionDays: number; - typingLoggingEnabled: boolean; - rawGatewayEnabled: boolean; }; const eventByKey = new Map(EVENT_CATALOG.map((event) => [event.key, event])); @@ -60,9 +58,6 @@ export function planAdjustments(tier: PlanTier, settings: PlanSettings, routes: const limits = planLimits(tier); const settingsPatch: Partial = {}; if (settings.defaultRetentionDays > limits.retentionDays) settingsPatch.defaultRetentionDays = limits.retentionDays; - for (const [key, required] of Object.entries(SETTINGS_TIER) as Array<[keyof typeof SETTINGS_TIER, PlanTier]>) { - if (settings[key] && !tierAllows(tier, required)) settingsPatch[key] = false; - } const allowedChannels = new Set(); if (settings.defaultLogChannelId) allowedChannels.add(settings.defaultLogChannelId); diff --git a/apps/api/src/plan-service.ts b/apps/api/src/plan-service.ts index b4dd971..e66ae33 100644 --- a/apps/api/src/plan-service.ts +++ b/apps/api/src/plan-service.ts @@ -1,13 +1,5 @@ import { prisma } from '@sentinel/db'; -import { - PLANS, - couponProblem, - effectiveTier, - normalizeCouponCode, - planLimits, - type BillingPeriod, - type PlanTier -} from '@sentinel/shared'; +import { PLANS, effectiveTier, planLimits, type PlanTier } from '@sentinel/shared'; import { setBotBranding } from './discord.js'; import { logChannelSet, planAdjustments } from './plan-rules.js'; import { protectJson } from './security.js'; @@ -60,53 +52,41 @@ export async function applyPlanLimits(guildId: string, tier: PlanTier, log: Logg return { routes: adjustments.routes.length, settings: Object.keys(adjustments.settings).length, bindingsRemoved: extraBindings.length }; } +/** + * A plan set by hand from the super console. Paid plans are sold only through + * Discord, so a manual plan is always free of charge: a trial, a partner plan + * or a gift. + */ export type PlanChange = { tier: PlanTier; - billing: BillingPeriod | null; expiresAt: Date | null; - couponCode: string | null; note: string | null; }; export async function changeGuildPlan(guildId: string, change: PlanChange, log: Logger) { - const current = await prisma.guildSettings.findUnique({ - where: { guildId }, - select: { planTier: true, planCouponCode: true } - }); + const current = await prisma.guildSettings.findUnique({ where: { guildId }, select: { planTier: true } }); if (!current) throw new PlanError('GUILD_NOT_FOUND', 404); const free = change.tier === 'FREE'; - const couponCode = free || !change.couponCode ? null : normalizeCouponCode(change.couponCode); - // A coupon counts as redeemed once per server: re-saving the same plan - // with the same code does not consume it again. - const newCoupon = couponCode && couponCode !== current.planCouponCode; - if (newCoupon) { - const coupon = await prisma.coupon.findUnique({ where: { code: couponCode! } }); - if (!coupon) throw new PlanError('COUPON_NOT_FOUND', 404); - const problem = couponProblem(coupon, change.tier, change.billing); - if (problem) throw new PlanError(problem); - } - - await prisma.$transaction([ - prisma.guildSettings.update({ - where: { guildId }, - data: { - planTier: change.tier, - planBilling: free ? null : change.billing, - planExpiresAt: free ? null : change.expiresAt, - planCouponCode: couponCode, - planNote: change.note, - planUpdatedAt: new Date(), - // Kept in sync so the previous release still behaves after a rollback. - premiumEnabled: !free - } - }), - ...(newCoupon ? [prisma.coupon.update({ where: { code: couponCode! }, data: { redemptions: { increment: 1 } } })] : []) - ]); + await prisma.guildSettings.update({ + where: { guildId }, + data: { + planTier: change.tier, + planBilling: free ? null : 'GIFT', + planExpiresAt: free ? null : change.expiresAt, + planSource: 'MANUAL', + planEntitlementId: null, + planCouponCode: null, + planNote: change.note, + planUpdatedAt: new Date(), + // Kept in sync so the previous release still behaves after a rollback. + premiumEnabled: !free + } + }); const tier = effectiveTier({ planTier: change.tier, planExpiresAt: free ? null : change.expiresAt }); const applied = await applyPlanLimits(guildId, tier, log); - return { previousTier: current.planTier, tier: change.tier, effectiveTier: tier, applied, couponRedeemed: Boolean(newCoupon) }; + return { previousTier: current.planTier, tier: change.tier, effectiveTier: tier, applied }; } export async function systemAudit(action: string, subjectType: string | null, subjectId: string | null, details: Record = {}) { @@ -132,7 +112,7 @@ export async function sweepExpiredPlans(log: Logger) { try { await prisma.guildSettings.update({ where: { guildId: row.guildId }, - data: { planTier: 'FREE', planBilling: null, planCouponCode: null, premiumEnabled: false, planUpdatedAt: new Date() } + data: { planTier: 'FREE', planBilling: null, planCouponCode: null, planSource: 'MANUAL', planEntitlementId: null, premiumEnabled: false, planUpdatedAt: new Date() } }); const applied = await applyPlanLimits(row.guildId, 'FREE', log); await systemAudit('plan.expired', 'GUILD', row.guildId, { previousTier: row.planTier, expiredAt: row.planExpiresAt, applied }); @@ -147,7 +127,7 @@ export async function guildPlanUsage(guildId: string) { const settings = await prisma.guildSettings.findUnique({ where: { guildId }, select: { - planTier: true, planExpiresAt: true, planBilling: true, defaultLogChannelId: true, + planTier: true, planExpiresAt: true, planBilling: true, planSource: true, defaultLogChannelId: true, brandNickname: true, brandBannerSet: true } }); @@ -165,6 +145,7 @@ export async function guildPlanUsage(guildId: string) { effectiveTier: tier, expiresAt: settings.planExpiresAt, billing: settings.planBilling, + source: settings.planSource, plan: PLANS[tier], usage: { eventsToday, diff --git a/apps/api/test/discord-plans.test.ts b/apps/api/test/discord-plans.test.ts new file mode 100644 index 0000000..2095e61 --- /dev/null +++ b/apps/api/test/discord-plans.test.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { RENEWAL_GRACE_MS, decidePlan, parseSkus, plansFromEntitlements, storeLinks } from '../src/discord-plans.ts'; + +const now = new Date('2026-10-13T12:00:00Z'); +const skus = parseSkus({ + TIER1: { MONTHLY: '100000000000000001', YEARLY: '100000000000000002' }, + TIER2: { MONTHLY: '100000000000000003' }, + TIER3: { MONTHLY: 'not-a-snowflake' }, + GOLD: { MONTHLY: '100000000000000009' } +}); +const entitlement = (id: string, skuId: string, guildId: string | null, endsAt: string | null = '2026-11-13T12:00:00Z') => + ({ id, skuId, guildId, type: 8, startsAt: '2026-10-13T12:00:00Z', endsAt }); + +test('SKU configuration ignores unknown plans and invalid IDs', () => { + assert.deepEqual(skus, { + TIER1: { MONTHLY: '100000000000000001', YEARLY: '100000000000000002' }, + TIER2: { MONTHLY: '100000000000000003' } + }); + assert.deepEqual(parseSkus(null), {}); + assert.deepEqual(parseSkus(['x']), {}); +}); + +test('store links exist only for configured SKUs', () => { + const store = storeLinks(skus, '900000000000000000'); + assert.equal(store.enabled, true); + assert.equal(store.links.TIER1.YEARLY, 'https://discord.com/application-directory/900000000000000000/store/100000000000000002'); + assert.equal(store.links.TIER2.YEARLY, null); + assert.equal(store.links.TIER3.MONTHLY, null); + assert.equal(storeLinks({}, '900000000000000000').enabled, false); +}); + +test('each server gets its best active guild subscription', () => { + const plans = plansFromEntitlements([ + entitlement('1', '100000000000000001', '500000000000000001'), + entitlement('2', '100000000000000003', '500000000000000001'), + entitlement('3', '100000000000000001', '500000000000000002', '2026-10-01T00:00:00Z'), + entitlement('4', '100000000000000001', null), + entitlement('5', '999999999999999999', '500000000000000003'), + entitlement('6', '100000000000000002', '500000000000000004', null) + ], skus, now); + assert.deepEqual([...plans.keys()].sort(), ['500000000000000001', '500000000000000004']); + const pro = plans.get('500000000000000001')!; + assert.equal(pro.tier, 'TIER2'); + assert.equal(pro.entitlementId, '2'); + assert.equal(pro.expiresAt!.getTime(), new Date('2026-11-13T12:00:00Z').getTime() + RENEWAL_GRACE_MS); + assert.deepEqual(plans.get('500000000000000004'), { tier: 'TIER1', billing: 'YEARLY', entitlementId: '6', expiresAt: null }); +}); + +test('sync decisions', () => { + const discord = { tier: 'TIER2' as const, billing: 'MONTHLY' as const, entitlementId: '2', expiresAt: new Date('2026-11-16T12:00:00Z') }; + const free = { planTier: 'FREE', planExpiresAt: null, planSource: 'MANUAL', planEntitlementId: null, planBilling: null }; + + assert.deepEqual(decidePlan(free, discord, now), { kind: 'discord', plan: discord, downgrade: false }); + assert.deepEqual(decidePlan(free, undefined, now), { kind: 'keep' }); + + const synced = { planTier: 'TIER2', planExpiresAt: discord.expiresAt, planSource: 'DISCORD', planEntitlementId: '2', planBilling: 'MONTHLY' }; + assert.deepEqual(decidePlan(synced, discord, now), { kind: 'keep' }); + assert.deepEqual(decidePlan(synced, undefined, now), { kind: 'ended' }); + const renewed = { ...discord, expiresAt: new Date('2026-12-16T12:00:00Z') }; + assert.equal(decidePlan(synced, renewed, now).kind, 'discord'); + const plus = { ...discord, tier: 'TIER1' as const, entitlementId: '7' }; + assert.deepEqual(decidePlan(synced, plus, now), { kind: 'discord', plan: plus, downgrade: true }); + + // A running manual gift above the subscription wins; an expired one does not. + const gift = { planTier: 'TIER3', planExpiresAt: new Date('2026-12-01T00:00:00Z'), planSource: 'MANUAL', planEntitlementId: null, planBilling: 'GIFT' }; + assert.deepEqual(decidePlan(gift, discord, now), { kind: 'keep' }); + assert.equal(decidePlan({ ...gift, planExpiresAt: new Date('2026-10-01T00:00:00Z') }, discord, now).kind, 'discord'); + assert.deepEqual(decidePlan(gift, undefined, now), { kind: 'keep' }); +}); diff --git a/apps/api/test/plans.test.ts b/apps/api/test/plans.test.ts index 6d1a926..2fed32c 100644 --- a/apps/api/test/plans.test.ts +++ b/apps/api/test/plans.test.ts @@ -3,14 +3,10 @@ import { test } from 'node:test'; import { EVENT_CATALOG, PLANS, - couponProblem, - discountedPriceCents, effectiveTier, - multiSubscriptionPercent, planLimits, requiredTierForEvent, - tierAllows, - type CouponLike + tierAllows } from '@sentinel/shared'; import { filterEntryCount, logChannelSet, planAdjustments, publicGuildSettings, trimFilters, type PlanRoute } from '../src/plan-rules.ts'; @@ -29,9 +25,7 @@ const route = (eventKey: string, patch: Partial = {}): PlanRoute => ( const settings = { defaultLogChannelId: '100000000000000001', - defaultRetentionDays: 30, - typingLoggingEnabled: false, - rawGatewayEnabled: false + defaultRetentionDays: 30 }; test('every high-volume logger needs a paid plan and limits grow with the tier', () => { @@ -41,7 +35,8 @@ test('every high-volume logger needs a paid plan and limits grow with the tier', else assert.equal(required, 'FREE', event.key); } assert.equal(requiredTierForEvent({ key: 'message.create', noisy: true }), 'TIER1'); - assert.equal(requiredTierForEvent({ key: 'typing.start', noisy: true }), 'TIER2'); + assert.equal(requiredTierForEvent({ key: 'voice.server_update', noisy: true }), 'TIER2'); + assert.ok(!EVENT_CATALOG.some((event) => ['typing.start', 'raw.gateway'].includes(event.key)), 'typing and raw Gateway are never logged'); assert.ok(!EVENT_CATALOG.some((event) => event.key === 'presence.update'), 'presence is never logged'); assert.ok(!EVENT_CATALOG.some((event) => event.key.startsWith('system.')), 'bot status is instance-wide, not per server'); @@ -55,13 +50,7 @@ test('every high-volume logger needs a paid plan and limits grow with the tier', assert.ok(tierAllows('TIER3', 'TIER2') && !tierAllows('TIER1', 'TIER2')); }); -test('multi-subscription discounts', () => { - assert.equal(multiSubscriptionPercent(1), 0); - assert.equal(multiSubscriptionPercent(2), 15); - assert.equal(multiSubscriptionPercent(3), 20); - assert.equal(multiSubscriptionPercent(4), 20); - assert.equal(multiSubscriptionPercent(5), 25); - assert.equal(multiSubscriptionPercent(12), 25); +test('plan prices and limits', () => { assert.equal(PLANS.TIER1.monthlyCents, 199); assert.equal(PLANS.TIER3.limits.eventsPerDay, 25_000); }); @@ -74,24 +63,10 @@ test('expired plans fall back to Free', () => { assert.equal(effectiveTier({ planTier: 'GOLD', planExpiresAt: null }, now), 'FREE'); }); -test('coupons validate scope, dates and redemptions', () => { - const now = new Date('2026-10-10T12:00:00Z'); - const coupon: CouponLike = { code: 'BETA', percentOff: 50, amountOffCents: null, tiers: ['TIER1'], billing: 'YEARLY', maxRedemptions: 2, redemptions: 0, validFrom: null, validUntil: '2026-12-31T00:00:00Z', active: true }; - assert.equal(couponProblem(coupon, 'TIER1', 'YEARLY', now), null); - assert.equal(couponProblem(coupon, 'TIER2', 'YEARLY', now), 'COUPON_WRONG_TIER'); - assert.equal(couponProblem(coupon, 'TIER1', 'MONTHLY', now), 'COUPON_WRONG_BILLING'); - assert.equal(couponProblem({ ...coupon, redemptions: 2 }, 'TIER1', 'YEARLY', now), 'COUPON_EXHAUSTED'); - assert.equal(couponProblem({ ...coupon, validUntil: '2026-01-01T00:00:00Z' }, 'TIER1', 'YEARLY', now), 'COUPON_EXPIRED'); - assert.equal(couponProblem({ ...coupon, active: false }, 'TIER1', 'YEARLY', now), 'COUPON_INACTIVE'); - assert.equal(couponProblem(coupon, 'FREE', null, now), 'COUPON_FREE_PLAN'); - assert.equal(discountedPriceCents(2000, coupon), 1000); - assert.equal(discountedPriceCents(200, { percentOff: null, amountOffCents: 500 }), 0); -}); - test('a downgrade switches off loggers above the plan and clamps limits', () => { const routes = [ route('message.create'), - route('typing.start'), + route('voice.server_update'), route('moderation.ban', { retentionDays: 365, mentionRoleIds: ['1', '2', '3'] }), route('message.delete', { ignoredUserIds: Array.from({ length: 12 }, (_, i) => `u${i}`), ignoredRoleIds: ['r1', 'r2', 'r3', 'r4', 'r5'] }), route('channel.create', { destinationChannelId: '200000000000000001' }), @@ -99,12 +74,12 @@ test('a downgrade switches off loggers above the plan and clamps limits', () => route('channel.update', { destinationChannelId: '200000000000000003' }), route('role.create', { destinationChannelId: '200000000000000001' }) ]; - const result = planAdjustments('FREE', { ...settings, defaultRetentionDays: 200, typingLoggingEnabled: true }, routes); - assert.deepEqual(result.settings, { defaultRetentionDays: 15, typingLoggingEnabled: false }); + const result = planAdjustments('FREE', { ...settings, defaultRetentionDays: 200 }, routes); + assert.deepEqual(result.settings, { defaultRetentionDays: 15 }); const patch = (key: string) => result.routes.find((item) => item.eventKey === key)?.data; assert.deepEqual(patch('message.create'), { captureEnabled: false, enabled: false }); - assert.deepEqual(patch('typing.start'), { captureEnabled: false, enabled: false }); + assert.deepEqual(patch('voice.server_update'), { captureEnabled: false, enabled: false }); assert.deepEqual(patch('moderation.ban'), { retentionDays: 15, mentionRoleIds: ['1'] }); assert.equal(filterEntryCount(patch('message.delete') as PlanRoute), 15); // Free allows 3 channels: the default one plus the first two distinct ones. @@ -113,7 +88,7 @@ test('a downgrade switches off loggers above the plan and clamps limits', () => assert.deepEqual(patch('channel.update'), { destinationChannelId: null }); assert.equal(patch('role.create'), undefined); - assert.deepEqual(planAdjustments('TIER2', settings, [route('typing.start')]).routes, []); + assert.deepEqual(planAdjustments('TIER2', settings, [route('voice.server_update')]).routes, []); }); test('plan helpers', () => { diff --git a/apps/bot/src/capability-policy-check.ts b/apps/bot/src/capability-policy-check.ts index 08e7593..a325bf4 100644 --- a/apps/bot/src/capability-policy-check.ts +++ b/apps/bot/src/capability-policy-check.ts @@ -7,6 +7,7 @@ const allowed: Array<[string, string]> = [ ['GET', '/guilds/123456789012345678/channels'], ['GET', '/guilds/123456789012345678/audit-logs'], ['GET', '/guilds/123456789012345678/members/234567890123456789'], + ['GET', '/applications/123456789012345678/entitlements'], ['POST', '/channels/123456789012345678/messages'], ['DELETE', '/users/@me/guilds/123456789012345678'], ['PATCH', '/guilds/123456789012345678/members/@me'] diff --git a/apps/bot/src/embed-format.ts b/apps/bot/src/embed-format.ts index 859a6aa..aa54049 100644 --- a/apps/bot/src/embed-format.ts +++ b/apps/bot/src/embed-format.ts @@ -39,7 +39,7 @@ export const normalizeColor = (value?: string | null) => { const USER_TARGET_EVENTS = new Set([ 'member.join', 'member.leave', 'member.update', 'moderation.kick', 'moderation.ban', 'moderation.unban', 'voice.join', 'voice.leave', 'voice.move', 'voice.state', 'thread.member_update', 'user.update', - 'typing.start', 'message.delete' + 'message.delete' ]); const CHANNEL_TARGET_EVENTS = new Set([ diff --git a/apps/bot/src/embed-i18n.ts b/apps/bot/src/embed-i18n.ts index 22dab94..c65a448 100644 --- a/apps/bot/src/embed-i18n.ts +++ b/apps/bot/src/embed-i18n.ts @@ -71,8 +71,6 @@ const EVENT_EN: Record = { 'application.permissions_update': { label: 'Application command permissions', description: 'Permissions for a command or application entity change in the server.' }, 'user.update': { label: 'User profile updated', description: 'Username, avatar or other global user data changes.' }, 'audit.entry': { label: 'Discord Audit Log', description: 'Discord creates a new Audit Log entry.' }, - 'typing.start': { label: 'Typing', description: 'A user starts typing.' }, - 'raw.gateway': { label: 'Raw Gateway', description: 'Raw copy of non-normalized Gateway events. Use only for debugging.' }, 'system.ready': { label: 'Bot ready', description: 'The bot completes its connection to Discord.' }, 'system.error': { label: 'Bot error', description: 'Bot runtime error.' }, 'system.warn': { label: 'Bot warning', description: 'Warning emitted by the Discord client.' } diff --git a/apps/bot/src/handlers.ts b/apps/bot/src/handlers.ts index 557d09f..d20762f 100644 --- a/apps/bot/src/handlers.ts +++ b/apps/bot/src/handlers.ts @@ -10,9 +10,9 @@ import { type PartialMessage } from 'discord.js'; import { prisma } from '@sentinel/db'; -import { ensureGuild, getGuildSettings, isGuildInstallBlocked, isUserInstallBlocked, snapshotMessage } from './store.js'; +import { ensureGuild, getGuildSettings, isGuildInstallBlocked, isUserInstallBlocked, markGuildRemoved, snapshotMessage } from './store.js'; import { recordEvent, shouldCapture } from './recorder.js'; -import { findRecentAuditEntry, jsonSafe, redactSecrets } from './utils.js'; +import { findRecentAuditEntry, jsonSafe } from './utils.js'; import { decryptText, redactText, unprotectJson } from './security.js'; import { leaveGuild } from './discord-actions.js'; import { logger } from './logger.js'; @@ -745,6 +745,7 @@ export function registerHandlers(client: Client, status: StatusNotifier) { fields: [{ name: 'Server', value: `${guild.name ?? 'sconosciuto'}\n\`${guild.id}\`` }] }); } + await markGuildRemoved(guild.id); if (await isGuildInstallBlocked(guild.id)) return; const exists = await prisma.guildSettings.findUnique({ where: { guildId: guild.id }, select: { guildId: true } }); if (!exists) return; @@ -939,30 +940,4 @@ export function registerHandlers(client: Client, status: StatusNotifier) { details: { action: entry.action, reason: entry.reason, changes: jsonSafe(entry.changes ?? []), extra: jsonSafe(entry.extra ?? null) } }); }); - - on(Events.TypingStart, async (typing) => { - if (!typing.guild) return; - const settings = await getGuildSettings(typing.guild.id); - if (!settings?.typingLoggingEnabled || typing.user.id === client.user?.id) return; - await recordEvent({ guildId: typing.guild.id, eventKey: 'typing.start', actorId: typing.user.id, channelId: typing.channel.id, summary: `${typing.user.tag ?? typing.user.id} ha iniziato a scrivere.`, details: { actorBot: typing.user.bot ?? false } }); - }); - - const handleRaw = async (packet: any) => { - const guildId = packet?.d?.guild_id as string | undefined; - if (!guildId) return; - if (packet.t === 'MESSAGE_CREATE' && packet.d?.author?.id === client.user?.id) return; - const settings = await getGuildSettings(guildId); - if (!settings?.rawGatewayEnabled) return; - let data = redactSecrets(jsonSafe(packet.d)); - if (!settings.storeMessageContent && data && typeof data === 'object' && !Array.isArray(data) && (packet.t === 'MESSAGE_CREATE' || packet.t === 'MESSAGE_UPDATE')) { - const redacted = { ...(data as Record) }; - delete redacted.content; - delete redacted.embeds; - data = redacted; - } - await recordEvent({ guildId, eventKey: 'raw.gateway', actorId: packet.d?.user_id ?? packet.d?.author?.id ?? null, targetId: packet.d?.id ?? null, channelId: packet.d?.channel_id ?? null, summary: `Gateway event ${packet.t ?? 'UNKNOWN'}.`, details: { opcode: packet.op, type: packet.t, sequence: packet.s, data } }); - }; - client.on(Events.Raw, (packet: any) => { - handleRaw(packet).catch((error: unknown) => report(Events.Raw, error)); - }); } diff --git a/apps/bot/src/index.ts b/apps/bot/src/index.ts index 35d1db6..62e0b8a 100644 --- a/apps/bot/src/index.ts +++ b/apps/bot/src/index.ts @@ -7,7 +7,7 @@ import { import { prisma } from '@sentinel/db'; import { config } from './config.js'; import { logger } from './logger.js'; -import { ensureGuild, isGuildInstallBlocked, runRetentionCleanup } from './store.js'; +import { REMOVED_GUILD_GRACE_DAYS, ensureGuild, isGuildInstallBlocked, markMissingGuildsRemoved, purgeRemovedGuilds, runRetentionCleanup } from './store.js'; import { registerHandlers } from './handlers.js'; import { startDispatcher } from './dispatcher.js'; import { onQuotaExceeded } from './recorder.js'; @@ -32,7 +32,6 @@ const client = new Client({ GatewayIntentBits.GuildVoiceStates, GatewayIntentBits.GuildMessages, GatewayIntentBits.GuildMessageReactions, - GatewayIntentBits.GuildMessageTyping, GatewayIntentBits.MessageContent, GatewayIntentBits.GuildScheduledEvents, GatewayIntentBits.AutoModerationConfiguration, @@ -90,9 +89,31 @@ client.once(Events.ClientReady, async (ready) => { { name: 'Avvio', value: `` } ] }); - await runRetentionCleanup().catch((error) => logger.error({ error: errorText(error) }, 'Initial retention cleanup failed')); + await markMissingGuildsRemoved(ready.guilds.cache.keys()) + .then((count) => { if (count) logger.info({ count }, 'Servers removed while offline'); }) + .catch((error) => logger.error({ error: errorText(error) }, 'Unable to reconcile removed servers')); + await runMaintenance(); }); +/** Retention cleanup, then deletion of servers removed more than 30 days ago. */ +async function runMaintenance() { + await runRetentionCleanup().catch((error) => logger.error({ error: errorText(error) }, 'Retention cleanup failed')); + try { + const purged = await purgeRemovedGuilds(); + if (purged.length) { + logger.info({ count: purged.length }, 'Deleted data of removed servers'); + status.notify({ + level: 'info', + title: 'Dati di server rimossi cancellati', + description: `Cancellati i dati di ${purged.length} server da cui Sentinel è stato rimosso da più di ${REMOVED_GUILD_GRACE_DAYS} giorni.`, + fields: [{ name: 'Server', value: purged.slice(0, 20).map((id) => `\`${id}\``).join('\n') }] + }); + } + } catch (error) { + logger.error({ error: errorText(error) }, 'Removed servers cleanup failed'); + } +} + // Bot health goes to the instance status channel chosen in the super // console, not to the servers being logged. client.on(Events.Warn, (warning) => { @@ -109,9 +130,7 @@ client.on(Events.ShardResume, (shardId, replayed) => { status.notify({ level: 'info', title: 'Connessione a Discord ripristinata', description: `Shard ${shardId}: ${replayed} eventi recuperati.`, dedupeKey: 'shard-resume' }); }); -setInterval(() => { - runRetentionCleanup().catch((error) => logger.error({ error: errorText(error) }, 'Retention cleanup failed')); -}, 6 * 60 * 60 * 1000).unref(); +setInterval(() => void runMaintenance(), 6 * 60 * 60 * 1000).unref(); let shuttingDown = false; const shutdown = async (signal: string) => { diff --git a/apps/bot/src/internal-api.ts b/apps/bot/src/internal-api.ts index 97ecd34..7860f81 100644 --- a/apps/bot/src/internal-api.ts +++ b/apps/bot/src/internal-api.ts @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; import http from 'node:http'; -import { DiscordAPIError, type Client } from 'discord.js'; +import { DiscordAPIError, Routes, type Client } from 'discord.js'; import { editOwnGuildProfile, leaveGuild } from './discord-actions.js'; import type { StatusNotifier } from './status.js'; @@ -13,6 +13,42 @@ function authorized(header: string | undefined, secret: string) { } const MAX_BODY_BYTES = 128 * 1024; +const MAX_ENTITLEMENT_PAGES = 50; + +type RawEntitlement = { + id: string; + sku_id: string; + guild_id?: string | null; + type: number; + deleted: boolean; + starts_at?: string | null; + ends_at?: string | null; +}; + +/** + * Active (not ended, not deleted) entitlements of the application, through a + * read-only REST call. Only what plan sync needs leaves the bot: the buyer's + * user ID is not passed on. + */ +async function activeEntitlements(client: Client) { + const applicationId = client.application?.id ?? client.user?.id; + if (!applicationId) throw new Error('APPLICATION_UNAVAILABLE'); + const result: Array<{ id: string; skuId: string; guildId: string | null; type: number; startsAt: string | null; endsAt: string | null }> = []; + let after: string | null = null; + for (let page = 0; page < MAX_ENTITLEMENT_PAGES; page += 1) { + const query = new URLSearchParams({ exclude_ended: 'true', exclude_deleted: 'true', limit: '100' }); + if (after) query.set('after', after); + const rows = await client.rest.get(Routes.entitlements(applicationId), { query }) as RawEntitlement[]; + for (const row of rows) { + if (row.deleted) continue; + result.push({ id: row.id, skuId: row.sku_id, guildId: row.guild_id ?? null, type: row.type, startsAt: row.starts_at ?? null, endsAt: row.ends_at ?? null }); + } + if (rows.length < 100) return result; + // Pages are ordered by ID; continue after the highest one seen. + after = rows.reduce((max, row) => (BigInt(row.id) > BigInt(max) ? row.id : max), rows[0]!.id); + } + throw new Error('TOO_MANY_ENTITLEMENTS'); +} const BANNER_URI = /^data:image\/(?:png|jpeg|webp|gif);base64,[A-Za-z0-9+/]+={0,2}$/; class RequestError extends Error { @@ -72,6 +108,11 @@ export function startInternalApi(client: Client, secret: string, port = 3002, st return; } + if (req.method === 'GET' && url.pathname === '/entitlements') { + res.end(JSON.stringify(await activeEntitlements(client))); + return; + } + let match = url.pathname.match(/^\/guilds\/(\d{17,20})\/leave$/); if (req.method === 'POST' && match) { const guildId = match[1]!; diff --git a/apps/bot/src/store.ts b/apps/bot/src/store.ts index 135f02b..93a6de7 100644 --- a/apps/bot/src/store.ts +++ b/apps/bot/src/store.ts @@ -8,7 +8,7 @@ const cache = new Map(); export async function ensureGuild(guild: Guild) { const settings = await prisma.guildSettings.upsert({ where: { guildId: guild.id }, - update: { guildName: guild.name, iconUrl: guild.iconURL() }, + update: { guildName: guild.name, iconUrl: guild.iconURL(), removedAt: null }, create: { guildId: guild.id, guildName: guild.name, iconUrl: guild.iconURL() } }); @@ -70,6 +70,46 @@ export async function snapshotMessage(message: Message) { }); } +/** Days a server's data is kept after the bot is removed from it. */ +export const REMOVED_GUILD_GRACE_DAYS = 30; + +/** The bot left or was removed: start the countdown to deleting its data. */ +export async function markGuildRemoved(guildId: string) { + cache.delete(guildId); + await prisma.guildSettings.updateMany({ where: { guildId, removedAt: null }, data: { removedAt: new Date() } }); +} + +/** + * Servers in the database that the bot no longer belongs to, for example + * because it was removed while offline. `present` must be the full guild list + * after login: unavailable guilds are part of it, so an outage never counts. + */ +export async function markMissingGuildsRemoved(present: Iterable) { + const ids = [...present]; + const result = await prisma.guildSettings.updateMany({ + where: { removedAt: null, guildId: { notIn: ids } }, + data: { removedAt: new Date() } + }); + return result.count; +} + +/** + * Deletes every server removed more than the grace period ago. The cascade + * removes its loggers, events, message snapshots, panel roles and panel log. + * Blacklist entries are kept; a Discord subscription is restored from its + * entitlement if the bot is added back. + */ +export async function purgeRemovedGuilds(now = new Date()) { + const cutoff = new Date(now.getTime() - REMOVED_GUILD_GRACE_DAYS * 86_400_000); + const rows = await prisma.guildSettings.findMany({ where: { removedAt: { lt: cutoff } }, select: { guildId: true } }); + for (const { guildId } of rows) { + cache.delete(guildId); + // Re-check the condition: the bot may have been added back meanwhile. + await prisma.guildSettings.deleteMany({ where: { guildId, removedAt: { lt: cutoff } } }); + } + return rows.map((row) => row.guildId); +} + export async function runRetentionCleanup() { const guilds = await prisma.guildSettings.findMany({ select: { guildId: true, defaultRetentionDays: true, planTier: true, planExpiresAt: true } diff --git a/apps/bot/test/embed-format.test.ts b/apps/bot/test/embed-format.test.ts index aabc32a..568639e 100644 --- a/apps/bot/test/embed-format.test.ts +++ b/apps/bot/test/embed-format.test.ts @@ -65,7 +65,7 @@ test('referenceKindForDetail classifies detail keys', () => { test('message.delete targets render as users', () => { assert.equal(targetKindForEvent('message.delete'), 'user'); assert.equal(targetKindForEvent('role.update'), 'role'); - assert.equal(targetKindForEvent('raw.gateway'), 'technical'); + assert.equal(targetKindForEvent('soundboard.sync'), 'technical'); }); test('buildDetailsField never exceeds a field value', () => { diff --git a/apps/web/app/(en)/en/opengraph-image.tsx b/apps/web/app/(en)/en/opengraph-image.tsx index 40d4275..10a890d 100644 --- a/apps/web/app/(en)/en/opengraph-image.tsx +++ b/apps/web/app/(en)/en/opengraph-image.tsx @@ -9,6 +9,6 @@ export default function Image() { kicker: 'Discord logging bot', title: 'Every action on your Discord server, on the record.', subtitle: 'Bans, kicks, roles, permissions, edited and deleted messages — encrypted, searchable, routed to the right channel.', - tags: ['72 event types', 'Free plan', 'Open source'] + tags: ['70 event types', 'Free plan', 'Open source'] }); } diff --git a/apps/web/app/(it)/it/opengraph-image.tsx b/apps/web/app/(it)/it/opengraph-image.tsx index 845835f..187f97e 100644 --- a/apps/web/app/(it)/it/opengraph-image.tsx +++ b/apps/web/app/(it)/it/opengraph-image.tsx @@ -9,6 +9,6 @@ export default function Image() { kicker: 'Bot di log per Discord', title: 'Ogni azione sul tuo server Discord, registrata.', subtitle: 'Ban, kick, ruoli, permessi, messaggi modificati ed eliminati — cifrati, ricercabili, nel canale giusto.', - tags: ['72 tipi di evento', 'Piano gratuito', 'Open source'] + tags: ['70 tipi di evento', 'Piano gratuito', 'Open source'] }); } diff --git a/apps/web/app/_components/GuildDashboard.tsx b/apps/web/app/_components/GuildDashboard.tsx index 5908e7a..feef5ad 100644 --- a/apps/web/app/_components/GuildDashboard.tsx +++ b/apps/web/app/_components/GuildDashboard.tsx @@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Icon } from './Brand'; import { LanguageSwitcher } from './LanguageSwitcher'; import { localizeCategory, localizeEvent, type Locale } from '../i18n'; -import { TIER_ORDER, formatLimit, formatNumber, limitLabels, planErrorMessage, tierLabel, tierShort, type Plan, type PlanTier } from './plans'; +import { TIER_ORDER, formatLimit, formatNumber, limitLabels, planErrorMessage, tierLabel, tierShort, type DiscordStore, type Plan, type PlanTier } from './plans'; type Settings = { guildId: string; @@ -18,8 +18,6 @@ type Settings = { embedFooter: string; messageSnapshotEnabled: boolean; storeMessageContent: boolean; - rawGatewayEnabled: boolean; - typingLoggingEnabled: boolean; premiumEnabled: boolean; }; @@ -62,7 +60,10 @@ type PlanInfo = { effectiveTier: PlanTier; expiresAt: string | null; billing: string | null; + /** MANUAL (trial, partner, gift) or DISCORD (a Discord subscription). */ + source: string; plan: Plan; + discord: DiscordStore; usage: { eventsToday: number; logChannels: number; roleBindings: number }; branding: { nickname: string | null; bannerSet: boolean }; eventTiers: { TIER1: string[]; TIER2: string[] }; @@ -107,9 +108,9 @@ const LOGGER_MACROS: LoggerMacro[] = [ }, { key: 'system', - label: 'Sistema & avanzato', - description: 'Disponibilità del server e diagnostica Gateway avanzata.', - categories: ['Sistema', 'Avanzato'] + label: 'Sistema', + description: 'Disponibilità del server e diagnostica Gateway.', + categories: ['Sistema'] } ]; @@ -183,7 +184,7 @@ export default function GuildDashboard({ guildId, locale }: { guildId: string; l server: { label: 'Server & structure', description: 'Channels, roles, invites, webhooks, integrations and server settings.' }, voice: { label: 'Voice & activity', description: 'Voice channels, Stage, scheduled events and soundboard.' }, content: { label: 'Content & customization', description: 'Emoji, stickers and other custom server elements.' }, - system: { label: 'System & advanced', description: 'Server availability and advanced Gateway diagnostics.' }, + system: { label: 'System', description: 'Server availability and Gateway diagnostics.' }, other: { label: 'Other loggers', description: 'Events not yet assigned to a macro category.' } }; return english[macro.key] ?? { label: macro.label, description: macro.description }; @@ -299,7 +300,6 @@ export default function GuildDashboard({ guildId, locale }: { guildId: string; l const canAdmin = access === 'ADMIN' || access === 'OWNER'; const tier: PlanTier = plan?.effectiveTier ?? 'FREE'; const maxRetention = plan?.plan.limits.retentionDays ?? 3650; - const advancedAllowed = tierIndex(tier) >= tierIndex('TIER2'); const canModerate = canAdmin || access === 'MODERATOR'; const formatDate = (value: string) => { try { @@ -456,8 +456,6 @@ export default function GuildDashboard({ guildId, locale }: { guildId: string; l

{L('DATI', 'DATA')}

{L('Acquisizione', 'Collection')}

saveSettings({ messageSnapshotEnabled: value })} /> saveSettings({ storeMessageContent: value })} /> - saveSettings({ typingLoggingEnabled: value })} /> - saveSettings({ rawGatewayEnabled: value })} danger /> @@ -934,16 +932,22 @@ function PlanPanel({ plan, locale }: { plan: PlanInfo; locale: Locale }) {

{L('PIANO', 'PLAN')}

{tierLabel(plan.effectiveTier, locale)}

- {L('Vedi i piani', 'See plans')} + {plan.discord.enabled + ? {plan.source === 'DISCORD' ? L('Gestisci su Discord', 'Manage on Discord') : L('Abbonati su Discord', 'Subscribe on Discord')} + : {L('Vedi i piani', 'See plans')}}

{expired ? L(`Il piano ${tierLabel(plan.tier, locale)} è scaduto: valgono i limiti Free.`, `The ${tierLabel(plan.tier, locale)} plan has expired: Free limits apply.`) - : plan.expiresAt - ? L(`Valido fino al ${new Date(plan.expiresAt).toLocaleDateString('it-IT')}.`, `Valid until ${new Date(plan.expiresAt).toLocaleDateString('en-GB')}.`) + : plan.source === 'DISCORD' + ? L('Abbonamento Discord attivo, con rinnovo automatico. Puoi gestirlo o disdirlo dalle impostazioni di Discord.', 'Active Discord subscription, renewed automatically. You can manage or cancel it from your Discord settings.') : plan.effectiveTier === 'FREE' - ? L('Piano gratuito. Per sbloccare i logger ad alto volume e alzare i limiti contattami dalla pagina prezzi.', 'Free plan. To unlock high-volume loggers and raise limits, contact me from the pricing page.') - : L('Piano attivo, senza scadenza.', 'Active plan, no expiry.')} + ? plan.discord.enabled + ? L('Piano gratuito. Per sbloccare i logger ad alto volume e alzare i limiti, abbonati su Discord.', 'Free plan. To unlock high-volume loggers and raise limits, subscribe on Discord.') + : L('Piano gratuito. I piani a pagamento saranno presto disponibili come abbonamenti Discord.', 'Free plan. Paid plans will soon be available as Discord subscriptions.') + : plan.expiresAt + ? L(`Piano di prova o omaggio, valido fino al ${new Date(plan.expiresAt).toLocaleDateString('it-IT')}.`, `Trial or complimentary plan, valid until ${new Date(plan.expiresAt).toLocaleDateString('en-GB')}.`) + : L('Piano di prova o omaggio, senza scadenza.', 'Trial or complimentary plan, no expiry.')}

{meters.map((meter) => { diff --git a/apps/web/app/_components/LegalPage.tsx b/apps/web/app/_components/LegalPage.tsx index ba9a0db..1cbc334 100644 --- a/apps/web/app/_components/LegalPage.tsx +++ b/apps/web/app/_components/LegalPage.tsx @@ -5,7 +5,7 @@ type Section = { title: string; body: Array }; type Doc = { id: string; kicker: string; title: string; intro: string; sections: Section[] }; const CONTACT = 'haxurus@icloud.com'; -const UPDATED = { it: '12 ottobre 2026', en: '12 October 2026' }; +const UPDATED = { it: '13 ottobre 2026', en: '13 October 2026' }; const copy: Record = { en: { @@ -34,10 +34,11 @@ const copy: Record `${count}${last ? ' o più' : ''} abbonamenti`, - multiExample: (servers: number, price: string, total: string) => `Esempio: ${servers} server Pro annuali a ${price} invece di ${total}.`, + subscribe: 'Abbonati su Discord', + soon: 'Presto su Discord', compare: 'Confronto completo', feature: 'Funzione', rows: { standard: 'Logger standard (moderazione, messaggi, struttura)', tier1: 'Logger ad alto volume: messaggi, reazioni, sondaggi, interazioni, Audit Log', - tier2: 'Logger avanzati: typing, Gateway raw, diagnostica', + tier2: 'Diagnostica Gateway e disponibilità del server', support: 'Supporto prioritario', branding: 'Nome e banner del bot personalizzati' }, contactKicker: 'Contatti', - contactTitle: 'Vuoi attivare un piano?', - contactText: 'Scrivimi indicando l’ID del server e il piano che ti interessa (mensile o annuale, ed eventuale codice sconto). Ti rispondo e attivo il piano manualmente.', + contactTitle: 'Domande o una prova?', + contactText: 'Scrivimi per domande sui piani, per un periodo di prova gratuito o per una collaborazione. I piani a pagamento si acquistano solo tramite Discord.', faqTitle: 'Domande frequenti', faq: [ + ['Come si paga?', 'Tramite Discord: apri il piano, scegli il server e abbonati. Discord elabora il pagamento e gestisce IVA e rinnovi; il piano si attiva da solo entro pochi minuti.'], + ['Come disdico?', 'Dalle impostazioni utente di Discord, nella sezione degli abbonamenti. Il piano resta attivo fino alla fine del periodo già pagato.'], ['Cosa succede alla scadenza?', 'Il server torna al piano Free: i logger non inclusi vengono disattivati e i limiti tornano quelli gratuiti. La configurazione non viene cancellata.'], ['Cosa vuol dire “eventi al giorno”?', 'È il numero di eventi salvati nello storico in un giorno (UTC). Raggiunto il limite, gli eventi successivi non vengono registrati fino al giorno dopo.'], - ['Posso provare prima di pagare?', 'Sì: il piano Free resta sempre disponibile e su richiesta posso attivare periodi di prova dei piani a pagamento.'] + ['Posso provare prima di pagare?', 'Sì: il piano Free resta sempre disponibile e su richiesta posso attivare un periodo di prova gratuito dei piani a pagamento.'] ], unavailable: 'Impossibile caricare i piani in questo momento. Riprova tra poco.', included: 'Incluso', @@ -64,45 +60,41 @@ const copy = { kicker: 'Plans', title: 'A plan for every server.', intro: 'The Free plan covers moderation and server-structure logging. Paid levels unlock high-volume loggers, raise the limits and add priority support.', - payments: 'Online payments are not active yet. To activate a plan, contact me directly and I will set it up on your server.', + payments: 'Paid plans are Discord subscriptions: choose the plan and the server and pay directly in Discord, which handles payment, VAT and renewals.', + paymentsSoon: 'Paid plans will soon be available as Discord subscriptions. Meanwhile, every server can use the Free plan.', monthly: 'Monthly', yearly: 'Yearly', perMonth: '/month', perYear: '/year', save: 'save', current: 'Free forever', - promo: 'Promotion', - code: 'code', - until: 'until', free: { cta: 'Add Sentinel', text: 'Every standard logger: bans, kicks, roles, permissions, channels, edited and deleted messages, AutoMod.' }, tiers: { TIER1: 'Unlocks the most useful high-volume loggers: new messages, reactions, polls, interactions, full Audit Log. Higher limits and priority support.', - TIER2: 'Every feature unlocked, including typing and raw Gateway, with the highest limits.', + TIER2: 'Every logger, including Gateway diagnostics, with higher limits and 60 days of history.', TIER3: 'Everything in Level 2, plus a custom bot name and banner in your server.' }, - contactCta: 'Contact me', - multiKicker: 'More servers', - multiTitle: 'Multiple subscriptions, lower price.', - multiText: 'Running a plan on several servers? The discount applies to every subscription of the same customer, monthly or yearly, even on different levels.', - multiUnit: (count: number, last: boolean) => `${count}${last ? '+' : ''} subscriptions`, - multiExample: (servers: number, price: string, total: string) => `Example: ${servers} yearly Pro servers for ${price} instead of ${total}.`, + subscribe: 'Subscribe on Discord', + soon: 'Coming soon on Discord', compare: 'Full comparison', feature: 'Feature', rows: { standard: 'Standard loggers (moderation, messages, structure)', tier1: 'High-volume loggers: messages, reactions, polls, interactions, Audit Log', - tier2: 'Advanced loggers: typing, raw Gateway, diagnostics', + tier2: 'Gateway diagnostics and server availability', support: 'Priority support', branding: 'Custom bot name and banner' }, contactKicker: 'Contact', - contactTitle: 'Want to activate a plan?', - contactText: 'Write to me with your server ID and the plan you want (monthly or yearly, plus any discount code). I will reply and activate it manually.', + contactTitle: 'Questions or a trial?', + contactText: 'Write to me with questions about the plans, for a free trial or for a partnership. Paid plans are bought only through Discord.', faqTitle: 'FAQ', faq: [ + ['How do I pay?', 'Through Discord: open the plan, choose your server and subscribe. Discord processes the payment and handles VAT and renewals; the plan switches on by itself within a few minutes.'], + ['How do I cancel?', 'From your Discord user settings, in the subscriptions section. The plan stays active until the end of the period already paid.'], ['What happens when a plan expires?', 'The server goes back to Free: loggers outside the plan are switched off and the free limits apply again. Your configuration is not deleted.'], ['What does “events per day” mean?', 'The number of events stored in the history in one (UTC) day. Once reached, further events are not recorded until the next day.'], - ['Can I try before paying?', 'Yes: the Free plan is always available, and on request I can enable trial periods of the paid plans.'] + ['Can I try before paying?', 'Yes: the Free plan is always available, and on request I can enable a free trial of the paid plans.'] ], unavailable: 'Plans cannot be loaded right now. Please try again shortly.', included: 'Included', @@ -114,6 +106,11 @@ export default function PricingView({ locale, info }: { locale: Locale; info: Pu const c = copy[locale]; const [billing, setBilling] = useState<'MONTHLY' | 'YEARLY'>('MONTHLY'); const plans = info ? TIER_ORDER.map((tier) => info.plans.find((plan) => plan.tier === tier)).filter((plan) => plan !== undefined) : []; + const store = info?.discord ?? null; + // Yearly prices are shown only when Discord sells a yearly subscription. + const yearlyAvailable = Boolean(store && (['TIER1', 'TIER2', 'TIER3'] as const).some((tier) => store.links[tier].YEARLY)); + const period = yearlyAvailable ? billing : 'MONTHLY'; + const storeLink = (tier: string) => store?.links[tier as PaidTier]?.[period] ?? null; const labels = limitLabels(locale); const check = (value: boolean) => value ? @@ -133,7 +130,7 @@ export default function PricingView({ locale, info }: { locale: Locale; info: Pu ? [{ '@type': 'Offer', name: plan.name, price: '0', priceCurrency: 'EUR' }] : [ { '@type': 'Offer', name: `${plan.name} (${c.monthly})`, price: (plan.monthlyCents / 100).toFixed(2), priceCurrency: 'EUR' }, - { '@type': 'Offer', name: `${plan.name} (${c.yearly})`, price: (plan.yearlyCents / 100).toFixed(2), priceCurrency: 'EUR' } + ...(yearlyAvailable ? [{ '@type': 'Offer', name: `${plan.name} (${c.yearly})`, price: (plan.yearlyCents / 100).toFixed(2), priceCurrency: 'EUR' }] : []) ]) }] : []), { @@ -160,42 +157,31 @@ export default function PricingView({ locale, info }: { locale: Locale; info: Pu

{c.intro}

-
{c.payments}
- - {info?.promotions.map((promo) => { - const text = promotionText(promo, locale); - return ( -
- {c.promo} - {text.amount} - {promo.description || text.scope} - {c.code}: {promo.code} - {promo.validUntil && {c.until} {new Date(promo.validUntil).toLocaleDateString(locale === 'it' ? 'it-IT' : 'en-GB')}} -
- ); - })} + {info &&
{store?.enabled ? c.payments : c.paymentsSoon}
} {!info &&
{c.unavailable}
} {info && <> -
+ {yearlyAvailable &&
-
+
}
{plans.map((plan) => { const free = plan.tier === 'FREE'; - const price = billing === 'YEARLY' ? plan.yearlyCents : plan.monthlyCents; + const price = period === 'YEARLY' ? plan.yearlyCents : plan.monthlyCents; + const link = free ? null : storeLink(plan.tier); + const buttonClass = `button button-lg ${!free && plan.tier === 'TIER2' ? 'button-primary' : 'button-secondary'}`; const saving = plan.monthlyCents * 12 - plan.yearlyCents; return (
{free ? 'FREE' : `${locale === 'it' ? 'LIVELLO' : 'LEVEL'} ${plan.level}`}

{plan.name}

- {free ? {formatPrice(0, locale)} : <>{formatPrice(price, locale)}{billing === 'YEARLY' ? c.perYear : c.perMonth}} + {free ? {formatPrice(0, locale)} : <>{formatPrice(price, locale)}{period === 'YEARLY' ? c.perYear : c.perMonth}}
-

{free ? c.current : billing === 'YEARLY' && saving > 0 ? `${c.save} ${formatPrice(saving, locale)}` : ' '}

+

{free ? c.current : period === 'YEARLY' && saving > 0 ? `${c.save} ${formatPrice(saving, locale)}` : ' '}

{free ? c.free.text : c.tiers[plan.tier as 'TIER1' | 'TIER2' | 'TIER3']}

  • {formatLimit('retentionDays', plan.limits.retentionDays, locale)} {locale === 'it' ? 'di storico' : 'of history'}
  • @@ -204,42 +190,16 @@ export default function PricingView({ locale, info }: { locale: Locale; info: Pu {plan.prioritySupport &&
  • {c.rows.support}
  • } {plan.customBranding &&
  • {c.rows.branding}
  • }
- - {free ? c.free.cta : c.contactCta} - + {free + ? {c.free.cta} + : link + ? {c.subscribe} + : {c.soon}}
); })}
- {info.multiSubscriptionDiscounts?.length > 0 && (() => { - const tiers = [...info.multiSubscriptionDiscounts].sort((a, b) => a.minSubscriptions - b.minSubscriptions); - const pro = info.plans.find((plan) => plan.tier === 'TIER2'); - const example = tiers[tiers.length - 1]!; - return ( -
-
- {c.multiKicker} -

{c.multiTitle}

-

{c.multiText}

- {pro &&

{c.multiExample( - example.minSubscriptions, - formatPrice(Math.round(pro.yearlyCents * example.minSubscriptions * (100 - example.percentOff) / 100), locale), - formatPrice(pro.yearlyCents * example.minSubscriptions, locale) - )}

} -
-
    - {tiers.map((tier, index) => ( -
  1. - -{tier.percentOff}% - {c.multiUnit(tier.minSubscriptions, index === tiers.length - 1)} -
  2. - ))} -
-
- ); - })()} -

{c.compare}

diff --git a/apps/web/app/_components/PublicHome.tsx b/apps/web/app/_components/PublicHome.tsx index 4c7f9b8..27441f4 100644 --- a/apps/web/app/_components/PublicHome.tsx +++ b/apps/web/app/_components/PublicHome.tsx @@ -26,10 +26,10 @@ const copy = { ['20:33:51', 'message.delete', '#generale · messaggio di giulia.r', 'stored'], ['20:31:07', 'member.join', 'nuovo_utente · account creato 2 giorni fa', 'filtered'] ] as const, - footer: ['72 eventi', 'consegna ok', 'storico cifrato'] + footer: ['70 eventi', 'consegna ok', 'storico cifrato'] }, stats: [ - ['72', 'tipi di evento Discord'], + ['70', 'tipi di evento Discord'], ['AES-256', 'cifratura dello storico'], ['4', 'livelli di accesso RBAC'], ['0', 'permessi di moderazione richiesti'] @@ -123,10 +123,10 @@ const copy = { ['20:33:51', 'message.delete', '#general · message by julia.r', 'stored'], ['20:31:07', 'member.join', 'new_user · account created 2 days ago', 'filtered'] ] as const, - footer: ['72 events', 'delivery ok', 'encrypted history'] + footer: ['70 events', 'delivery ok', 'encrypted history'] }, stats: [ - ['72', 'Discord event types'], + ['70', 'Discord event types'], ['AES-256', 'history encryption'], ['4', 'RBAC access levels'], ['0', 'moderation permissions required'] diff --git a/apps/web/app/_components/SuperConsole.tsx b/apps/web/app/_components/SuperConsole.tsx index eee6704..1abbd95 100644 --- a/apps/web/app/_components/SuperConsole.tsx +++ b/apps/web/app/_components/SuperConsole.tsx @@ -1,16 +1,16 @@ 'use client'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { Icon, type IconName } from './Brand'; import { SiteHeader } from './SiteChrome'; import type { Locale } from '../i18n'; -import { TIER_ORDER, formatPrice, multiDiscountFor, tierLabel, type MultiDiscount, type Plan, type PlanTier } from './plans'; +import { TIER_ORDER, formatPrice, tierLabel, type DiscordStore, type PaidTier, type Plan, type PlanTier, type StorePeriod } from './plans'; type GuildPlan = { planTier: PlanTier; planBilling: string | null; planExpiresAt: string | null; - planCouponCode: string | null; + planSource: string; planNote: string | null; planUpdatedAt: string | null; } | null; @@ -29,20 +29,11 @@ type Block = { id: string; kind: 'USER' | 'GUILD'; subjectId: string; reason: st type Audit = { id: string; username: string; action: string; subjectType: string | null; subjectId: string | null; createdAt: string }; type Config = { contactEmail: string | null; contactDiscord: string | null; contactUrl: string | null }; type StatusChannel = { guildId: string; channelId: string; updatedAt: string } | null; -type Coupon = { - id: string; - code: string; - description: string | null; - percentOff: number | null; - amountOffCents: number | null; - tiers: string[]; - billing: string; - maxRedemptions: number | null; - redemptions: number; - validFrom: string | null; - validUntil: string | null; - active: boolean; - public: boolean; +type Skus = Partial>>>; +type DiscordOverview = DiscordStore & { + skus: Skus; + applicationId: string; + lastSync: { at: string; ok: boolean; entitlements: number; changed: number; error?: string } | null; }; type Overview = { guilds: Guild[]; @@ -50,11 +41,10 @@ type Overview = { audit: Audit[]; config: Config; statusChannel: StatusChannel; - coupons: Coupon[]; planCatalog: Plan[]; - multiSubscriptionDiscounts: MultiDiscount[]; + discord: DiscordOverview; }; -type Tab = 'servers' | 'contacts' | 'coupons' | 'status' | 'blacklist' | 'audit'; +type Tab = 'servers' | 'contacts' | 'discord' | 'status' | 'blacklist' | 'audit'; const copy = { it: { @@ -63,7 +53,7 @@ const copy = { intro: 'Area riservata al proprietario dell’istanza. Le azioni qui sotto hanno effetto su tutti i server collegati al bot.', dashboard: 'Dashboard', pricing: 'Prezzi', - tabs: { servers: 'Server e piani', contacts: 'Contatti', coupons: 'Coupon', status: 'Notifiche', blacklist: 'Blacklist', audit: 'Audit' }, + tabs: { servers: 'Server e piani', contacts: 'Contatti', discord: 'Abbonamenti Discord', status: 'Notifiche', blacklist: 'Blacklist', audit: 'Audit' }, liveServers: 'Server collegati', liveServersText: 'Elenco live dalla sessione Discord del bot. Da qui assegni il piano di ogni server.', members: 'membri', @@ -77,20 +67,14 @@ const copy = { noServers: 'Sentinel non è collegato ad alcun server.', plan: { tier: 'Piano', - billing: 'Fatturazione', - monthly: 'Mensile', - yearly: 'Annuale', - gift: 'Omaggio / prova', + manual: 'Da qui assegni solo piani gratuiti (prova, partner, omaggio): i piani a pagamento si vendono solo tramite Discord. Un abbonamento Discord attivo di livello uguale o superiore prende il posto del piano manuale.', + discordSource: 'Abbonamento Discord', expires: 'Scadenza', noExpiry: 'Nessuna scadenza', plusMonth: '+1 mese', plusYear: '+1 anno', - coupon: 'Codice coupon', - subscriptions: 'Abbonamenti del cliente', - multi: 'sconto abbonamenti multipli', note: 'Nota interna', - notePlaceholder: 'Es. pagato con bonifico il 10/10', - price: 'Prezzo', + notePlaceholder: 'Es. prova di 30 giorni per un server partner', save: 'Salva piano', expiresOn: 'scade il', expired: 'scaduto', @@ -98,36 +82,27 @@ const copy = { }, contacts: { title: 'Recapiti pubblici', - text: 'Mostrati nella pagina prezzi a chi vuole attivare un piano.', + text: 'Mostrati nella pagina prezzi per domande, prove gratuite e collaborazioni.', email: 'Email', discord: 'Discord (username o link https)', url: 'Link: server Discord, sito o modulo (https)', save: 'Salva recapiti' }, - coupons: { - title: 'Coupon e sconti', - text: 'I coupon si applicano quando assegni un piano a un server. Quelli pubblici compaiono come promozione nella pagina prezzi.', - code: 'Codice', - type: 'Tipo di sconto', - percent: 'Percentuale', - amount: 'Importo fisso (€)', - value: 'Valore', - tiers: 'Piani', - allTiers: 'Tutti i piani a pagamento', - billing: 'Fatturazione', - any: 'Qualsiasi', - max: 'Utilizzi massimi', - from: 'Valido dal', - until: 'Valido fino al', - description: 'Descrizione', - public: 'Mostra nella pagina prezzi', - create: 'Crea coupon', - active: 'Attivo', - used: 'utilizzi', - unlimited: 'illimitati', - remove: 'Elimina', - confirmRemove: 'Eliminare questo coupon?', - none: 'Nessun coupon.' + discord: { + title: 'Abbonamenti Discord', + text: 'I piani a pagamento si vendono solo come abbonamenti per server tramite le App premium di Discord, agli stessi prezzi della pagina prezzi. Crea gli SKU nel Portale sviluppatori (Monetizzazione) e incolla qui i loro ID: i piani dei server si aggiornano da soli ogni due minuti.', + monthly: 'SKU mensile', + yearly: 'SKU annuale (solo se Discord lo prevede)', + price: 'Prezzo da impostare su Discord', + save: 'Salva SKU', + sync: 'Sincronizza ora', + store: 'Apri lo store', + notConfigured: 'Nessuno SKU configurato: la pagina prezzi mostra «Presto su Discord».', + lastSync: 'Ultima sincronizzazione', + never: 'non ancora eseguita', + entitlements: 'abbonamenti attivi', + changed: 'piani aggiornati', + failed: 'non riuscita: il bot non ha potuto leggere gli abbonamenti' }, status: { title: 'Canale notifiche di stato', @@ -167,16 +142,9 @@ const copy = { user: 'Utente', guild: 'Server', errors: { - COUPON_NOT_FOUND: 'Coupon inesistente.', - COUPON_INACTIVE: 'Coupon disattivato.', - COUPON_NOT_STARTED: 'Coupon non ancora valido.', - COUPON_EXPIRED: 'Coupon scaduto.', - COUPON_EXHAUSTED: 'Coupon esaurito.', - COUPON_FREE_PLAN: 'Un coupon non si applica al piano Free.', - COUPON_WRONG_TIER: 'Il coupon non vale per questo piano.', - COUPON_WRONG_BILLING: 'Il coupon non vale per questa fatturazione.', - COUPON_EXISTS: 'Esiste già un coupon con questo codice.', - INVALID_COUPON: 'Coupon non valido: indica un solo tipo di sconto e un codice di 3-32 caratteri.', + INVALID_SKU: 'ID SKU non valido (17-20 cifre).', + DUPLICATE_SKU: 'Lo stesso SKU non può valere per due piani.', + DISCORD_SYNC_FAILED: 'Sincronizzazione non riuscita: il bot non ha potuto leggere gli abbonamenti.', EXPIRY_IN_PAST: 'La scadenza è nel passato.', UNKNOWN_GUILD_REFERENCE: 'Il canale non appartiene al server scelto o non può ricevere messaggi.', DISCORD_RESOURCES_FAILED: 'Impossibile leggere i canali da Discord.', @@ -190,7 +158,7 @@ const copy = { intro: 'Instance-owner area. Actions here affect every server connected to the bot.', dashboard: 'Dashboard', pricing: 'Pricing', - tabs: { servers: 'Servers & plans', contacts: 'Contacts', coupons: 'Coupons', status: 'Notifications', blacklist: 'Blacklist', audit: 'Audit' }, + tabs: { servers: 'Servers & plans', contacts: 'Contacts', discord: 'Discord subscriptions', status: 'Notifications', blacklist: 'Blacklist', audit: 'Audit' }, liveServers: 'Connected servers', liveServersText: 'Live list from the bot Discord session. Assign each server’s plan from here.', members: 'members', @@ -204,20 +172,14 @@ const copy = { noServers: 'Sentinel is not connected to any server.', plan: { tier: 'Plan', - billing: 'Billing', - monthly: 'Monthly', - yearly: 'Yearly', - gift: 'Gift / trial', + manual: 'Only free plans are assigned here (trial, partner, gift): paid plans are sold only through Discord. An active Discord subscription of the same or a higher level replaces the manual plan.', + discordSource: 'Discord subscription', expires: 'Expiry', noExpiry: 'No expiry', plusMonth: '+1 month', plusYear: '+1 year', - coupon: 'Coupon code', - subscriptions: 'Customer subscriptions', - multi: 'multi-subscription discount', note: 'Internal note', - notePlaceholder: 'E.g. paid by bank transfer on 10/10', - price: 'Price', + notePlaceholder: 'E.g. 30-day trial for a partner server', save: 'Save plan', expiresOn: 'expires', expired: 'expired', @@ -225,36 +187,27 @@ const copy = { }, contacts: { title: 'Public contacts', - text: 'Shown on the pricing page to people who want to activate a plan.', + text: 'Shown on the pricing page for questions, free trials and partnerships.', email: 'Email', discord: 'Discord (username or https link)', url: 'Link: Discord server, website or form (https)', save: 'Save contacts' }, - coupons: { - title: 'Coupons & discounts', - text: 'Coupons apply when you assign a plan to a server. Public ones appear as promotions on the pricing page.', - code: 'Code', - type: 'Discount type', - percent: 'Percentage', - amount: 'Fixed amount (€)', - value: 'Value', - tiers: 'Plans', - allTiers: 'Every paid plan', - billing: 'Billing', - any: 'Any', - max: 'Maximum redemptions', - from: 'Valid from', - until: 'Valid until', - description: 'Description', - public: 'Show on the pricing page', - create: 'Create coupon', - active: 'Active', - used: 'used', - unlimited: 'unlimited', - remove: 'Delete', - confirmRemove: 'Delete this coupon?', - none: 'No coupons.' + discord: { + title: 'Discord subscriptions', + text: 'Paid plans are sold only as per-server subscriptions through Discord Premium Apps, at the same prices as the pricing page. Create the SKUs in the Developer Portal (Monetization) and paste their IDs here: server plans update by themselves every two minutes.', + monthly: 'Monthly SKU', + yearly: 'Yearly SKU (only if Discord offers it)', + price: 'Price to set on Discord', + save: 'Save SKUs', + sync: 'Sync now', + store: 'Open the store', + notConfigured: 'No SKU configured: the pricing page shows “Coming soon on Discord”.', + lastSync: 'Last sync', + never: 'not run yet', + entitlements: 'active subscriptions', + changed: 'plans updated', + failed: 'failed: the bot could not read the subscriptions' }, status: { title: 'Status notification channel', @@ -294,16 +247,9 @@ const copy = { user: 'User', guild: 'Server', errors: { - COUPON_NOT_FOUND: 'Unknown coupon.', - COUPON_INACTIVE: 'Coupon is disabled.', - COUPON_NOT_STARTED: 'Coupon is not valid yet.', - COUPON_EXPIRED: 'Coupon has expired.', - COUPON_EXHAUSTED: 'Coupon has no redemptions left.', - COUPON_FREE_PLAN: 'Coupons do not apply to the Free plan.', - COUPON_WRONG_TIER: 'The coupon does not cover this plan.', - COUPON_WRONG_BILLING: 'The coupon does not cover this billing period.', - COUPON_EXISTS: 'A coupon with this code already exists.', - INVALID_COUPON: 'Invalid coupon: use exactly one discount type and a 3-32 character code.', + INVALID_SKU: 'Invalid SKU ID (17-20 digits).', + DUPLICATE_SKU: 'The same SKU cannot be used for two plans.', + DISCORD_SYNC_FAILED: 'Sync failed: the bot could not read the subscriptions.', EXPIRY_IN_PAST: 'The expiry date is in the past.', UNKNOWN_GUILD_REFERENCE: 'The channel does not belong to the selected server or cannot receive messages.', DISCORD_RESOURCES_FAILED: 'Unable to read channels from Discord.', @@ -370,7 +316,7 @@ export default function SuperConsole({ locale }: { locale: Locale }) { const tabs: Array<{ key: Tab; icon: IconName; label: string; badge?: number }> = [ { key: 'servers', icon: 'grid', label: c.tabs.servers }, { key: 'contacts', icon: 'users', label: c.tabs.contacts }, - { key: 'coupons', icon: 'key', label: c.tabs.coupons }, + { key: 'discord', icon: 'key', label: c.tabs.discord }, { key: 'status', icon: 'bolt', label: c.tabs.status }, { key: 'blacklist', icon: 'lock', label: c.tabs.blacklist }, { key: 'audit', icon: 'clock', label: c.tabs.audit } @@ -404,7 +350,7 @@ export default function SuperConsole({ locale }: { locale: Locale }) {
{c.liveServers}{data.guilds.length}
{c.paid}{data.guilds.filter((guild) => guild.plan && guild.plan.planTier !== 'FREE').length}
{c.freeServers}{data.guilds.filter((guild) => !guild.plan || guild.plan.planTier === 'FREE').length}
-
{c.tabs.coupons}{data.coupons.filter((coupon) => coupon.active).length}
+
{c.tabs.discord}{data.guilds.filter((guild) => guild.plan?.planSource === 'DISCORD').length}