Files
Greenlens/docs/superpowers/plans/2026-08-09-weekly-subscription-plan.md
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

20 KiB
Raw Blame History

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_id inkl. ALTER TABLE ... ADD COLUMN IF NOT EXISTS in ensureBillingSchema, (2) getCycleBounds muss vom Account abhängen statt global Kalendermonat zu sein, (3) alignAccountToCurrentCycle und isAllowedMonthlyAllowance mü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 34-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.js Label.

  • Abschnitt 5.1 komplett — contracts.ts, AppContext.tsx (beide Stellen).

  • Abschnitt 6, Mock-Teil — mockBackendService.ts; simulatePurchase nutzt jetzt das Produkt-Set statt hartkodierter Vergleiche, plus RENEWAL_DAYS_BY_SUBSCRIPTION (weekly = 7 Tage).

  • Abschnitt 5.2, Plumbing — SubscriptionProductId, neuer isSubscriptionProductId()-Helper (ersetzt alle drei hartkodierten monthly_pro || yearly_pro-Vergleiche), resolveSubscriptionPackages löst weekly_pro über PACKAGE_TYPE.WEEKLY auf, Package-Auswahl beim Kauf über subscriptionPackages[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_plan liegt jetzt an paywall_viewed an.

  • 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 selben Promise.all wie Offerings und Top-ups (eigener catch, damit ein Ausfall der Prüfung nicht den Store-Load mitreißt). Nur ein klares ELIGIBLE gilt als berechtigt — UNKNOWN wird bewusst als „kein Trial" behandelt, so wie es die SDK-Doku empfiehlt. Nicht berechtigt → die Karte zeigt nur planWeeklyNamePlain / planYearlyNamePlain, kein dueSummary-Banner, CTA = „Jetzt starten", keine Trial-Erinnerung im Footer.

  • trial_started feuerte bisher bei jedem Abo-Kauf, auch bei Monatlich ohne Testphase. Feuert jetzt nur noch, wenn das gekaufte Produkt tatsächlich eine Testphase hatte. paywall_viewed unterscheidet zusätzlich trial_enabled (Plan sieht Trial vor) von trial_eligible (Apple gewährt ihn diesem Nutzer).

  • Abschnitt 6, Tests — 9 neue Testfälle. Server (server/test/billing.test.js): weekly_pro steht in AVAILABLE_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 und renewsAt +7 Tage, weekly_pro ist in availableProducts, RevenueCat-Sync erkennt ein weekly_pro-Abo, und ein weekly_pro-Trial bekommt nur 30 Credits. Gegenprobe gemacht: mit zurückgedrehtem SUPPORTED_SUBSCRIPTION_PRODUCTS schlägt der Entitlement-Test fehl — der Test bewacht also tatsächlich die Regression.

Offen — alles außerhalb des Codes:

  • Abschnitte 13 — 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 wie monthly_pro/yearly_pro; der Client matcht auf pkg.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_pro anlegen (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_pro importieren (App Store, ggf. Play).
  • Entitlementsproweekly_pro anhängen. (Entitlement-ID kommt aus REVENUECAT_PRO_ENTITLEMENT_ID, Default probilling.js:7, AppContext.tsx:56.)
  • Offerings → das current-Offering → Package mit Identifier $rc_weekly hinzufügen, Produkt weekly_pro zuweisen. Der Client löst über PACKAGE_TYPE.WEEKLY auf, 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/webhook bleibt (index.js:550).

4. Backend (server/)

Minimal bei Option A — aber alle drei Stellen sind Pflicht:

  • billing.js:8SUPPORTED_SUBSCRIPTION_PRODUCTS um 'weekly_pro' erweitern.
  • billing.js:10-16TOPUP_CREDITS_BY_PRODUCT um weekly_pro: 0.
  • billing.js:18AVAILABLE_PRODUCTS um 'weekly_pro'.
  • discord.js:12-13PRODUCT_LABELS um weekly_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 aktives pro-Entitlement, wenn die productIdentifier nicht in der Menge steht. Ohne den Eintrag bleibt ein Weekly-Käufer beim Client-Sync auf free, bis zufällig der Webhook durchläuft (der Webhook-Pfad greift über entitlement_ids und 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

5.2 Paywall (app/profile/billing.tsx)

  • :23 SubscriptionProductId um 'weekly_pro'.
  • :27 PaywallPlanId um 'weekly'.
  • :52-67 resolveSubscriptionPackagesweekly_pro über PACKAGE_TYPE.WEEKLY auflösen; offering.weekly in 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 einen isSubscriptionProductId()-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_viewed hä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:258availableProducts.
  • mockBackendService.ts:1089 — Abo-Zweig in simulatePurchase.
  • __tests__/services/mockBackendService.test.ts erweitern.
  • server/test/billing.test.js: Test, dass weekly_pro das Pro-Entitlement erteilt (aktuell keine Produkt-IDs in dieser Datei — neuer Test).
  • npm run test + npx tsc --noEmit.

7. Analytics

  • billing.tsx:511-529trial_enabled ist ein Boolean, abgeleitet aus selectedPaywallPlan === 'yearly'. Mit drei Plänen ist Weekly im Funnel nicht von Monthly unterscheidbar. selected_plan: PaywallPlanId als Property ergänzen und die PostHog-Funnels darauf umstellen.
  • subscription_started / purchase_initiated tragen bereits product_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, das weekly_pro nicht kennt.
  • Build & Submit: npx eas-cli build:version:set -p iosnpx eas-cli build -p ios --profile productionnpx eas-cli submit -p ios --latest
  • Beim Submit die neue Subscription im Review mit einreichen.

Reihenfolge

  1. D1D4 entscheiden
  2. App Store Connect + RevenueCat (Vorlaufzeit: Store-Sync + Review)
  3. Backend (Abschnitt 4) → deployen
  4. App (5 + 6 + 7)
  5. Sandbox-Verifikation (9)
  6. Release

Größte Stolperfallen

  1. 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.
  2. SUPPORTED_SUBSCRIPTION_PRODUCTS an beiden Stellen (Server + AppContext) — sonst bleibt der Käufer nach dem Kauf auf free.
  3. Die drei hartkodierten monthly_pro || yearly_pro-Vergleiche in billing.tsx.
  4. Der binäre Paywall-Toggle trägt keine dritte Option — echte UI-Arbeit, nicht nur ein String.
  5. Neue IAPs brauchen einen App-Build im Review.
  6. TRIAL_MONTHLY_CREDITS = 30 gilt auch für den 3-Tage-Weekly-Trial.
  7. Bei Option B: isAllowedMonthlyAllowance überschreibt abweichende Allowances still.