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>
118 lines
4.0 KiB
JavaScript
118 lines
4.0 KiB
JavaScript
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,
|
||
};
|