Files
Greenlens/server/lib/discord.js
Timo e3a28b0a1c feat(billing): add weekly_pro subscription plan
Adds a 2.99 EUR/week plan with a 3-day free trial alongside the existing
monthly and yearly subscriptions.

Backend: weekly_pro joins the supported subscription products, the
available product list and the Discord sales label. No schema change --
weekly Pro grants the same 100 credits per calendar month as monthly Pro,
so no column is needed to tell the two apart.

Paywall: weekly and yearly are the two prominent cards, monthly is a
selectable row below them. Weekly is preselected. Cards only render when
their RevenueCat package exists, and the selection falls back to a
visible plan so the CTA can never buy a product that is not loaded.

Trial eligibility: checkTrialOrIntroductoryPriceEligibility now gates the
trial copy. Apple grants one intro offer per subscription group, so with
two trial products a second free-trial promise would otherwise be shown
to users who get charged immediately. Anything but a clear ELIGIBLE is
treated as no trial, as the RevenueCat SDK recommends.

Analytics: trial_started previously fired on every subscription purchase,
including monthly which never had a trial. It now fires only for products
that actually carry one. paywall_viewed distinguishes trial_enabled from
trial_eligible and reports selected_plan.

Tests: 9 new cases covering the entitlement path, credits, renewal period
and trial allowance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:50:38 +02:00

118 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const SALES_WEBHOOK_URL = (process.env.DISCORD_WEBHOOK_SALES_URL || '').trim();
const DOWNLOADS_WEBHOOK_URL = (process.env.DISCORD_WEBHOOK_DOWNLOADS_URL || '').trim();
const PURCHASE_EVENT_TYPES = new Set([
'INITIAL_PURCHASE',
'RENEWAL',
'NON_RENEWING_PURCHASE',
'PRODUCT_CHANGE',
]);
const PLAN_NAMES_BY_PRODUCT = {
weekly_pro: 'Pro (wöchentlich)',
monthly_pro: 'Pro (monatlich)',
yearly_pro: 'Pro (jährlich)',
topup_small: 'Top-up Small (30 Credits)',
topup_medium: 'Top-up Medium (100 Credits)',
topup_large: 'Top-up Large (250 Credits)',
};
const EMBED_COLOR_SALE = 0x2ecc71;
const EMBED_COLOR_NEW_USER = 0x3498db;
const EMBED_COLOR_DOWNLOAD = 0x9b59b6;
const isPurchaseEventType = (eventType) =>
PURCHASE_EVENT_TYPES.has(String(eventType || '').toUpperCase());
// Header/payload values end up in Discord embeds; strip markdown-relevant
// characters and cap length so user-controlled input cannot break or spoof
// the embed (Discord rejects fields over 1024 chars).
const sanitizeEmbedText = (value, maxLength = 64) =>
String(value ?? '').replace(/[\r\n`]/g, ' ').trim().slice(0, maxLength);
const formatStore = (store) => {
const normalized = String(store || '').toUpperCase();
if (normalized === 'APP_STORE' || normalized === 'MAC_APP_STORE') return 'iOS';
if (normalized === 'PLAY_STORE') return 'Android';
return normalized || 'Unbekannt';
};
const formatPlatform = (platform) => {
const normalized = String(platform || '').toLowerCase();
if (normalized === 'ios') return 'iOS';
if (normalized === 'android') return 'Android';
return 'Unbekannt';
};
const formatPrice = (price, currency) => {
if (typeof price !== 'number' || !Number.isFinite(price)) return '';
return `${price.toFixed(2)} ${String(currency || 'USD').toUpperCase()}`;
};
const sendDiscordEmbed = (webhookUrl, embed) => {
if (!webhookUrl) return;
fetch(webhookUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ embeds: [embed] }),
signal: AbortSignal.timeout(5000),
})
// Drain the body so undici can release the connection back to the pool.
.then((response) => response.arrayBuffer())
.catch(() => {});
};
const notifyPurchase = ({ productId, price, currency, store, isTrial, isRenewal, isSandbox } = {}) => {
const safeProductId = sanitizeEmbedText(productId);
const planName = Object.hasOwn(PLAN_NAMES_BY_PRODUCT, safeProductId)
? PLAN_NAMES_BY_PRODUCT[safeProductId]
: safeProductId || 'Unbekannt';
const title = isTrial
? '🛍️ Trial gestartet'
: isRenewal
? '🔁 Abo verlängert'
: '🛍️ Neuer Kauf';
sendDiscordEmbed(SALES_WEBHOOK_URL, {
title: isSandbox ? `${title} (Sandbox)` : title,
color: EMBED_COLOR_SALE,
fields: [
{ name: 'Produkt', value: `${planName} (\`${safeProductId || ''}\`)`, inline: true },
{ name: 'Preis', value: isTrial ? 'Trial' : formatPrice(price, currency), inline: true },
{ name: 'Gerät', value: formatStore(store), inline: true },
],
timestamp: new Date().toISOString(),
});
};
const notifyNewUser = ({ platform, appVersion, provider } = {}) => {
sendDiscordEmbed(DOWNLOADS_WEBHOOK_URL, {
title: '📲 Neuer User',
color: EMBED_COLOR_NEW_USER,
fields: [
{ name: 'Gerät', value: formatPlatform(platform), inline: true },
{ name: 'App-Version', value: sanitizeEmbedText(appVersion) || 'Unbekannt', inline: true },
{ name: 'Anmeldung', value: String(provider || 'Unbekannt'), inline: true },
],
timestamp: new Date().toISOString(),
});
};
const notifyDownload = ({ platform, appVersion } = {}) => {
sendDiscordEmbed(DOWNLOADS_WEBHOOK_URL, {
title: '⬇️ Neuer Download',
color: EMBED_COLOR_DOWNLOAD,
fields: [
{ name: 'Gerät', value: formatPlatform(platform), inline: true },
{ name: 'App-Version', value: sanitizeEmbedText(appVersion) || 'Unbekannt', inline: true },
],
timestamp: new Date().toISOString(),
});
};
module.exports = {
isPurchaseEventType,
notifyPurchase,
notifyNewUser,
notifyDownload,
};