IAP Credit Safety
Transaction safety, refund handling, CloudKit bankroll backup, purchase support tooling, and admin panel.
The IAP Credit Safety system protects customer purchases and gives the developer tools to diagnose and resolve credit issues. It spans five plans (A–E) that work together as a complete pipeline from purchase to support resolution.
Overview
| Plan | Name | Purpose |
|---|---|---|
| A | Transaction Safety | Idempotent credit delivery with ProcessedTransaction receipt ledger |
| B | Refund Handling | Detect and reverse credits for refunded/revoked transactions |
| C | Bankroll Backup | CloudKit public DB backup for fast reinstall recovery |
| D | Purchase Support Tooling | In-app diagnostics + remote credit grants |
| E | Admin Panel | Next.js web app for managing users, grants, and bankrolls |
Plan A — Transaction Safety
Every StoreKit purchase flows through StoreKitService.deliverContent(), which:
- Checks the
ProcessedTransactiontable for an existing receipt (idempotency) - Adds credits to
GameState.bankrolldirectly (atomic with receipt) - Inserts a
ProcessedTransactionrecord - Saves bankroll + receipt in a single
modelContext.save()— all or nothing
On app launch, recoverUnfinishedTransactions() iterates Transaction.unfinished and re-delivers any transactions where finish() was never called. This catches crashes during purchase.
The ProcessedTransaction model stores: transaction ID, original transaction ID, product ID, credits delivered, ad-free flag, dates, and revocation status.
Key guarantee
If Apple charged the customer, the app will deliver the credits — either immediately during purchase, or on next launch via recovery. The receipt ledger prevents double-delivery.
Plan B — Refund Handling
When Apple issues a refund, the Transaction.updates listener detects revocationDate != nil and calls handleRevocation():
- Finds the matching
ProcessedTransactionrecord - Subtracts the credited amount from
GameState.bankroll - Marks the receipt as
revoked = truewith a timestamp - Saves atomically
The same idempotency check prevents double-revocation. If the bankroll would go negative, it floors at 0.
Plan C — Bankroll Backup (CloudKit Public Database)
Problem
When a user reinstalls, SwiftData reconnects to CloudKit (private database) and pulls game data back — but there's a delay (seconds to minutes) where the app shows the default bankroll (1,000).
Solution
BankrollBackupService writes the bankroll to a BankrollBackup record in the CloudKit public database at key moments:
- After credit purchases or
setBankroll()calls - When the app goes to background
- Every 5 minutes via a periodic timer
On launch, checkAndRestoreFromBackup() queries CloudKit:
- If the local bankroll is the default (1,000) and CloudKit has a higher value — restore it
- If CloudKit's
lastUpdatedtimestamp is newer than the app's last backup (admin edit) — accept the CloudKit value - Otherwise — push the local value to CloudKit
Writes are fire-and-forget with automatic retry on oplock conflicts. The ownerRecordName field (queryable) identifies the user's record.
CloudKit schema
BankrollBackup:
ownerRecordName STRING QUERYABLE
bankroll INT64
lastUpdated TIMESTAMPPlan D — Purchase Support Tooling
Purchase History (Settings screen, visible to all users)
A production-visible section in Settings with:
- Transaction Log — all
ProcessedTransactionrecords in reverse chronological order (product name, credits, status, transaction ID) - Support ID — the user's CloudKit
userRecordName, tap-to-copy. Used to identify the user when contacting support. - Verify Purchases — reconciliation button that:
- Auto-delivers any unfinished transactions (safe — Apple confirms not fulfilled)
- Reports finished-but-unrecorded transactions as "could not be verified — contact support"
- Bankroll Backup Status — CloudKit backup value vs local bankroll with match indicator
Remote Credit Grants (CloudKit Public Database)
When a customer reports missing credits:
- Customer provides their Support ID
- Developer verifies the purchase in App Store Connect
- Developer creates a
CreditGrantrecord (via admin panel orgrant-credits.shscript) - The app discovers the grant on next launch/foreground/refresh and delivers credits
The app polls for unclaimed grants (processed == 0, matching targetUserRecordName) on:
- Every app launch
- Every foreground transition (
scenePhase .active) - Store screen refresh button tap
- Silent push notification (best-effort, via
CKQuerySubscription)
After delivering, the app marks processed = 1 in CloudKit and tracks the grant locally (UserDefaults) as an idempotency safeguard.
Store Screen
The Store screen shows a bankroll display in the title area and a refresh button that checks for pending credit grants and admin bankroll edits.
CloudKit schema
CreditGrant:
targetUserRecordName STRING QUERYABLE
credits INT64
reason STRING
processed INT64 QUERYABLECLI script
scripts/grant-credits.sh — a cktool wrapper for creating grant records from the terminal:
./scripts/grant-credits.sh \
--recipient _abc123def456 \
--credits 400000 \
--reason "Ticket #42"Plan E — Admin Panel
A localhost-only Next.js + shadcn/ui web app in the admin/ directory for managing CloudKit public database records visually.
Features
- Dashboard — user count, pending/claimed grant statistics
- Users — browse
BankrollBackuprecords, search by Support ID, click to view/edit - User detail — bankroll value (editable with admin override), grant history, quick actions
- Grants — browse all
CreditGrantrecords with All/Pending/Claimed filter tabs - New Grant — create credit grants with Support ID, amount, optional reason
- Delete All Grants — bulk cleanup for test data in development
- Environment switcher — Development (green) / Production (red) with confirmation dialog
Architecture
- Next.js API routes sign requests with an ECDSA P-256 server-to-server key
- Private key never reaches the browser — signing happens server-side
- Environment switcher persists in localStorage and forces full page remount on change
Setup
cd admin && pnpm install && pnpm devRequires admin/.env.local with CLOUDKIT_KEY_ID, CLOUDKIT_CONTAINER, and PRIVATE_KEY_PATH, plus admin/eckey.pem (both gitignored).
Security Model
| Record Type | _icloud (Authenticated) | _creator |
|---|---|---|
BankrollBackup | Create + Read | Write |
CreditGrant | Read + Write + Create | — |
- Users can only query their own
BankrollBackup(viaownerRecordName) - Credit grants are delivered only to matching
targetUserRecordName - The app tracks claimed grants locally (UserDefaults) to prevent double-delivery even if CloudKit is tampered with
- Admin bankroll edits are detected via
lastUpdatedtimestamp comparison
Support Ticket Flow
1. Customer: Settings → Purchase History → "Verify Purchases"
→ If unfinished transactions found: auto-delivered. Done.
→ If unverified: customer sees "contact support."
2. Customer contacts support with Support ID
3. Developer: Admin Panel → find user → verify in App Store Connect
4. Developer: Admin Panel → "Grant Credits" → enter amount + reason
→ CloudKit delivers on customer's next app launch
5. No offer codes, no re-purchases, no back-and-forth.Files
| File | Plan | Purpose |
|---|---|---|
truecount/Services/BankrollBackupService.swift | C | CloudKit backup/restore |
truecount/Services/CreditGrantService.swift | D | Grant discovery and delivery |
truecount/Services/AppDelegate.swift | D | Push registration and silent push handling |
truecount/ViewModels/PurchaseHistoryViewModel.swift | D | Transaction log, Support ID, reconciliation |
truecount/Views/Screens/PurchaseHistorySection.swift | D | Purchase History UI in Settings |
truecount/Services/StoreKitService.swift | A/B | deliverContent, handleRevocation, reconcilePurchases |
truecount/Models/ProcessedTransaction.swift | A | Receipt ledger model |
cloudkit/CloudKitSchema.ckdb | C/D | Declarative schema for both record types |
scripts/grant-credits.sh | D | CLI wrapper for creating grants |
admin/ | E | Next.js admin panel |
How is this guide?