BILLING_AND_LIMITS.mdverified at v4.4, shipped tree is v5.0View source

Docs/BILLING_AND_LIMITS.md — verified at v4.4, shipped tree is v5.0

Documentation status: Verified for OpenIntelligence v4.4 on 2026-06-30. Product identifiers and the Document Pack status re-checked against source on 2026-08-05; the quota and grandfathering sections were not re-verified. Document Pack Add-On is no longer sold (see §3). doc_pack_addon is absent from OpenIntelligence/Resources/StoreKit/StoreKitConfiguration.storekit and has no paywall UI. The BillingProduct.documentPackAddOn case, the cap logic, and the legacyDocumentPackOwner protection state are deliberately retained so existing owners keep their capacity. StoreKitBillingService still requests the id via BillingProduct.allCases, and StoreKit simply omits an unavailable product from the result, so nothing fails. [evidence_level: code_verified, confidence: exact, evidence_source: StoreKitConfiguration.storekit, BillingProduct.swift, PlanUpgradeSheet.swift, StoreKitBillingService.swift:42] Source of truth: Codebase audit in Docs/AUDIT/.

This document describes the billing tiers, StoreKit 2 product identifiers, and resource quota boundaries as audited in the OpenIntelligence v4.4 codebase.


1. Product Identifier Register

These StoreKit product IDs are defined centrally in BillingProduct.swift:

Product IdentifierrawValueKindAssociated TierDescription
Pro Monthly"pro_monthly"SubscriptionProGrants monthly access to Pro features.
Pro Annual"pro_annual"SubscriptionProGrants annual access to Pro features.
Lifetime Cohort"lifetime_cohort"Non-ConsumableLifetimeOne-time purchase for permanent Lifetime access.
Document Pack Add-On"doc_pack_addon"ConsumableNoneDiscontinued — not sold. Granted 10 extra document slots per pack. Enum case and entitlement logic retained for existing owners only.

2. Resource Quotas & Limits

Enforcement logic is defined in QuotaPolicy.swift and checked in EntitlementStore.swift at runtime before document ingestion or library creation.

Quota Matrix by Tier

Feature / ResourceFree TierPro TierLifetime Tier
Document Limit5 documents1,000 documentsUnlimited
Library Limit1 library10 libraries20 libraries
Maximum Mode Runs3 per day (Metered)UnlimitedUnlimited
Standard Mode RunsUnlimitedUnlimitedUnlimited
Deep Think RunsUnlimitedUnlimitedUnlimited

3. Document Pack Add-On Mechanics (discontinued product, retained for existing owners)

The pack was withdrawn from sale in v4.4. Nothing below describes a purchase a new user can make; it describes how the app continues to honour packs bought before the withdrawal. Read every “purchase” below as “a historical purchase being re-validated.”

  • Allowance: A "doc_pack_addon" transaction appends a ledger entry containing 10 credits to documentPacks.
  • Enforcement Cap: Users are capped at a maximum of 3 active document packs simultaneously (yielding a maximum bonus of +30 documents). The property hasReachedDocumentPackCap in EntitlementStore.swift gates purchases if addOnPacks >= 3.
  • Expiration: Consumable packs are verified against transaction expiration dates. Expired packs are pruned on app launch via pruneExpiredDocumentPacksIfNeeded().

4. Entitlement Reconciliation & Legacy Protection

  • Grandfathering Protection: A sticky paid-history protection state (LegacyProtectionState) is implemented. If a user has a historical paid transaction (subscription or non-consumable), EntitlementStore.swift promotes their state to .historicalPaidPurchase or .legacyDocumentPackOwner on launch. This maintains their Lifetime access and protects their active document limits even if their StoreKit subscription has expired or is unrenewed.
  • Local Simulation: In DEBUG simulator builds, the app supports simulated billing overrides using simulateDebugPurchase(_:) to bypass StoreKit connection failures.

5. Promotional pricing and the launch sale

Added 2026-09-09.

Regular prices, as App Store Connect holds them

Only the USA price is set by hand. Apple generates the other 174 storefronts from it, and those generated figures are what customers actually pay, so they are read from the API rather than converted.

CurrencyLifetime CohortPro Annual
USD59.9929.99
GBP59.9929.99
EUR69.9934.99
CAD79.9939.99
AUD99.9949.99
INR59002999
JPY100005000
BRL399.90199.90
MXN1299599

Across all 177 generated Lifetime territory prices, every currency resolves to exactly one amount, which is what makes LaunchSale safe to key by currency rather than territory. That is a property of this price schedule and not a guarantee from Apple, so scripts/verify_sale_prices.py re-checks it and reports drift. [evidence_level: measured, confidence: exact, evidence_source: /v1/inAppPurchasePriceSchedules/6756638872/automaticPrices (177 rows) and /v1/subscriptions/6756638919/prices (175 rows), grouped by currency, read 2026-09-09]

US proceeds are a flat 85% of list under the Small Business Program, which the price point records show directly. The blended net across all territories is lower, 66.4%, because several storefronts quote VAT-inclusive prices that Apple remits. Use 66.4% for revenue forecasts and 85% only for US-only questions. [evidence_level: measured, confidence: exact, evidence_source: proceeds field on USA price points (85%); SUM(proceeds_usd)/SUM(sales_usd) over the ten Lifetime rows in store_purchases, ~/ASC/data/asc.sqlite3, 2026-09-09. That archive is not version-controlled, so the figure is reproducible only while it exists.]

How a discount is delivered, and why the app has to announce it

A non-consumable cannot have a “sale” in the App Store sense. The only mechanism is a temporary price change, which App Store Connect accepts with a start and an end date and reverts on its own; the maximum length is one year. [evidence_level: documented, confidence: exact, evidence_source: https://developer.apple.com/help/app-store-connect/manage-in-app-purchases/schedule-price-changes-for-in-app-purchases, fetched 2026-09-09]

The App Store shows no strikethrough, no “was/now” and no sale badge for in-app purchases. A customer during a price change simply sees a smaller number. The discount therefore does not exist as a discount unless the app says so, which is what LaunchSale and the paywall banner are for.

What LaunchSale guarantees, and what it does not

OpenIntelligence/Features/Billing/LaunchSale.swift advertises nothing unless both hold:

  1. now is inside LaunchSale.window.
  2. StoreKit’s reported price is strictly below the regular price recorded for Lifetime in that customer’s currency.

Condition 2 means a price change that was never scheduled, never reached a storefront, or has already reverted produces silence whatever the date says. The percentage comes from the two real numbers, rounded down, so the saving is never overstated, and it is computed per storefront because Apple rounds each territory to its own price point.

The limit, stated plainly. Condition 2 is not “a sale is running”, it is “the price is below a constant compiled into this build”. Apple recalculates generated prices when tax and exchange rates move, so a table recorded today can be wrong in six months and the app would then advertise a discount nobody scheduled. Run scripts/verify_sale_prices.py before every sale. Separately, EntitlementStore caches its products, so PlanUpgradeSheet refreshes them on appear to keep the banner and the checkout price the same number. [evidence_level: code_verified, confidence: high, evidence_source: LaunchSale.swift offer(for:storeProduct:now:), PlanUpgradeSheet.swift .task, OpenIntelligenceTests/Features/Billing/LaunchSaleTests.swift]

Why only Lifetime is ever discounted

Pro Annual and Pro Monthly are deliberately excluded, for two independent reasons.

  • Pro Annual has already spent its introductory offer. Its 7-day free trial is an introductory offer, and a customer may redeem only one per subscription group, so a discount offer would displace the trial rather than add to it.
  • A temporary price change on a subscription creates a price increase later. When the price reverts, everyone who subscribed at the sale price faces an increase at renewal. Apple requires consent where a region demands it, where the increase exceeds 50% and about US$50 a year, or where the subscriber saw an increase in the last 12 months, and a subscriber who does not consent has the subscription expire at the end of the current cycle.

Lifetime is a non-consumable, so it reverts with no consequence for anyone who already bought. It is also 83% of revenue, so restricting the sale to it costs almost nothing. [evidence_level: documented+code_verified, confidence: exact, evidence_source: StoreKitConfiguration.storekit pro_annual introductoryOffer; https://developer.apple.com/help/app-store-connect/manage-subscriptions/manage-pricing-for-auto-renewable-subscriptions/ and .../set-up-introductory-offers-for-auto-renewable-subscriptions/, fetched 2026-09-09; store_purchases revenue share]

One US price change is a different discount in every storefront

Apple rounds each territory to its own price ladder, so a single USA change from $59.99 to $39.99 does not land at 33% anywhere but the USA and the UK. Read from Apple’s equalizations for the $39.99 USA price point rather than converted:

TerritoryRegularSaleOff
MexicoMX$1299MX$89930%
India₹5900₹399932%
USA, United Kingdom59.9939.9933%
Germany€69.99€44.9935%
CanadaCA$79.99CA$49.9937%
BrazilR$399.90R$249.9037%
Australia, JapanA$99.99, ¥10000A$59.99, ¥600040%

This is why LaunchSale.percentOff computes per storefront from the customer’s own two prices instead of showing a single advertised figure, and why marketing copy that has to be true everywhere says “a third off” rather than a percentage. 30% is the floor across these nine. [evidence_level: measured, confidence: exact, evidence_source: /v1/inAppPurchasePricePoints/<USA 39.99>/equalizations, 174 rows, read 2026-09-09]

Writing the price change: the schedule is replaced, not amended

There is no “add a price change” call. POST /v1/inAppPurchasePriceSchedules submits the entire schedule, and the schedule is not a list of prices, it is a partition of the timeline. Apple enforces that intervals do not intersect, that the timeline is covered with no gaps, and that the rightmost interval has no end date.

A temporary sale is therefore three intervals, because the regular price has to hold both the time before the sale and the time after it:

IntervalPriceRole
null to start59.99open at the start of time
start to end39.99the sale
end to null59.99what it reverts to, open forever

[evidence_level: measured, confidence: exact, evidence_source: two HTTP 409 responses from POST /v1/inAppPurchasePriceSchedules, 2026-09-09. ENTITY_ERROR.INVALID_INTERVAL: "Adjacent intervals must not intersect for USA: [null - null] and [2026-09-15T00:00 - 2026-09-30T00:00]". ENTITY_ERROR.INVALID_END_DATE: "Entire timeline must be covered for USA. Rightmost interval must not have an end date". Apple's reference documents the field names but none of this.]

The consequence for the guard in schedule_sale.py: the price that answers “what is the regular price” is the open-ended interval, not the one with a null start. A sale is already scheduled means the null-start interval is still the regular price but so is the tail, and it is the tail that customers pay once the sale expires.

Two further things the error messages settle, which Apple’s help pages do not. Inline entity ids must be written ${local-id} with literal braces, not as bare strings. And the intervals are half-open: [start, end), since adjacent intervals share a boundary date without intersecting. So the end date is the day the price reverts, and the last day at the sale price is the day before. LaunchSale.deadlineText names that day, which makes the advertised deadline exact rather than approximate. [evidence_level: measured, confidence: high, evidence_source: same two 409 responses; the half-open reading is what makes "must not intersect" and "must be covered" simultaneously satisfiable for adjacent intervals sharing a date, and is confirmed by Apple accepting that shape]

Running a sale

  1. zsh -ic 'python3 scripts/verify_sale_prices.py' and fix any drift it reports.
  2. Set LaunchSale.window before the build, since it compiles into the binary: scripts/schedule_sale.py --start ... --end ... --write-window.
  3. On release day, the same script with --confirm writes the temporary price change.

Step 1 is not optional; it is what keeps the struck-through price honest. schedule_sale.py reads the live schedule first and refuses to write if the standing price is not what this repo expects, or if a hand-set price exists outside the USA, because either would mean the submission silently changes or deletes a real price. The full procedure with exact figures is Docs/Release/5.2/launch-sale.md.

Offer codes, for targeted discounts that leave the list price alone

Offer codes now cover consumables, non-consumables and non-renewing subscriptions, not just auto-renewable ones, and promo codes for in-app purchases were retired on 2026-03-26. Limits: 10 active offers per app, up to 1,000,000 codes per app per quarter, one-time-use batches of 500 to 25,000, custom-code batches up to 25,000 redemptions, a maximum 6-month expiry, and one redemption per customer per offer. An offer can be a discounted price rather than free. [evidence_level: documented, confidence: exact, evidence_source: https://developer.apple.com/help/app-store-connect/manage-in-app-purchases/create-offer-codes-for-in-app-purchases/, fetched 2026-09-09]

Prefer offer codes over a price change for win-back and targeted outreach: they never touch the public price and each redemption is attributable.