Skip to content

Supporter Tier — Product Deep Dive

Objectuve's first monetization layer. Optional perks — mostly cosmetic, with one functional convenience as of v4.67 — that fund independent development without ever taking anything away from the free experience.


Overview

The Supporter tier is Objectuve's optional subscription for users who want to contribute to the mission. It follows the Public Benefit Corporation pricing philosophy: no features are ever paywalled — a Supporter perk is never required, and no capability a free user has is ever taken away or degraded to sell it back. Most Supporter perks are cosmetic (themes, badge, early access); Streak Insurance (v4.67) is the first that does something, and it clears the same bar — every free recovery lane (proactive freezes, XP-based Streak Repair, Streak Mercy) stays exactly as it was.

Related docs:

TierPriceBillingKey Perk
Free$0 foreverN/AFull-featured goal tracking
Supporter Monthly$4/monthRecurringCustom themes + profile badge
Supporter Yearly$36/yearRecurring (~25% savings)Custom themes + profile badge
Lifetime Supporter$99 one-timeOne-time (user sets amount)Permanent access + Legendary badge

What Supporters Get

  1. 9 custom app color themes, or build your own — Sunset, Ocean, Forest, Midnight, Rose, Ember, Lavender, Gold Rush, Slate — plus a Theme Creator for picking a fully custom primary/accent color, with every result guaranteed WCAG AA-readable. See Theme Creator below.
  2. Supporter badge on profile — Star icon (monthly/yearly) or crown (lifetime), using the --gold design token. Since v4.47 wave 2 (OBJ-3019), the badge carries role="img" and a tier-aware aria-label ("Supporter" / "Lifetime Supporter") — both production call sites (Dashboard.vue, MemberProfileModal.vue) render it at size="sm", where the icon-only glyph previously had no accessible name at all (SupporterBadge.vue:1-20)
  3. Early access to new features — Future: beta opt-in before public release
  4. Streak Insurance (v4.67) — a free-of-XP way to restore a broken habit streak, once per calendar month. See Streak Insurance below.

What Supporters Do NOT Get

  • No goal-tracking advantage over free users — every recovery lane a free user has (proactive freezes, XP-based Streak Repair, Streak Mercy) stays exactly as it is; Streak Insurance is a no-XP-cost, rationed convenience on top, not a capability free users lack
  • No extra goals, communities, or AI coaching
  • No priority support (reserved for Teams tier)
  • No free capability ever gated behind Supporter — Streak Insurance is additive; nothing moved behind the paywall to create it

Streak Insurance

Status: shipped behind streak_insurance_enabled, at 0% rollout as of this writing — not yet reachable by any real user. See docs/operations/rollouts/v4.67-streak-insurance.md for the ramp plan.

A third streak-recovery lane, alongside the free proactive freeze and the free once-per-goal XP repair (see Habits § Streak Repair and Streak Insurance):

  • Who: any Supporter tier (monthly, yearly, lifetime).
  • Cost: no XP — the free XP-repair lane (streak_repaired_count) is never touched by a claim, so using Insurance never spends a habit's free repair.
  • Allowance: one claim per calendar month, across all of a Supporter's habits (INSURANCE_MONTHLY_ALLOWANCE = 1 in Gamification::StreakRepairOfferResolver; see Game Balance § Streak Insurance Monthly Allowance).
  • Window: same 48-hour streak_repair_eligible_until window the free repair lane uses — a claim must be made before it closes.
  • Where: the Dashboard's streak-repair card (StreakRepairCard.vue), routed through the existing repair-confirmation modal's insurance variant. Not available on Goal Detail.
  • Entry point: Gamification::ClaimStreakInsurance interaction, exposed via the claimStreakInsurance GraphQL mutation.

This is the first Supporter perk that does something rather than something purely visual — it does not, however, provide a goal-tracking advantage a free user lacks: every free recovery path (freeze, XP repair, Streak Mercy) is untouched exactly as it was before this perk existed, and none of it was narrowed or removed to create Insurance.


Architecture

Database Schema

Supporter status lives directly on the users table to avoid joins on every request:

users
├── supporter_tier      (string)   — nil, "monthly", "yearly", "lifetime"
├── supporter_until     (datetime) — when access expires (nil = lifetime/never)
└── stripe_customer_id  (string)   — Stripe Customer ID for billing

Two supporting tables:

plans
├── public_id, name, slug, price_cents, currency
├── interval            (string)   — "month", "year", nil (one-time)
├── stripe_price_id     (string)   — maps to Stripe Price object
└── active              (boolean)

payment_records
├── public_id, user_id, plan_id
├── stripe_checkout_session_id, stripe_subscription_id
├── status              (string)   — pending, completed, failed, refunded
└── amount_cents, currency

Stripe Integration

Payment flow: Stripe Checkout (hosted redirect). Users never enter card details in-app. Zero PCI scope.

User clicks "Continue to Checkout"
  → Frontend calls CreateCheckoutSession mutation
    → Backend creates Stripe Checkout Session via StripeService
      → Returns checkout URL
  → Frontend redirects to Stripe-hosted page
  → User completes payment on Stripe
  → Stripe sends webhook to /webhooks/stripe
    → Backend updates supporter_tier + supporter_until on user
  → Stripe redirects to /checkout/success
    → Frontend polls USER_QUERY until isSupporter = true

⚠️ Recurring (Monthly/Yearly) checkout requires a provisioned Stripe price id. Monthly and Yearly redirect to Stripe via a stored plans.stripe_price_id. Lifetime does not — it builds an inline price_data, so it works without any stored price. The seed migration leaves stripe_price_id nil, so until it is provisioned, Billing::CreateCheckout fails fast (:invalid_input) with a clear user-facing message — "Monthly and yearly aren't quite ready for checkout — Lifetime works today, or check back soon." — instead of surfacing a cryptic Stripe error and a dead button.

To make recurring checkout live:

  1. Create the recurring Prices in the Stripe dashboard (see Go-Live → Stripe Dashboard Setup) and copy each price_… id.
  2. Provide them to the app one of two ways:
    • Env-driven (preferred): set STRIPE_PRICE_MONTHLY_V2 and STRIPE_PRICE_YEARLY_V2 in Cloud Run env / GCP Secret Manager, then run (or re-run) the UpdatePlanPrices migration — it copies the env values onto the monthly / yearly Plan rows.
    • Direct: Plan.find_by(slug: 'monthly').update!(stripe_price_id: 'price_…') (and the same for yearly) via a Rails console.
  3. Confirm: Plan.where(slug: %w[monthly yearly]).pluck(:slug, :stripe_price_id) returns non-nil ids.

Webhook events handled:

  • checkout.session.completed — Activate supporter tier
  • customer.subscription.updated — Update billing period
  • customer.subscription.deleted — Mark access end date
  • charge.refunded — Immediately revoke access
  • invoice.payment_failed — Stripe retries automatically (3 attempts over 3 weeks)

⚠️ Reading Stripe subscription fields: item-level, not subscription-level. Under Stripe API versions 2025-03-31.basil and later, current_period_end no longer exists on the Subscription object — it lives only on each SubscriptionItem. Always read it off subscription.items.data (take the max across items, since a subscription's items can carry different periods) with a fallback to the legacy top-level field, guarding every hop with respond_to? — either shape may legitimately be absent depending on the live webhook endpoint's configured API version. See Billing::ProcessStripeWebhook#subscription_period_end (rails_api/app/interactions/billing/process_stripe_webhook.rb) for the reference implementation, including the Sentry.capture_message tripwire fired when neither shape resolves — this must never raise. Raising would 500 the webhook, and Stripe's retry would be swallowed by the already_processed? idempotency guard, turning a real gap into a silently-never-retried one.

Stripe.api_version (config/initializers/stripe.rb) pins OUTBOUND calls only — it has no effect on inbound webhook payload shape. The webhook endpoint's API version is a setting on the endpoint object in the Stripe dashboard, fixed at creation and never auto-upgraded; this codebase cannot see or control it. The only defense against payload shape drifting out from under this code is version-tolerant parsing (above) plus the Sentry tripwire — not the initializer pin. (OBJ-1410 — the subscription-level-only read was a live landmine on the production endpoint the moment the first monthly renewal landed: it would have silently returned nil, starving the supporter_until extension write while Billing::ExpireSupportersJob kept stripping access on schedule. Confirmed zero blast radius at fix time — no subscription had yet existed on the account.)

Self-service billing: The Stripe Billing Portal handles payment method updates, invoice viewing, and plan changes. Accessed via GetBillingPortalUrl mutation.

Subscription Lifecycle

Statesupporter_tiersupporter_untilBehavior
Free usernilnilNo access to supporter perks
Active monthly"monthly"Future dateFull access
Active yearly"yearly"Future dateFull access
Lifetime"lifetime"nilPermanent access, never expires
Canceled (grace)"monthly"/"yearly"Future dateAccess until period end
ExpirednilnilCleared by nightly ExpireSupportersJob

Expiration: The Billing::ExpireSupportersJob runs nightly at 3:00 AM via Crono. It clears supporter_tier and supporter_until for users whose supporter_until has passed (excluding lifetime supporters).


Backend Implementation

Key Files

FilePurpose
app/models/plan.rbPlan definitions (PublicRecord, acts_as_paranoid)
app/models/payment_record.rbPayment audit trail
app/models/user.rbsupporter?, supporter_active_tier methods
app/services/stripe_service.rbStripe API wrapper (checkout, portal, cancel)
app/interactions/billing/create_checkout.rbCheckout session creation logic
app/controllers/webhooks/stripe_controller.rbStripe webhook handler
app/jobs/billing/expire_supporters_job.rbNightly expiration sweep
app/graphql/types/plan_type.rbPlan GraphQL type
app/graphql/mutations/create_checkout_session.rbCheckout mutation
app/graphql/mutations/cancel_supporter_subscription.rbCancel mutation
app/graphql/mutations/get_billing_portal_url.rbPortal URL mutation

GraphQL API

Query:

graphql
query PlansQuery {
  plans {
    publicId, name, slug, priceCents, currency, interval, priceDisplay, active
  }
}

User fields (added to existing UserType):

graphql
user(id: $id) {
  isSupporter      # Boolean — computed from supporter? method
  supporterTier    # String — "monthly", "yearly", "lifetime", or null
  supporterUntil   # ISO8601DateTime — null for lifetime
  colorTheme       # String — Supporter color theme slug, synced across devices. Defaults to "default".
}

Mutations:

graphql
mutation CreateCheckoutSession($planSlug: String!, $successUrl: String!, $cancelUrl: String!, $amountCents: Int) {
  createCheckoutSession(...) { checkoutUrl, errors }
}

mutation CancelSupporterSubscription {
  cancelSupporterSubscription { success, errors }
}

mutation GetBillingPortalUrl($returnUrl: String!) {
  getBillingPortalUrl(returnUrl: $returnUrl) { portalUrl, errors }
}

mutation SetColorTheme($slug: String!) {
  setColorTheme(slug: $slug) { colorTheme, errors }
}

setColorTheme requires auth (FORBIDDEN/UNAUTHORIZED raised as a GraphQL::ExecutionError for an unauthenticated caller or a supporterOnly slug on a non-Supporter); an unknown slug returns a non-raising errors: [...] instead. default is always permitted regardless of Supporter status.

Environment Variables

VariablePurpose
STRIPE_SECRET_KEYStripe API secret key
STRIPE_WEBHOOK_SECRETWebhook signature verification
STRIPE_PRICE_MONTHLY_V2Recurring Stripe price id for the monthly plan, copied onto plans.stripe_price_id by the UpdatePlanPrices migration. Monthly checkout fails closed until this is set.
STRIPE_PRICE_YEARLY_V2Recurring Stripe price id for the yearly plan, copied onto plans.stripe_price_id by the UpdatePlanPrices migration. Yearly checkout fails closed until this is set.

Stored in GCP Secret Manager, injected as environment variables in Cloud Run.


Frontend Implementation

Theme System

Themes use CSS custom properties with data-theme attribute on <html>. The light/dark mode toggle (class="dark") and color theme are independent axes — a user can pick "Sunset" in dark mode.

CSS architecture:

<html class="dark" data-theme="sunset">
  ↓ tailwind.css defines base variables
  ↓ supporter-themes.css overrides for data-theme="sunset"
  ↓ html.dark overrides for dark mode
  ↓ html.dark[data-theme="sunset"] overrides for dark + sunset

Each theme overrides ~15 CSS custom properties: --primary, --primary-foreground, --primary-light, --primary-glow, --accent, --accent-light, --gradient-primary, --gradient-accent, --gradient-hero, --gradient-card, --shadow-glow, --shadow-accent, --ring.

Persistence — backend-synced, localStorage as instant cache:

The selected slug persists server-side in user_details.data.supporter_color_theme (jsonb store_accessor, no migration — mirrors the critical_path_active_theme mini-game theme pattern), exposed as the colorTheme field on User/UserSearchResult and written via the setColorTheme mutation. Selecting a theme still applies instantly via localStorage + data-theme (useTheme.ts's setColorTheme), then fires setColorTheme best-effort in the background — a failed mutation (offline, network error) is swallowed and never blocks the UI or shows an error, since this is a cosmetic perk.

On session sync (router boot's syncClerkUser(), and sign-in's fallbackLogin()), hydrateColorThemeFromServer() applies the server's colorTheme when it's present and differs from the local value — last-sync-wins, no merge logic — so a second device picks up the synced palette.

Lapsed-Supporter behavior: hydrate always applies the stored slug regardless of current Supporter status (no live re-check on hydrate, same as critical_path_active_theme) — a lapsed Supporter keeps seeing their previously-chosen palette. Only new selection of a supporterOnly slug is blocked for non-Supporters, enforced on both the frontend (useTheme.ts's setColorTheme gate) and the backend (Supporter::SetColorTheme's theme_permitted guard); default is always selectable.

Theme Creator

Beyond the 9 hand-authored palettes, Supporters can build a fully custom theme via an 11th tile ("Custom") in ThemeSelector.vue's grid, which opens ThemeCreator.vue.

Picker mechanism: three sliders — Primary hue, Accent hue (both 0–360°), and Depth (saturation, 0–100%, labeled Soft / Balanced / Rich). No raw hex input, by design — full role="slider" semantics with pointer drag and arrow/Shift+arrow/Page/Home/End keyboard support. Lightness is never user-controlled; it's always seeded at CUSTOM_THEME_SEED_LIGHTNESS (45) and left for the AA clamp below to adjust.

AA-safe derivation: deriveCustomTheme(primaryHsl, accentHsl) (src/lib/customTheme.ts) is the single source of truth for the contrast math shared with tests/unit/tokenDrift.spec.ts — duplicating it is exactly the drift that spec guards against. It derives the same ~15-variable token set the 9 hand-authored palettes define (primary/light/glow, accent/light, gradients, shadows, ring) for both light and dark, searching outward from the picked lightness until every WCAG AA condition holds (4.5:1 text, 3:1 large-text/UI). A pick that would fail AA is nudged, never silently accepted or hard-rejected: the live preview (ThemePreviewTile.vue, reused) always renders the clamped result, and an aria-live="polite" info chip — "Nudged for readability." — explains the adjustment in plain language when it happens. The panel's CTA is always a solid --primary fill, never a gradient, since gradient text-over-background contrast can't be guaranteed at every stop.

Reset: returns to the Supporter's last-selected preset (lastPresetTheme, persisted in useTheme.ts so it survives a reload even while a custom theme is active), defaulting to default if none.

Persistence — localStorage only, not yet cross-device: unlike the 9 presets (which sync server-side via setColorTheme, see above), a custom pick is not sent to the backend. setCustomTheme stores the payload ({ primaryHue, accentHue, saturation } — hues, not a slug, so a future backend field can carry it verbatim) under the customThemePayload localStorage key and sets colorTheme to 'custom' locally only. hydrateColorThemeFromServer explicitly skips overwriting an active 'custom' selection (colorTheme.value === 'custom' guard) so a stale server preset slug can't silently revert a Supporter out of custom mode on a fresh session sync — but the custom color itself still doesn't follow a Supporter to a second device the way a preset does. Cross-device sync of the actual hue/depth payload is a known follow-up, tracked separately from the OBJ-1757 preset-sync work described above.

Key Frontend Files

FilePurpose
src/theme/supporter-themes.css9 theme palettes (CSS variables)
src/config/themes.tsTheme registry with metadata and preview colors
src/lib/customTheme.tsderiveCustomTheme — shared WCAG contrast math + AA-safe custom token derivation (also imported by tokenDrift.spec.ts)
src/composables/useTheme.tsTheme state management (mode + color theme + custom payload); fires setColorTheme mutation on preset selection, hydrates from server via hydrateColorThemeFromServer() on session sync, persists lastPresetTheme for Reset
src/composables/useSupporter.tsReactive supporter status from Apollo cache
src/constants/graphql/user.jsSET_COLOR_THEME_MUTATION and other user/session GraphQL operations
src/components/supporter/ThemeSelector.vueTheme grid with lock indicators + the Custom tile
src/components/supporter/ThemeCreator.vueTheme Creator panel — hue/depth sliders, live preview, AA-nudge feedback, reset
src/components/supporter/SupporterUpgradeModal.vuePlan selection + Stripe checkout
src/components/supporter/SupporterBadge.vueInline badge (star/crown icon)
src/views/CheckoutSuccess.vuePost-checkout celebration page
src/views/Settings.vueSupporter tab with billing management
src/constants/graphql/billing.jsGraphQL queries/mutations for billing

CTA Surfaces

Three non-intrusive discovery points (consistent with anti-social app philosophy):

  1. Theme selector (Settings > Supporter tab) — Locked themes show a lock icon and "Supporter" pill. Tapping opens the upgrade modal.
  2. Settings Supporter tab — Dedicated section with perks list and "View Plans" button.
  3. Profile badge — Supporter badges visible on profiles in communities and feeds create organic social proof without any explicit CTA.

No banners, no interstitials, no popups, no countdown timers, no FOMO language.


Theme Palette Reference

The 10 hand-authored presets below always exist regardless of what a Supporter builds in the Theme Creator (custom, not listed here — its colors are user-generated, not fixed).

ThemeSlugPrimaryAccentSupporter-Only
Legacy Bluedefaulthsl(203 78% 41%)hsl(27 89% 55%)No
Sunsetsunsethsl(15 85% 55%)hsl(35 90% 55%)Yes
Oceanoceanhsl(185 75% 42%)hsl(200 80% 50%)Yes
Forestforesthsl(155 70% 38%)hsl(35 75% 50%)Yes
Midnightmidnighthsl(250 70% 58%)hsl(280 65% 55%)Yes
Roserosehsl(340 75% 55%)hsl(15 70% 55%)Yes
Emberemberhsl(5 80% 52%)hsl(30 85% 52%)Yes
Lavenderlavenderhsl(270 60% 62%)hsl(310 55% 55%)Yes
Gold Rushgold-rushhsl(43 90% 48%)hsl(27 85% 52%)Yes
Slateslatehsl(220 15% 45%)hsl(220 10% 55%)Yes

iOS/Android Strategy

Native in-app purchases are shipped via RevenueCat (@revenuecat/purchases-capacitor). Apple and Google require in-app purchases for digital goods sold within native apps, so the purchase path branches by platform:

  • On web: Stripe Checkout redirect (unchanged — see Stripe Integration above)
  • On native (Capacitor, iOS/Android): RevenueCat's native purchase sheet via useInAppPurchases() (ionic_frontend/src/composables/useInAppPurchases.ts)

How it works:

  1. App.vue calls useInAppPurchases().identify(publicId) on sign-in, setting the RevenueCat app_user_id to the user's public_id — this is what lets the backend webhook resolve the purchasing user.
  2. SupporterUpgradeModal.vue calls getPackages() to fetch the current RevenueCat offering and purchase(pkg) to launch the native purchase sheet. A cancelled sheet returns { cancelled: true } (not treated as an error); a completed purchase resolves once the supporter entitlement is active.
  3. Settings.vue's Supporter tab uses getManagementUrl() to deep-link native users to the OS subscription manager (App Store / Play Store) instead of the Stripe billing portal.
  4. RevenueCat posts purchase/renewal/refund/expiration events to Webhooks::RevenueCatController, which authenticates the request (constant-time comparison against REVENUECAT_WEBHOOK_AUTH) and hands off to Billing::ProcessRevenueCatWebhook. That interaction maps the store product id to a Plan slug (objectuve_supporter_monthly / _yearly / objectuve_lifetime_99), handles idempotency via PaymentRecord.provider_event_id, creates the PaymentRecord, and publishes the same billing.payment_processed domain event used by the Stripe path — so downstream subscribers apply the supporter-tier update identically regardless of provider.

Product id → plan mapping (kept in sync between useInAppPurchases.ts's PRODUCT_SLUG and the backend's PRODUCT_MAP):

Store product idPlan slug
objectuve_supporter_monthlymonthly
objectuve_supporter_yearlyyearly
objectuve_lifetime_99lifetime

Google Play subscription product ids can arrive as <product_id>:<base_plan_id>; both the frontend and backend match on the base id so either store resolves correctly.

Test coverage: ionic_frontend/tests/unit/composables/useInAppPurchases.spec.ts, tests/unit/components/supporter/SupporterUpgradeModal.spec.ts, tests/unit/views/Settings.spec.ts (frontend); rails_api/spec/controllers/webhooks/revenue_cat_controller_spec.rb, spec/interactions/billing/process_revenue_cat_webhook_spec.rb, spec/lib/billing/subscribers_spec.rb (backend).

Native copy contract (App Store 3.1.1 / 2.3.7)

The purchase mechanics are compliant by construction — RevenueCat, StoreKit-sourced pricing, no Stripe on native. Historically the recurring gap was copy: strings written without a native/web branch decision, surfacing donation, funding, or contribution framing (Guideline 3.1.1) or a non-StoreKit price (Guideline 2.3.7) on a native build.

Scope note on 4.4.1's 2.3.7 citation: on submission 1772712c-42d6-436a-bfbf-80e43765d6e8 (reviewed 2026-08-03, version 4.4.1 build 116) Apple cited 2.3.7 against the App Store screenshots, not against any in-app string — that half was remediated in scripts/capture-store-screenshots.mjs and docs/marketing/store-assets/README.md; the in-app copy obligation on that same submission is its separate 3.1.1 citation, which is what the rule below enforces.

Verification-status correction (OBJ-3034, 2026-08-31): this section previously implied OBJ-2182 had already closed the 4.4.1 rejection. That reading was wrong — it's a build-vs-fix timing gap, not a re-opened issue:

EventDate
v4.4.1 tagged2026-07-31
App Store reviewed build 116 (submission 1772712c-42d6-436a-bfbf-80e43765d6e8)2026-08-03
OBJ-2182 copy remediation merged (PR #2116, commit f17da3861)2026-08-06
First shipped release containing the fixv4.4.5

The rejected build predates the fix by five days — Apple reviewed a binary that couldn't contain a remediation that hadn't merged yet. Every unbranched string Apple's reviewer could have seen is gone on master today. That said, OBJ-3034's investigation (Orion, 2026-08-31) found the mechanics audit below had never covered two adjacent surfaces, closed in the same package as this correction:

  • Finding A — the guide site was never in the audited surface. OBJ-2182 audited the native app itself; it never audited guide_site/supporter-tier.md, which the native app links into via SideMenu.vue's "Help & Guide" item. The page carried donation framing, a non-StoreKit price table, and a Stripe-only billing claim — a direct, in-app-reachable 3.1.1/2.3.7 exposure that predated this fix. Rewritten platform-safe in the same package (Task 1, guide_site/supporter-tier.md). HELP_TOPICS.supporter (ionic_frontend/src/constants/urls.ts) still has no live call site as of this writing — the guide copy is now safe to link to, but nothing links to it yet natively.
  • Finding B — the RevenueCat API key had no non-empty CI gate. VITE_REVENUECAT_IOS_KEY / VITE_REVENUECAT_ANDROID_KEY were threaded into the mobile build workflows but nothing asserted either resolved non-empty. An unset key makes useInAppPurchases.ts's configure() return early after a Sentry warning, getPackages() returns [], and SupporterUpgradeModal.vue renders "Supporter plans aren't available right now" — a dead purchase surface on a reviewer's device with zero build-time signal, and a plausible independent explanation for a 3.1.1 citation. Closed by scripts/check-revenuecat-keys.mjs — a structural PR-time gate (both workflows still wire the secrets) plus a resolved dispatch-time gate scoped to appstore/testflight dispatches only, mirroring scripts/check-ios-google-client-id.mjs.

Also landed in the same package: an App Store Guideline 3.1.2 subscription disclosure block (auto-renew statement, duration, Terms of Use / Privacy Policy links) added to SupporterUpgradeModal.vue, since a 3.1.x resubmission is exactly where a missing disclosure gets cited alongside whatever else is checked.

The rule: any new supporter-adjacent user-facing string needs an explicit isNative branch decision at write time. On native: no donation / funding / contribution / mission-support / "keep it free" framing, and no price outside StoreKit. Native framing is perks + purchase — custom color themes, Supporter profile badge, early access, and (as of v4.67) the Streak Insurance recovery lane — the same vocabulary already shipped in SupporterHero.vue:48-57 and SupporterUpgradeModal.vue:155-161. The perk list is no longer cosmetics-only — Streak Insurance is a functional convenience — but the underlying prohibition is unchanged: still no donation/funding framing on native, still no price outside StoreKit. Web framing is unchanged and unaffected — the mission-support framing is accurate there.

The pattern: assign Capacitor.isNativePlatform() once to a module-scope const isNative (or reuse a file's existing isNativePlatform), then branch either a computed (ctaCopy in OurStory.vue) or an inline ternary at the string's call site — never call isNativePlatform() inline in the template.

Audited surface inventory (OBJ-2182, PR #2116) — every native supporter-adjacent string branched or explicitly ruled to leave as-is. Scope note (OBJ-3034): this inventory covers in-app strings only. The guide site (guide_site/supporter-tier.md, reachable in-app via SideMenu.vue's "Help & Guide" link) is also in scope for this contract and was not audited until OBJ-3034 Task 1 — see Finding A above.

SurfaceFileNative stringWeb string (unchanged)
Settings hub — Supporter tile hintSettingsHub.vue:463Themes, badge, early accessKeep this thing free
Our Story — hero CTA buttonOurStory.vue:134 (reads ctaCopy.button, computed at :35-51)View Supporter plansBecome a Supporter
Our Story — timeline TODAY entryOurStory.vue:60-65Same small team. No VCs please. No exit clock.Funded by Supporters. No VCs please. No exit clock.
Checkout success — confirmed-state bodyCheckoutSuccess.vue:16,102-104Your custom themes and Supporter badge are active. Early access to new features starts now.Thank you for supporting independent development. Your custom themes and badge are now active.
Paid confirmation card — headlinePaidConfirmationCard.vue:29 (optional headline prop, defaulted at :12, wired from Settings.vue:1010-1011)You're a Supporter. Perks are live.You're supporting Objectuve. Thank you.
Our Story — charter pull-quoteOurStory.vue:226(byte-identical both platforms — leave as-is)same
Our Story — governance lineOurStory.vue:242(byte-identical both platforms — leave as-is)same

PaidConfirmationCard.vue stays presentational — no Capacitor import — the branch decision is made by its caller (Settings.vue) and passed in as a prop.

Already compliant, do not re-touch: SupporterUpgradeModal.vue, SupporterHero.vue, Settings.vue's pricing/plan logic, and settingsSearchIndex.ts were remediated in an earlier pass (PR #1834) and verified untouched by this one.

Verification limit — no CI gate covers native in-app copy.scripts/check-store-asset-copy-drift.mjs gates App Store screenshot copy only (the SLIDES array in scripts/capture-store-screenshots.mjs against the committed PNGs) — it has no visibility into any string inside the app itself. Native in-app copy isn't reachable from the Playwright smoke lane either — it runs against a browser, where Capacitor.isNativePlatform() is always false, so every native branch is structurally unreachable from that suite. Vitest coverage (both branches asserted per surface, per SupporterUpgradeModal.spec.ts and friends) plus a manual device/simulator pass before App Store resubmission remain the only real verification of these strings — there is no automated substitute for either.

Full ruling and rationale (including the two borderline calls and the measured layout-fit tradeoff on L1): docs/ui-specs/native-supporter-copy-contract.md.


Testing

Backend

  • spec/models/plan_spec.rb — Validations, scopes, lifetime?
  • spec/models/user_spec.rbsupporter? method for active/expired/lifetime
  • spec/interactions/billing/create_checkout_spec.rb — Happy path and validation errors
  • spec/services/stripe_service_spec.rb — Mocked Stripe API calls
  • spec/controllers/webhooks/stripe_controller_spec.rb — Signature verification, event handling
  • spec/jobs/billing/expire_supporters_job_spec.rb — Expiration logic

Frontend

  • tests/unit/composables/useTheme.spec.ts — Color theme selection and gating, custom-theme apply/persist, lastPresetTheme persistence, hydrateColorThemeFromServer's custom-mode guard
  • tests/unit/components/SupporterBadge.spec.ts — Renders per tier
  • tests/unit/components/ThemeSelector.spec.ts — Locked state and click behavior, Custom tile
  • tests/unit/components/supporter/ThemeCreator.spec.ts — Slider interaction/keyboard semantics, AA-nudge feedback, reset, Storybook states
  • tests/unit/lib/customTheme.test.tsderiveCustomTheme AA fuzz suite across a hue sweep, light + dark

Manual QA

  • Use Stripe test mode with card 4242 4242 4242 4242
  • Simulate webhook events with stripe trigger checkout.session.completed
  • Test all 10 presets x 2 modes (light/dark) = 20 visual states, plus the Custom tile's picker/preview/nudge/reset flow

Next Steps — Go-Live Checklist

1. Install Dependencies & Migrate

bash
cd rails_api
bundle install                     # Adds the stripe gem
rails db:migrate                   # Creates plans, payment_records tables + user columns + seeds plans

2. Stripe Dashboard Setup

  1. Create a Stripe account (or use existing) at dashboard.stripe.com
  2. Create a Product named "Objectuve Supporter"
  3. Create three Prices under that product:
    • $4.00/month recurring → copy the price_id
    • $36.00/year recurring → copy the price_id
    • (Lifetime uses dynamic pricing — no Stripe Price needed)
  4. Update the Plan records in the database:
    ruby
    Plan.find_by(slug: 'monthly').update!(stripe_price_id: 'price_xxx_monthly')
    Plan.find_by(slug: 'yearly').update!(stripe_price_id: 'price_xxx_yearly')
  5. Configure the Stripe Billing Portal at Settings > Billing > Customer Portal:
    • Enable invoice history
    • Enable payment method updates
    • Enable subscription cancellation

3. Webhook Configuration

  1. In Stripe Dashboard > Developers > Webhooks, create an endpoint:
    • URL: https://api.objectuve.com/webhooks/stripe
    • Events to listen for:
      • checkout.session.completed
      • customer.subscription.updated
      • customer.subscription.deleted
      • invoice.payment_failed
      • charge.refunded
  2. Copy the Signing Secret (whsec_...)

4. Environment Variables

Add to GCP Secret Manager (or .env for local dev):

VariableValueWhere
STRIPE_SECRET_KEYsk_live_... (or sk_test_...)Rails API
STRIPE_WEBHOOK_SECRETwhsec_...Rails API
STRIPE_PRICE_MONTHLY_V2price_... (recurring monthly)Rails API — required for Monthly checkout
STRIPE_PRICE_YEARLY_V2price_... (recurring yearly)Rails API — required for Yearly checkout

For local development, add to rails_api/.env:

STRIPE_SECRET_KEY=sk_test_your_key_here
STRIPE_WEBHOOK_SECRET=whsec_your_secret_here

5. Local Testing with Stripe CLI

bash
# Install Stripe CLI
brew install stripe/stripe-cli/stripe

# Login
stripe login

# Forward webhooks to local server
stripe listen --forward-to localhost:3000/webhooks/stripe

# In another terminal, trigger test events
stripe trigger checkout.session.completed
stripe trigger customer.subscription.deleted
stripe trigger charge.refunded

6. Deploy

bash
# Deploy to staging first
git push origin master              # Triggers staging deploy

# Test the full flow on staging:
# 1. Visit staging.app.objectuve.com/settings?tab=supporter
# 2. Click "View Plans" → select Monthly → checkout with test card 4242...
# 3. Verify supporter badge appears after redirect
# 4. Verify theme selector unlocks
# 5. Test "Manage Billing" → Stripe Portal opens
# 6. Test cancel flow

# Promote to production
promote                             # master → production

7. Post-Launch Monitoring

  • Stripe Dashboard: Monitor successful payments, failed charges, and churn
  • Sentry: Watch for errors in Webhooks::StripeController and Billing::CreateCheckout
  • Logs: Search for Supporter activated: and Supporter expired: in GCP Cloud Logging
  • Metrics: Track conversion rate from Settings > Supporter tab views to checkout completions

8. Future Enhancements (Post-Launch)

EnhancementPriorityDescription
Founding Supporters pageMediumPublic acknowledgment page for Lifetime Supporters
Physical merchLowT-shirts, stickers for Lifetime Supporters
Supporter-only community eventsLowVirtual events for the Supporter community

Shipped: Theme creator — see Theme Creator above. Custom-theme colors are still localStorage-only across devices (the 9 presets already sync); cross-device sync of the actual hue/depth payload remains a follow-up.

Last updated: 2026-09-20 (v4.67 Phase 5, OBJ-4026: corrected "purely cosmetic" framing per Josh's ruling, added § Streak Insurance — see OBJ-4026 handoff comment for full rationale)

Loading…