Skip to content

feat(mobile): add Apple Kilo Pass purchases - #3079

Merged
iscekic merged 166 commits into
mainfrom
feat/apple-iap-personal-credit-purchases
May 13, 2026
Merged

iscekic merged 166 commits into
mainfrom
feat/apple-iap-personal-credit-purchases

Conversation

@iscekic

@iscekic iscekic commented May 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

This PR replaces the original one-time Apple credit purchase experiment with App Store-managed Kilo Pass subscriptions across the data model, backend APIs, web management surfaces, and mobile purchase flow.

  • Adds provider-aware Kilo Pass persistence for Stripe vs store-managed subscriptions, including store purchase/event tables, provider subscription IDs, App Store account tokens, monthly App Store product metadata, and Google Play placeholders for the shared catalog.
  • Adds App Store server-side handling for signed transaction verification, idempotent purchase completion, renewals, cancellation state, failed renewals, upgrades/proration, refunds/revocations, credit clawbacks, and mobile-facing Kilo Pass store product/completion router methods.
  • Adds the iOS mobile Kilo Pass subscription experience: native modal presentation, App Store product loading, purchase/recovery hooks, account-token matching, ownership and cancellation states, App Store management routing, and profile dismissal/routing behavior after purchase.
  • Updates web Kilo Pass management so store-managed subscriptions route to App Store management instead of Stripe-only actions, and updates /device-auth to show the signed-in account plus a sign-out path before approving a device.
  • Adds Expo IAP/App Store server dependencies, env placeholders, generated DB migration artifacts, and focused tests around the new store subscription, notification, mobile purchase, and management flows.

Verification

  • App Store sandbox upgrade/downgrade sequence was manually checked against DB rows for subscription, store event, and credit ledger behavior in earlier branch validation.
  • Not manually re-verified during this PR-description update.

Visual Changes

Visual changes exist for the mobile Kilo Pass/profile purchase flow and web account-management surfaces, but screenshots are not attached in this description update.

Reviewer Notes

  • App Store behavior depends on production/sandbox config: root certificates, private key, key ID, issuer ID, Apple app ID, bundle ID, product IDs, subscription group, sandbox testers, and APPLE_IAP_ENVIRONMENT.
  • The current store catalog is monthly-only for App Store. Google Play product/base-plan metadata is present as a placeholder, but Google Play verification and notifications are not implemented here.
  • Store-managed subscriptions intentionally bypass Stripe portal, cancel, schedule, payment-method, and billing-history paths; please check those provider guards preserve existing Stripe behavior.
  • Purchase completion finishes StoreKit transactions only after backend completion so failed completions can be recovered on app start.
  • Refund/revocation handling reverses App Store base and bonus credits as negative ledger entries and depends on usable transaction price data from Apple.

iscekic added 30 commits May 6, 2026 14:36
@iscekic

iscekic commented May 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

@pandemicsyn @marius-kilocode gentle ping: this is still blocked only on required non-author review. Current head 3e2fad9996d0dda9dba37f31a59548b76092236d has 42 checks passing, 0 failing, 0 pending; review threads are resolved.

Bot gone cray, sorry for the ping.

…al-credit-purchases

# Conflicts:
#	pnpm-workspace.yaml

@jeanduplessis jeanduplessis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left one optional suggestion on state.ts about the read-named getter performing a DB write — feel free to take or leave it.

Comment thread apps/web/src/lib/kilo-pass/state.ts Resolved
iscekic added 18 commits May 12, 2026 19:41
Non-USD refunds (EUR, GBP, etc.) previously threw before writing
processed_at, causing Apple to retry forever and leaving subscriptions
active with no credit clawback.

- getAppleTransactionPriceMicrodollars returns null for non-USD instead
  of throwing; the stored amount_microdollars on the credit_transactions
  row for the original purchase is used as the base reversal amount
- When no stored amount is found for a non-USD refund, logs to Sentry
  and skips the base clawback rather than blocking the handler
- reverseAppStoreRefundCredits call is wrapped in try/catch so that any
  remaining error logs to Sentry and processing continues; subscription
  is always marked ended and processed_at is always written
…ForUser

App Store subscriptions have no stripeSubscriptionId, so the function was
returning no_subscription instead of a distinct reason. Added a new
store_managed_subscription reason variant (paymentProvider: 'apple') that
fires when paymentProvider !== 'stripe', updated both admin router consumers
and the bulk cancel UI to handle it.
…fications

Wrap the REFUND/REVOKE and renewal-completion dispatch branches each in a
single outer db.transaction so that credit reversals, subscription status
updates, audit log entries, and the processed_at write are committed or
rolled back as a unit.

- markStoreSubscriptionEnded now accepts a DbOrTx first arg and is exported
- reverseAppStoreRefundCredits drops its inner db.transaction; caller owns tx
- completeStoreKiloPassPurchase accepts an optional dbOrTx; wraps internally
  when called without one (backward-compatible for all existing callers)
- processAppStoreKiloPassNotification accepts an optional endStoreSubscription
  override (test-injection pattern matching decodeNotification etc.)
- Adds TDD test: rolls back credit reversal and processed_at write when
  markStoreSubscriptionEnded throws inside the outer transaction
Extract the lazy-cancel of store-managed subscriptions out of
`getKiloPassStateForUser` (which was performing a DB UPDATE inside a
read-named getter) and into a dedicated 15-minute cron at
`/api/cron/kilo-pass-store-subscription-reconcile`. The getter still
derives `canceled` status from the latest store-purchase `expires_at`
so callers see the correct state immediately, but no longer mutates
the row — read-only DB connections are now safe.
@iscekic
iscekic merged commit 0b19f8c into main May 13, 2026
43 checks passed
@iscekic
iscekic deleted the feat/apple-iap-personal-credit-purchases branch May 13, 2026 16:23
iscekic added a commit that referenced this pull request May 15, 2026
…7.11

Expo SDK 55 expects @sentry/react-native ~7.11. The 8.x bump from
#3079 was incidental — every API the app uses (init, wrap,
captureException, reactNavigationIntegration, mobileReplayIntegration,
the /expo config plugin path) is present in 7.11, so we no longer
need the expo.install.exclude entry that was tracking the divergence.
iscekic added a commit that referenced this pull request May 15, 2026
* fix(mobile): align expo SDK 55 deps and pnpm version

- Sync @shopify/flash-list, expo-dev-client, react, react-native,
  react-native-reanimated, and react-native-svg to versions expected
  by Expo SDK 55 so `expo-doctor` passes.
- Add expo-dev-client to pnpm's minimumReleaseAgeExclude so the
  matching 55.0.34 patch can install before its release-age window.
- Pin eas.json pnpm to 11.1.1 to match the root package.json
  packageManager field and the lockfile.

* fix(mobile): downgrade @sentry/react-native to Expo SDK 55 expected ~7.11

Expo SDK 55 expects @sentry/react-native ~7.11. The 8.x bump from
#3079 was incidental — every API the app uses (init, wrap,
captureException, reactNavigationIntegration, mobileReplayIntegration,
the /expo config plugin path) is present in 7.11, so we no longer
need the expo.install.exclude entry that was tracking the divergence.

* fix(mobile): pin lightningcss to 1.30.1 to fix metro bundling

lightningcss 1.30.2 regressed how var() tokens round-trip through JS
visitor return values. react-native-css's metro transformer returns
declarations from its `Declaration` visitor, which trips the new bug:

  src/global.css: failed to deserialize;
  expected an object-like struct named Specifier, found ()

The regression is still present in the latest 1.32.0 — upstream issue
parcel-bundler/lightningcss#1081 remains open. Pin to 1.30.1, the last
known-good release.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants