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>
20 KiB
Plan: Wochen-Abo (weekly_pro) als dritte Abo-Auswahl
Stand: 2026-08-09. Ziel: neben monthly_pro (4,99 €/Monat) und yearly_pro (39,99 €/Jahr) ein
Wochen-Abo als Einstiegsoption auf der Paywall.
0. Entscheidungen vorab (blockieren alles andere)
| # | Frage | Status | Konsequenz wenn anders |
|---|---|---|---|
| D1 | Preis? | ENTSCHIEDEN: 2,99 €/Woche (≈ 12,95 €/Monat, bewusst teurer pro Monat als das Monatsabo) | — |
| D2 | Credits? | ENTSCHIEDEN: gleich wie Pro, 100/Monatszyklus (= Option A, kein Schema-Change) | — |
| D3 | Trial? | ENTSCHIEDEN: 3 Tage gratis auf weekly (7 Tage wären bei einem Wochenprodukt eine komplette Gratisperiode) | — |
| D4 | Android? | OFFEN — nur wenn Play-Release aktiv ist | Sonst Play-Console-Schritte streichen |
D3 zieht Arbeit nach sich, die es ohne Trial nicht gäbe: siehe Abschnitt 5.4 (Trial-Eligibility). Das ist nach dem Paywall-Umbau der zweitgrößte Posten im Plan.
Warum D2 so wichtig ist
Der Credit-Zyklus im Backend ist hart auf den UTC-Kalendermonat verdrahtet
(billing.js:44-62) und billing_accounts hat keine Spalte, die
Monats- von Wochen-Pro unterscheidet — nur plan = 'free' | 'pro'
(billing.js:815-826).
- Option A (empfohlen, D2 = 100 Credits): kein Schema-Change, kein Migrations-Risiko. Backend-Aufwand ≈ 4 Zeilen.
- Option B (eigenes Wochen-Kontingent): braucht (1) neue Spalte
plan_tier/product_idinkl.ALTER TABLE ... ADD COLUMN IF NOT EXISTSinensureBillingSchema, (2)getCycleBoundsmuss vom Account abhängen statt global Kalendermonat zu sein, (3)alignAccountToCurrentCycleundisAllowedMonthlyAllowancemüssen den neuen Tier kennen, sonst wird die Allowance beim nächsten Request stillschweigend auf 100 zurückgesetzt (billing.js:147-168). Rechne mit dem 3–4-fachen Aufwand.
Bekannte Kante bei Option A: Wer am 30. eines Monats eine Woche kauft, bekommt am 1. den nächsten 100er-Block — also 200 Credits für eine Woche Zahlung. Der gleiche Effekt existiert heute schon beim Monatsabo, ist beim Wochenabo aber ausgeprägter. Bewusst akzeptieren oder Option B wählen.
Fortschritt (Stand 2026-08-10)
Erledigt im Code (tsc --noEmit sauber, App-Tests 121 grün, Server-Tests 26 grün):
-
Abschnitt 4 komplett —
billing.js(3 Konstanten) +discord.jsLabel. -
Abschnitt 5.1 komplett —
contracts.ts,AppContext.tsx(beide Stellen). -
Abschnitt 6, Mock-Teil —
mockBackendService.ts;simulatePurchasenutzt jetzt das Produkt-Set statt hartkodierter Vergleiche, plusRENEWAL_DAYS_BY_SUBSCRIPTION(weekly = 7 Tage). -
Abschnitt 5.2, Plumbing —
SubscriptionProductId, neuerisSubscriptionProductId()-Helper (ersetzt alle drei hartkodiertenmonthly_pro || yearly_pro-Vergleiche),resolveSubscriptionPackageslöstweekly_proüberPACKAGE_TYPE.WEEKLYauf, Package-Auswahl beim Kauf übersubscriptionPackages[productId]. -
Abschnitt 5.2, UI — Layout-Entscheidung: Weekly + Yearly prominent, Monthly als eigene anklickbare Zeile darunter. Zwei Karten mit Radio-Indikator, ein CTA darunter. Reihenfolge Weekly → Yearly, Vorauswahl = Weekly. Weekly-Karte rendert nur, wenn das RevenueCat-Package existiert (
renderPlanCard), sonst fällt sie ersatzlos weg — mit Fallback auf Yearly, damit die Vorauswahl nie auf einen unsichtbaren Plan zeigt. -
Abschnitt 5.3 — Copy in de/es/en:
planWeeklyName,perWeek,proWeeklyPlanPriceBare,trialReminderWeekly(1 Tag statt 2, weil der Trial nur 3 Tage läuft). -
Abschnitt 7 —
selected_planliegt jetzt anpaywall_viewedan. -
Monatsäquivalent bleibt dem Jahresabo vorbehalten (Entscheidung vom 2026-08-10).
toMonthlyEquivalent(price, periodsPerYear)ersetzt die alte Jahres-Sonderlogik, wird aber nur für Yearly aufgerufen. Beim Wochenabo steht ausschließlich „2,99 € / Woche". Bewusst asymmetrisch: die Umrechnung erscheint dort, wo sie den Preis kleiner wirken lässt. Rechtlich unkritisch (Preis + Periode sind klar ausgewiesen, mehr verlangt weder Apple noch die PAngV), aber es ist die Stelle, an der ein genauer Leser Absicht erkennen kann — relevant, falls Refund-Quote oder Review-Tonfall später auffällig werden. -
Abschnitt 5.4 komplett —
checkTrialOrIntroductoryPriceEligibility(['weekly_pro','yearly_pro'])läuft im selbenPromise.allwie Offerings und Top-ups (eigenercatch, damit ein Ausfall der Prüfung nicht den Store-Load mitreißt). Nur ein klaresELIGIBLEgilt als berechtigt —UNKNOWNwird bewusst als „kein Trial" behandelt, so wie es die SDK-Doku empfiehlt. Nicht berechtigt → die Karte zeigt nurplanWeeklyNamePlain/planYearlyNamePlain, keindueSummary-Banner, CTA = „Jetzt starten", keine Trial-Erinnerung im Footer. -
trial_startedfeuerte bisher bei jedem Abo-Kauf, auch bei Monatlich ohne Testphase. Feuert jetzt nur noch, wenn das gekaufte Produkt tatsächlich eine Testphase hatte.paywall_viewedunterscheidet zusätzlichtrial_enabled(Plan sieht Trial vor) vontrial_eligible(Apple gewährt ihn diesem Nutzer). -
Abschnitt 6, Tests — 9 neue Testfälle. Server (
server/test/billing.test.js):weekly_prosteht inAVAILABLE_PRODUCTS, trägt ein gültiges Pro-Entitlement, Monthly/Yearly weiterhin auch, ein Top-up-Produkt weiterhin nicht, und die Allowance liegt bei 100 bzw. 30 im Trial. Client (__tests__/services/mockBackendService.test.ts): Kauf setzt Pro mit 100 Credits undrenewsAt+7 Tage,weekly_proist inavailableProducts, RevenueCat-Sync erkennt einweekly_pro-Abo, und einweekly_pro-Trial bekommt nur 30 Credits. Gegenprobe gemacht: mit zurückgedrehtemSUPPORTED_SUBSCRIPTION_PRODUCTSschlägt der Entitlement-Test fehl — der Test bewacht also tatsächlich die Regression.
Offen — alles außerhalb des Codes:
- Abschnitte 1–3 — Store- und RevenueCat-Setup (nur du).
- Abschnitt 9 — Sandbox-Verifikation, insbesondere die drei Trial-Szenarien.
- Optional: das „Abo verwalten"-Modal für bestehende Pro-Nutzer listet weiterhin nur Free / Pro monatlich / Pro jährlich. Unkritisch, weil dort jeder Tap ohnehin in die iOS-Einstellungen führt.
- Kosmetik:
offerCardOuter,offerBadge,offerAltLink& Co. sind jetzt ungenutzte Styles.
Hinweis zum Diff: services/backend/mockBackendService.ts hatte gemischte Zeilenenden;
mein Schreibvorgang hat die Datei einheitlich normalisiert. Dadurch zeigt git diff dort ~90
zusätzliche Zeilen, die inhaltlich identisch sind — git diff --ignore-all-space weist genau
die beabsichtigten 12 Einfügungen / 4 Löschungen aus. Alle anderen Dateien sind unauffällig.
1. App Store Connect (Apple)
- Neues Abo im BESTEHENDEN Subscription Group anlegen (nicht in einer neuen Gruppe — sonst kein Up-/Downgrade zwischen den Plänen und Apple behandelt sie als parallele Abos).
- Produkt-ID exakt
weekly_pro(gleiche Konvention wiemonthly_pro/yearly_pro; der Client matcht aufpkg.product.identifier === productId, billing.tsx:46). - Laufzeit: 1 Woche.
- Preis: 2,99 €, Preisplan für alle Territorien setzen.
- Group Ranking / Level: weekly = niedrigstes Level (unter monthly, unter yearly), damit Upgrades sofort und Downgrades zum Periodenende greifen.
- Lokalisierungen: Anzeigename + Beschreibung für de, es, en.
- Review-Screenshot der Paywall hochladen (Apple lehnt sonst ab).
- Introductory Offer: 3 Tage kostenlos auf
weekly_proanlegen (Typ „Free Trial"). Beim Anlegen prüfen, welche Laufzeiten das Dropdown für ein Wochenprodukt überhaupt anbietet — Apple schränkt die Trial-Dauer relativ zur Abo-Laufzeit ein. - ⚠️ Eligibility-Kollision bewusst machen: Intro-Offer-Eligibility gilt pro Subscription Group, nicht pro Produkt. Alle drei Abos liegen in derselben Gruppe. Wer den 3-Tage-Weekly-Trial nimmt, ist damit für den 7-Tage-Yearly-Trial verbrannt — und umgekehrt. Das muss die Paywall abbilden, siehe Abschnitt 5.4.
- Status auf „Ready to Submit". Neue In-App-Käufe werden nur zusammen mit einem App-Build reviewt — d. h. ohne neuen Build bleibt das Produkt hängen.
- App-Beschreibung prüfen: Apple 3.1.2 verlangt Laufzeit + Preis aller Abos in der Store-Beschreibung + Links zu Terms/Privacy.
2. Google Play (nur bei D4 = ja)
- Play Console → Monetarisierung → Abos → neues Abo
weekly_pro. - Basisplan
weekly-autorenewing, Abrechnungszeitraum 1 Woche, Preis, aktivieren. - Auf denselben Entitlement-Tag mappen.
3. RevenueCat
- Products → neues Produkt
weekly_proimportieren (App Store, ggf. Play). - Entitlements →
pro→weekly_proanhängen. (Entitlement-ID kommt ausREVENUECAT_PRO_ENTITLEMENT_ID, Defaultpro— billing.js:7, AppContext.tsx:56.) - Offerings → das
current-Offering → Package mit Identifier$rc_weeklyhinzufügen, Produktweekly_prozuweisen. Der Client löst überPACKAGE_TYPE.WEEKLYauf, sobald der Code aus Schritt 5 drin ist. - Warten bis RC den Store-Sync bestätigt („Product is available"), sonst kommt das Package
nicht in
Purchases.getOfferings()an. - Webhook: keine Änderung nötig,
/api/revenuecat/webhookbleibt (index.js:550).
4. Backend (server/)
Minimal bei Option A — aber alle drei Stellen sind Pflicht:
- billing.js:8 —
SUPPORTED_SUBSCRIPTION_PRODUCTSum'weekly_pro'erweitern. - billing.js:10-16 —
TOPUP_CREDITS_BY_PRODUCTumweekly_pro: 0. - billing.js:18 —
AVAILABLE_PRODUCTSum'weekly_pro'. - discord.js:12-13 —
PRODUCT_LABELSumweekly_pro: 'Pro (wöchentlich)', sonst steht im Sales-Channel eine rohe Produkt-ID.
Warum Punkt 1 nicht optional ist:
getValidProEntitlement(billing.js:332-360) verwirft ein aktivespro-Entitlement, wenn dieproductIdentifiernicht in der Menge steht. Ohne den Eintrag bleibt ein Weekly-Käufer beim Client-Sync auffree, bis zufällig der Webhook durchläuft (der Webhook-Pfad greift überentitlement_idsund würde funktionieren — der direkte Sync nach dem Kauf aber nicht).
Trial-Credits prüfen (wegen D3): TRIAL_MONTHLY_CREDITS = 30
(billing.js:4) wird produktunabhängig vergeben, sobald RevenueCat
period_type = trial meldet (billing.js:72,
billing.js:367). Ein 3-Tage-Weekly-Trial gibt damit 30 Credits für
den ganzen Kalendermonat — mehr, als der Trial wirtschaftlich hergibt. Entweder bewusst
akzeptieren (einfach) oder die Trial-Allowance produktabhängig machen (braucht dieselbe
Tier-Spalte wie Option B).
Bei Option B zusätzlich: neue Konstante WEEKLY_PRO_CREDITS, getMonthlyAllowanceForPlan
(billing.js:64), isAllowedMonthlyAllowance
(billing.js:76), getCycleBounds, Spalte + Migration in
ensureBillingSchema (billing.js:812).
5. Mobile App
5.1 Typen & Sync
- contracts.ts:5 —
PurchaseProductIdum'weekly_pro'. - AppContext.tsx:90 —
SUPPORTED_REVENUECAT_SUBSCRIPTION_PRODUCTSum'weekly_pro'(sonst greift die lokale Entitlement-Anwendung nach dem Kauf nicht, AppContext.tsx:120-124). - AppContext.tsx:466 —
availableProducts-Liste.
5.2 Paywall (app/profile/billing.tsx)
- :23
SubscriptionProductIdum'weekly_pro'. - :27
PaywallPlanIdum'weekly'. - :52-67
resolveSubscriptionPackages—weekly_proüberPACKAGE_TYPE.WEEKLYauflösen;offering.weeklyin die Kandidatenliste aufnehmen. - :480 Warn-Check um das Weekly-Package erweitern.
- :444 Default-Auswahl festlegen (Empfehlung:
'monthly'lassen). - :531-536
weeklyPackage+weeklyPrice(Fallback-Copy). - :646 Package-Auswahl von Ternary auf Map umstellen.
- Drei Stellen mit hartkodiertem
productId === 'monthly_pro' || productId === 'yearly_pro'(:598, :629, :640) durch einenisSubscriptionProductId()-Helper ersetzen — sonst läuft ein Weekly-Kauf in den Top-up-Zweig. - :1130 Verwaltungs-Modal (
handlePurchase('monthly_pro')) prüfen. - UI-Umbau: Die Paywall zeigt aktuell eine Angebotskarte + einen Textlink zur Alternative (:875-884). Das ist ein binäres Muster und trägt keine dritte Option. Ersetzen durch eine kompakte 3-Zeilen-Plan-Auswahl (weekly / monthly / yearly) über dem CTA. Das ist der größte Einzelposten im ganzen Plan.
yearlyMonthlyEquivalent(:747) analog für weekly → Monatsäquivalent, damit der Preisvergleich ehrlich bleibt.
5.3 Copy — dreimal, für de / es / en in getBillingCopy (:103-420)
Neue Keys: planWeeklyName, perWeek, proWeeklyPlanPriceBare (2,99 €), autoRenewWeekly,
altWeekly(price), planCardPriceWeekly(price). Pflichtangabe im Text: automatische
wöchentliche Verlängerung + Kündigungsweg (steht heute in autoRenewMonthly/autoRenewYearly).
Wegen D3 zusätzlich: planWeeklyNameTrial („Wöchentlich – 3 Tage gratis"),
ctaTrialWeekly, dueSummaryWeekly, trialReminderWeekly (die 2-Tage-Vorwarnung aus
trialReminder ist bei 3 Tagen Trial knapp — Formulierung anpassen oder auf 1 Tag ziehen),
plus je eine Nicht-berechtigt-Variante der Trial-Texte (siehe 5.4).
5.4 Trial-Eligibility — neu, weil D3 = Trial
Heute hat nur yearly_pro einen Trial, deshalb zeigt die Paywall die Trial-Texte
bedingungslos: planYearlyName („Jährlich - 7 Tage gratis"), das dueSummary-Banner
(„Heute fällig: 0,00 € - dann 39,99 € am …", billing.tsx:887-894)
und ctaTrial. Es gibt keinerlei Eligibility-Prüfung in der Codebase (verifiziert).
Mit zwei Trial-Produkten in einer Subscription Group wird das falsch: Nutzer, die ihren Group-Trial schon verbraucht haben, sehen weiterhin „0,00 € heute" und werden sofort abgebucht. Das ist ein Refund-/Chargeback-Treiber und ein Ablehnungsrisiko nach App-Store-Richtlinie 3.1.2 (irreführende Preisdarstellung).
- Beim Laden der Offerings (billing.tsx:463-509)
Purchases.checkTrialOrIntroductoryPriceEligibility(['weekly_pro', 'yearly_pro'])mitziehen und das Ergebnis in den State legen. - Trial-Copy pro Plan an die Eligibility hängen: bei „nicht berechtigt" fällt die Karte auf
reine Preisdarstellung zurück (kein „gratis", kein
dueSummary-Banner, CTA =ctaMonthly-Variante). - Ladezustand abfangen: solange die Eligibility unbekannt ist, keine Trial-Zusage rendern (lieber kurz neutral als kurz falsch).
- Analytics: Eligibility als Property an
paywall_viewedhängen — sonst sind die Conversion-Zahlen zwischen „hat Trial gesehen" und „hat keinen gesehen" vermischt.
Aufwand: ca. ein halber Tag. Ohne diesen Punkt ist D3 nicht releasefähig.
6. Mock & Tests
- mockBackendService.ts:48-57 — beide Konstanten.
- mockBackendService.ts:258 —
availableProducts. - mockBackendService.ts:1089 — Abo-Zweig in
simulatePurchase. __tests__/services/mockBackendService.test.tserweitern.server/test/billing.test.js: Test, dassweekly_prodas Pro-Entitlement erteilt (aktuell keine Produkt-IDs in dieser Datei — neuer Test).npm run test+npx tsc --noEmit.
7. Analytics
- billing.tsx:511-529 —
trial_enabledist ein Boolean, abgeleitet ausselectedPaywallPlan === 'yearly'. Mit drei Plänen ist Weekly im Funnel nicht von Monthly unterscheidbar.selected_plan: PaywallPlanIdals Property ergänzen und die PostHog-Funnels darauf umstellen. subscription_started/purchase_initiatedtragen bereitsproduct_id— ok.
8. Rechtliches / Landing
- PrivacyContent.tsx und Terms prüfen: Werden Abo-Laufzeiten dort einzeln aufgezählt? Dann Wochenabo ergänzen.
- App-Store-Beschreibung (siehe Abschnitt 1, letzter Punkt).
- Die Landing Page listet aktuell keine Preise — nichts zu tun, außer du willst es dort zeigen.
9. Build, Test, Release
- Dev-Build/TestFlight — Expo Go kann keine echten Käufe (
Constants.appOwnership === 'expo'→ Simulationspfad, billing.tsx:88). - Sandbox-Tester: Wochenabo kaufen → prüfen dass (a) Paywall alle drei Preise aus dem Store zieht (nicht die Fallback-Copy), (b) Plan sofort auf Pro springt (Client-Sync, Abschnitt 4/5.1), (c) Credits stimmen, (d) RevenueCat-Dashboard das Event zeigt, (e) Discord-Sales-Webhook „Pro (wöchentlich)" meldet, (f) „Käufe wiederherstellen" funktioniert.
- Trial-Szenarien mit zwei frischen Sandbox-Accounts (Eligibility lässt sich pro Account nicht zurücksetzen — pro Durchlauf ein neuer Tester): (a) frischer Account → Weekly zeigt „3 Tage gratis", Abbuchung erst an Tag 3; (b) Account, der den Weekly-Trial verbraucht hat → Yearly zeigt kein „7 Tage gratis" und kein „Heute fällig 0,00 €"; (c) umgekehrt: Yearly-Trial verbraucht → Weekly zeigt keinen Trial. Fällt (b) oder (c) durch, ist der Release blockiert (siehe 5.4).
- Upgrade weekly → monthly/yearly im Sandbox testen (Group Ranking).
- Backend deployen (Root-
docker-compose.yml), vor dem App-Release — sonst laufen erste Käufe gegen ein Backend, dasweekly_pronicht kennt. - Build & Submit:
npx eas-cli build:version:set -p ios→npx eas-cli build -p ios --profile production→npx eas-cli submit -p ios --latest - Beim Submit die neue Subscription im Review mit einreichen.
Reihenfolge
- D1–D4 entscheiden
- App Store Connect + RevenueCat (Vorlaufzeit: Store-Sync + Review)
- Backend (Abschnitt 4) → deployen
- App (5 + 6 + 7)
- Sandbox-Verifikation (9)
- Release
Größte Stolperfallen
- Trial-Eligibility gilt pro Subscription Group. Zwei Trial-Produkte in einer Gruppe, aber null Eligibility-Prüfung im Code → die Paywall verspricht „0,00 € heute" an Leute, die sofort abgebucht werden. Abschnitt 5.4, blockiert den Release.
SUPPORTED_SUBSCRIPTION_PRODUCTSan beiden Stellen (Server + AppContext) — sonst bleibt der Käufer nach dem Kauf auffree.- Die drei hartkodierten
monthly_pro || yearly_pro-Vergleiche inbilling.tsx. - Der binäre Paywall-Toggle trägt keine dritte Option — echte UI-Arbeit, nicht nur ein String.
- Neue IAPs brauchen einen App-Build im Review.
TRIAL_MONTHLY_CREDITS = 30gilt auch für den 3-Tage-Weekly-Trial.- Bei Option B:
isAllowedMonthlyAllowanceüberschreibt abweichende Allowances still.