True Count
Features

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

PlanNamePurpose
ATransaction SafetyIdempotent credit delivery with ProcessedTransaction receipt ledger
BRefund HandlingDetect and reverse credits for refunded/revoked transactions
CBankroll BackupCloudKit public DB backup for fast reinstall recovery
DPurchase Support ToolingIn-app diagnostics + remote credit grants
EAdmin PanelNext.js web app for managing users, grants, and bankrolls

Plan A — Transaction Safety

Every StoreKit purchase flows through StoreKitService.deliverContent(), which:

  1. Checks the ProcessedTransaction table for an existing receipt (idempotency)
  2. Adds credits to GameState.bankroll directly (atomic with receipt)
  3. Inserts a ProcessedTransaction record
  4. 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():

  1. Finds the matching ProcessedTransaction record
  2. Subtracts the credited amount from GameState.bankroll
  3. Marks the receipt as revoked = true with a timestamp
  4. 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 lastUpdated timestamp 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      TIMESTAMP

Plan D — Purchase Support Tooling

Purchase History (Settings screen, visible to all users)

A production-visible section in Settings with:

  • Transaction Log — all ProcessedTransaction records 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:
    1. Auto-delivers any unfinished transactions (safe — Apple confirms not fulfilled)
    2. 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:

  1. Customer provides their Support ID
  2. Developer verifies the purchase in App Store Connect
  3. Developer creates a CreditGrant record (via admin panel or grant-credits.sh script)
  4. 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 QUERYABLE

CLI 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 BankrollBackup records, search by Support ID, click to view/edit
  • User detail — bankroll value (editable with admin override), grant history, quick actions
  • Grants — browse all CreditGrant records 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 dev

Requires 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
BankrollBackupCreate + ReadWrite
CreditGrantRead + Write + Create
  • Users can only query their own BankrollBackup (via ownerRecordName)
  • 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 lastUpdated timestamp 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

FilePlanPurpose
truecount/Services/BankrollBackupService.swiftCCloudKit backup/restore
truecount/Services/CreditGrantService.swiftDGrant discovery and delivery
truecount/Services/AppDelegate.swiftDPush registration and silent push handling
truecount/ViewModels/PurchaseHistoryViewModel.swiftDTransaction log, Support ID, reconciliation
truecount/Views/Screens/PurchaseHistorySection.swiftDPurchase History UI in Settings
truecount/Services/StoreKitService.swiftA/BdeliverContent, handleRevocation, reconcilePurchases
truecount/Models/ProcessedTransaction.swiftAReceipt ledger model
cloudkit/CloudKitSchema.ckdbC/DDeclarative schema for both record types
scripts/grant-credits.shDCLI wrapper for creating grants
admin/ENext.js admin panel

How is this guide?

On this page