Mobile in-app purchases (Apple / Google)

Module id: mobile_iap · Kind: commerce · Configured by: at least one IAP_PRODUCT_* mapped to a store product · Healthy when: an Apple or Google receipt verifier is configured too

Apple and Google require that a subscription bought inside a native app goes through their stores, not through Stripe. This module is the server side of that rule: it maps store product ids to Synaplan tiers and verifies every receipt server-side before it grants anything. You only need it if you publish your own build of the Synaplan mobile apps against your server and sell subscriptions in it. The published Synaplan apps pointed at a self-hosted server do not require it.

Background and design notes: docs/PAYMENTS_CHANNELS.md in the main repository.


What it adds

  • Product ↔ tier mapping (IAP_PRODUCT_PRO / _TEAM / _BUSINESS) and per-tier store prices for display in the app.
  • POST /api/v1/iap/verify — redeem a fresh purchase for the signed-in user; Apple StoreKit 2 transaction JWS or Google purchases.subscriptionsv2.
  • POST /api/v1/iap/apple/notifications and /google/notifications — renewals, cancellations, refunds and grace periods applied without the app open.
  • One owner per subscription: a receipt already bound to another account is refused, Google pending purchases never unlock early, and a user with an active Stripe subscription cannot buy again through a store (and vice versa).

Without it

GET /api/v1/subscription/plans reports iapConfigured: false and the app does not offer a purchase; web checkout through Stripe is unaffected. When the module is absent and its gate is on, /api/v1/iap/verify answers 404 feature_not_configured. The two store notification endpoints are never gated — they keep answering 503 so Apple and Google retry instead of dropping the event.


How to enable

1. Create the products in the stores

In App Store Connect and the Play Console create one auto-renewable subscription per tier. The store price must include the store commission (≈30 %, sometimes 15 %) — see the pricing notes in PAYMENTS_CHANNELS.md.

2. Map them to tiers

IAP_PRODUCT_PRO=com.example.app.pro.monthly
IAP_PRODUCT_TEAM=com.example.app.team.monthly
IAP_PRODUCT_BUSINESS=com.example.app.business.monthly

IAP_STORE_PRICE_PRO=24.99          # fixed EUR store price points shown in the app
IAP_STORE_PRICE_TEAM=64.99
IAP_STORE_PRICE_BUSINESS=129.99
IAP_PRICE_MARKUP_PERCENT=30        # fallback when a tier's store price is 0: web price + markup, snapped to x.99

There are deliberately no defaults: a forgotten product id switches IAP off loudly instead of pointing at a product that does not exist in your account.

3. Configure at least one verifier

Apple (App Store Server API, StoreKit 2):

IAP_APPLE_BUNDLE_ID=com.example.app
IAP_APPLE_APP_APPLE_ID=1234567890
IAP_APPLE_ENVIRONMENT=Production           # or Sandbox
IAP_APPLE_ROOT_CERTS_DIR=/secrets/apple-roots   # Apple root certificates, DER, exactly as Apple ships them — MOUNT the directory

Google (Play Developer API through a service account):

IAP_GOOGLE_PACKAGE_NAME=com.example.app
IAP_GOOGLE_SERVICE_ACCOUNT_JSON=/secrets/play-service-account.json

Mount both secret locations into the backend and worker containers; a copy made inside a container is lost on the next recreate. Restart both.

4. Point the store notifications at your server

App Store Connect → App Store Server Notifications V2 → https://<your-domain>/api/v1/iap/apple/notifications. Play Console → Monetization setup → Real-time developer notifications → a Pub/Sub topic with a push subscription to https://<your-domain>/api/v1/iap/google/notifications.


Check it works

  • Operate → Feature StatusMobile in-app purchases:
    • Available / IAP products mapped; receipt verification ready — details list the mapped products and apple_verifier / google_verifier flags.
    • Needs setup / IAP products mapped but no store verifier is configured — purchases would be refused with 503.
    • Not installed / No IAP_PRODUCT_* tier is mapped to a store product.
  • GET /api/v1/subscription/plans shows iapConfigured: true and an iapProductId per mapped tier.
  • Make a sandbox purchase from a test build and check the tier changes on the account.

Configuration reference

Variable Default Meaning
IAP_PRODUCT_PRO / _TEAM / _BUSINESS empty Store product id per tier. At least one makes the module configured.
IAP_STORE_PRICE_PRO / _TEAM / _BUSINESS 24.99 / 64.99 / 129.99 Fixed EUR store price shown as appPrice until the store's localized price loads. 0 = use the markup fallback.
IAP_PRICE_MARKUP_PERCENT 30 Fallback markup on the web price, snapped to the nearest x.99.
IAP_APPLE_BUNDLE_ID empty iOS bundle identifier.
IAP_APPLE_APP_APPLE_ID empty Numeric App Store id of the app.
IAP_APPLE_ENVIRONMENT Production Production or Sandbox.
IAP_APPLE_ROOT_CERTS_DIR empty Directory with Apple's root certificates in DER form.
IAP_GOOGLE_PACKAGE_NAME empty Android package name.
IAP_GOOGLE_SERVICE_ACCOUNT_JSON empty Absolute path to the Play service-account JSON.

Troubleshooting

  • Every Apple purchase fails while the deployment looks healthy. The root certificates were converted to PEM or copied into the container instead of mounted. Use the DER files as downloaded from Apple and mount the directory.
  • iapConfigured: false although the products are set. Restart backend and worker; the values are read at start. Check for a typo — the ids must match the store exactly.
  • Google purchase stays pending. Deferred / SCA purchases are only granted when the confirming real-time notification arrives; check the Pub/Sub push subscription reaches your server.
  • IAP_OWNERSHIP_CONFLICT. The receipt is already bound to another account, or the user has an active Stripe subscription. The UI explains where the existing subscription is managed.

Related