From 837c414c34f3f7e520c19d3343ca9170f633163f Mon Sep 17 00:00:00 2001 From: Marco Pifferi Date: Fri, 13 Mar 2026 10:46:13 +0100 Subject: [PATCH] init --- .gitignore | 56 +++++ Package.swift | 10 + README.md | 106 +++++++++ Sources/main.swift | 546 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 718 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/main.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fb9370c --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +# Created by https://www.toptal.com/developers/gitignore/api/macos,xcode +# Edit at https://www.toptal.com/developers/gitignore?templates=macos,xcode + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Xcode ### +## User settings +xcuserdata/ + +## Xcode 8 and earlier +*.xcscmblueprint +*.xccheckout + +### Xcode Patch ### +*.xcodeproj/* +!*.xcodeproj/project.pbxproj +!*.xcodeproj/xcshareddata/ +!*.xcodeproj/project.xcworkspace/ +!*.xcworkspace/contents.xcworkspacedata +/*.gcno +**/xcshareddata/WorkspaceSettings.xcsettings + +# End of https://www.toptal.com/developers/gitignore/api/macos,xcode + +.build/ +export/ \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..2dc8eb8 --- /dev/null +++ b/Package.swift @@ -0,0 +1,10 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "pricing", + platforms: [.macOS(.v13)], + targets: [ + .executableTarget(name: "pricing", path: "Sources") + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ad3367 --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Pricing + +A Swift command-line tool to download subscription pricing from App Store Connect. + +## Prerequisites + +- Swift 5.9+ +- An App Store Connect API key (`.p8` file) with access to your app's subscription data + +## Setup + +Export your App Store Connect API credentials as environment variables: + +```bash +export AS_ISSUER_ID="your-issuer-id" +export AS_KEY_ID="your-key-id" +export AS_PRIVATE_KEY_PATH="/path/to/AuthKey_XXXX.p8" +``` + +You can add these to your shell profile (`~/.zshrc`, `~/.bashrc`) to persist them. + +## Usage + +``` +swift run pricing [product-id] [options] +swift run pricing --group [--csv ] +``` + +### Arguments + +| Argument | Description | +|---|---| +| `app-id` | Numeric Apple ID from App Store Connect (e.g. `6737844884`) | +| `product-id` | Subscription product ID string (e.g. `com.example.app.monthly`) | + +### Options + +| Option | Description | +|---|---| +| `--group ` | Fetch all subscriptions in a named subscription group | +| `--csv ` | Export results to CSV files (one per subscription) in the given directory | + +## Examples + +### Default: fetch all subscriptions in a group and export to CSV + +```bash +swift run pricing 6737844884 --group subscriptions --csv ./export +``` + +This fetches every subscription in the "subscriptions" group, prints a price table for each one, and writes individual CSV files to `./export/` (e.g. `Core_Monthly.csv`, `Pro_Annual.csv`). + +### Fetch a single subscription + +```bash +swift run pricing 6737844884 com.pixycad.core.monthly +``` + +### Fetch a single subscription and export to CSV + +```bash +swift run pricing 6737844884 com.pixycad.core.monthly --csv ./export +``` + +### Fetch all subscriptions in a group (console only) + +```bash +swift run pricing 6737844884 --group subscriptions +``` + +## Output + +### Console + +``` +[Core Monthly] (state: APPROVED) + +Subscription Prices for: com.pixycad.core.monthly +──────────────────────────────────────────────────────────────────────── +Territory Curr Price Proceeds +──────────────────────────────────────────────────────────────────────── +USA USD 4.99 4.24 +GBR GBP 4.99 4.24 +... +──────────────────────────────────────────────────────────────────────── +Total territories: 175 +``` + +### CSV + +Each CSV file contains: + +``` +Product ID,Territory,Currency,Customer Price,Proceeds +com.pixycad.core.monthly,USA,USD,4.99,4.24 +com.pixycad.core.monthly,GBR,GBP,4.99,4.24 +... +``` + +Opens directly in Excel, Numbers, or Google Sheets. + +## Notes + +- Only the **current active price** per territory is returned (future scheduled prices and historical prices are filtered out) +- The tool automatically retries on timeouts and rate limits (up to 3 attempts with backoff) +- Group name matching is case-insensitive; if no match is found, available group names are printed diff --git a/Sources/main.swift b/Sources/main.swift new file mode 100644 index 0000000..8b50456 --- /dev/null +++ b/Sources/main.swift @@ -0,0 +1,546 @@ +import CryptoKit +import Foundation + +// MARK: - Configuration + +struct Config { + let issuerId: String + let keyId: String + let privateKeyPath: String + + static func fromEnvironment() throws -> Config { + guard let issuerId = ProcessInfo.processInfo.environment["AS_ISSUER_ID"] else { + throw AppError.missingEnvVar("AS_ISSUER_ID") + } + guard let keyId = ProcessInfo.processInfo.environment["AS_KEY_ID"] else { + throw AppError.missingEnvVar("AS_KEY_ID") + } + guard let keyPath = ProcessInfo.processInfo.environment["AS_PRIVATE_KEY_PATH"] else { + throw AppError.missingEnvVar("AS_PRIVATE_KEY_PATH") + } + return Config(issuerId: issuerId, keyId: keyId, privateKeyPath: keyPath) + } +} + +// MARK: - Errors + +enum AppError: LocalizedError { + case missingEnvVar(String) + case invalidPrivateKey + case apiError(Int, String) + case noSubscriptionFound(String) + case noGroupFound(String) + case missingUsage + + var errorDescription: String? { + switch self { + case .missingEnvVar(let name): + return "Missing environment variable: \(name)" + case .invalidPrivateKey: + return "Failed to load private key from AS_PRIVATE_KEY_PATH" + case .apiError(let status, let body): + return "API error (HTTP \(status)): \(body)" + case .noSubscriptionFound(let productId): + return "No subscription found for product ID: \(productId)" + case .noGroupFound(let name): + return "No subscription group found matching: \(name)" + case .missingUsage: + return "Usage: swift run pricing " + } + } +} + +// MARK: - JWT Generation + +func generateJWT(config: Config) throws -> String { + let pemString = try String(contentsOfFile: config.privateKeyPath, encoding: .utf8) + + // Strip PEM headers and whitespace + let base64Key = pemString + .replacingOccurrences(of: "-----BEGIN PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "-----END PRIVATE KEY-----", with: "") + .replacingOccurrences(of: "\n", with: "") + .replacingOccurrences(of: "\r", with: "") + .trimmingCharacters(in: .whitespaces) + + guard let keyData = Data(base64Encoded: base64Key) else { + throw AppError.invalidPrivateKey + } + + let privateKey = try P256.Signing.PrivateKey(derRepresentation: keyData) + + let now = Date() + let header: [String: Any] = [ + "alg": "ES256", + "kid": config.keyId, + "typ": "JWT", + ] + let payload: [String: Any] = [ + "iss": config.issuerId, + "iat": Int(now.timeIntervalSince1970), + "exp": Int(now.addingTimeInterval(20 * 60).timeIntervalSince1970), + "aud": "appstoreconnect-v1", + ] + + let headerData = try JSONSerialization.data(withJSONObject: header) + let payloadData = try JSONSerialization.data(withJSONObject: payload) + + let headerB64 = headerData.base64URLEncoded() + let payloadB64 = payloadData.base64URLEncoded() + + let signingInput = "\(headerB64).\(payloadB64)" + let signature = try privateKey.signature(for: Data(signingInput.utf8)) + let signatureB64 = signature.rawRepresentation.base64URLEncoded() + + return "\(headerB64).\(payloadB64).\(signatureB64)" +} + +// MARK: - Base64URL Encoding + +extension Data { + func base64URLEncoded() -> String { + base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } +} + +// MARK: - API Client + +let baseURL = "https://api.appstoreconnect.apple.com/v1" + +func apiRequest(path: String, token: String, retries: Int = 3) async throws -> (Data, HTTPURLResponse) { + let urlString = path.hasPrefix("http") ? path : "\(baseURL)\(path)" + guard let url = URL(string: urlString) else { + throw URLError(.badURL) + } + + var request = URLRequest(url: url) + request.timeoutInterval = 30 + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + var lastError: Error? + for attempt in 1...retries { + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + if httpResponse.statusCode == 429 || (httpResponse.statusCode >= 500 && httpResponse.statusCode < 600) { + let delay = Double(attempt) * 2.0 + fputs(" Rate limited/server error (HTTP \(httpResponse.statusCode)), retrying in \(Int(delay))s...\n", stderr) + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + continue + } + + if httpResponse.statusCode >= 400 { + let body = String(data: data, encoding: .utf8) ?? "" + throw AppError.apiError(httpResponse.statusCode, body) + } + + return (data, httpResponse) + } catch let error as AppError { + throw error // don't retry client errors + } catch { + lastError = error + if attempt < retries { + let delay = Double(attempt) * 2.0 + fputs(" Request failed (\(error.localizedDescription)), retrying in \(Int(delay))s...\n", stderr) + try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) + } + } + } + + throw lastError ?? URLError(.timedOut) +} + +// MARK: - API Response Models + +struct APIResponse: Decodable { + let data: [T] + let included: [IncludedResource]? + let links: PageLinks? +} + +struct SingleResponse: Decodable { + let data: T +} + +struct PageLinks: Decodable { + let next: String? +} + +struct IncludedResource: Decodable { + let type: String + let id: String + let attributes: IncludedAttributes? +} + +struct IncludedAttributes: Decodable { + // SubscriptionPricePoint attributes + let customerPrice: String? + let proceeds: String? + let proceedsYear2: String? + // Territory attributes + let currency: String? + // Shared + let name: String? +} + +struct SubscriptionGroup: Decodable { + let type: String + let id: String + let attributes: SubscriptionGroupAttributes? +} + +struct SubscriptionGroupAttributes: Decodable { + let referenceName: String? +} + +struct Subscription: Decodable { + let type: String + let id: String + let attributes: SubscriptionAttributes +} + +struct SubscriptionAttributes: Decodable { + let productId: String? + let name: String? + let state: String? +} + +struct SubscriptionPrice: Decodable { + let type: String + let id: String + let attributes: SubscriptionPriceAttributes? + let relationships: SubscriptionPriceRelationships? +} + +struct SubscriptionPriceAttributes: Decodable { + let startDate: String? + let preserved: Bool? +} + +struct SubscriptionPriceRelationships: Decodable { + let subscriptionPricePoint: RelationshipData? + let territory: RelationshipData? +} + +struct RelationshipData: Decodable { + let data: ResourceIdentifier? +} + +struct ResourceIdentifier: Decodable { + let type: String + let id: String +} + +// MARK: - Price Entry (for display) + +struct PriceEntry { + let territory: String + let currency: String + let customerPrice: String + let proceeds: String +} + +// MARK: - Fetch Logic + +func findSubscription(appId: String, productId: String, token: String) async throws -> Subscription { + let groupsPath = "/apps/\(appId)/subscriptionGroups?limit=200" + let (groupsData, _) = try await apiRequest(path: groupsPath, token: token) + let groupsResponse = try JSONDecoder().decode(APIResponse.self, from: groupsData) + + if groupsResponse.data.isEmpty { + throw AppError.noSubscriptionFound(productId) + } + + for group in groupsResponse.data { + let subsPath = "/subscriptionGroups/\(group.id)/subscriptions?limit=200" + let (subsData, _) = try await apiRequest(path: subsPath, token: token) + let subsResponse = try JSONDecoder().decode(APIResponse.self, from: subsData) + + if let match = subsResponse.data.first(where: { $0.attributes.productId == productId }) { + return match + } + } + + throw AppError.noSubscriptionFound(productId) +} + +func findSubscriptionsInGroup(appId: String, groupName: String, token: String) async throws -> (SubscriptionGroup, [Subscription]) { + let groupsPath = "/apps/\(appId)/subscriptionGroups?limit=200" + let (groupsData, _) = try await apiRequest(path: groupsPath, token: token) + let groupsResponse = try JSONDecoder().decode(APIResponse.self, from: groupsData) + + // Case-insensitive match on referenceName + guard let group = groupsResponse.data.first(where: { + $0.attributes?.referenceName?.lowercased() == groupName.lowercased() + }) else { + let available = groupsResponse.data.compactMap { $0.attributes?.referenceName }.joined(separator: ", ") + fputs("Available groups: \(available)\n", stderr) + throw AppError.noGroupFound(groupName) + } + + let subsPath = "/subscriptionGroups/\(group.id)/subscriptions?limit=200" + let (subsData, _) = try await apiRequest(path: subsPath, token: token) + let subsResponse = try JSONDecoder().decode(APIResponse.self, from: subsData) + + return (group, subsResponse.data) +} + +struct RawPriceEntry { + let territory: String + let currency: String + let customerPrice: String + let proceeds: String + let startDate: Date? // nil = original price (no scheduled change) +} + +private let dateFormatter: DateFormatter = { + let df = DateFormatter() + df.dateFormat = "yyyy-MM-dd" + df.locale = Locale(identifier: "en_US_POSIX") + df.timeZone = TimeZone(identifier: "UTC") + return df +}() + +func fetchPrices(subscriptionId: String, token: String) async throws -> [PriceEntry] { + var rawEntries: [RawPriceEntry] = [] + var nextURL: String? = + "/subscriptions/\(subscriptionId)/prices?include=subscriptionPricePoint,territory&limit=200" + + let today = Date() + + while let currentPath = nextURL { + let (data, _) = try await apiRequest(path: currentPath, token: token) + let response = try JSONDecoder().decode(APIResponse.self, from: data) + + var pricePoints: [String: IncludedAttributes] = [:] + var territories: [String: IncludedAttributes] = [:] + + for resource in response.included ?? [] { + switch resource.type { + case "subscriptionPricePoints": + pricePoints[resource.id] = resource.attributes + case "territories": + territories[resource.id] = resource.attributes + default: + break + } + } + + for price in response.data { + let pricePointId = price.relationships?.subscriptionPricePoint?.data?.id + let territoryId = price.relationships?.territory?.data?.id + + let pp = pricePointId.flatMap { pricePoints[$0] } + let terr = territoryId.flatMap { territories[$0] } + + let startDate = price.attributes?.startDate.flatMap { dateFormatter.date(from: $0) } + + // Skip prices scheduled for the future + if let startDate, startDate > today { + continue + } + + rawEntries.append(RawPriceEntry( + territory: terr?.name ?? territoryId ?? "Unknown", + currency: terr?.currency ?? "???", + customerPrice: pp?.customerPrice ?? "-", + proceeds: pp?.proceeds ?? "-", + startDate: startDate + )) + } + + nextURL = response.links?.next + } + + // Keep only the most recent price per territory + var latestByTerritory: [String: RawPriceEntry] = [:] + for entry in rawEntries { + if let existing = latestByTerritory[entry.territory] { + let existingDate = existing.startDate ?? .distantPast + let newDate = entry.startDate ?? .distantPast + if newDate > existingDate { + latestByTerritory[entry.territory] = entry + } + } else { + latestByTerritory[entry.territory] = entry + } + } + + return latestByTerritory.values + .map { PriceEntry(territory: $0.territory, currency: $0.currency, customerPrice: $0.customerPrice, proceeds: $0.proceeds) } + .sorted { $0.territory < $1.territory } +} + +// MARK: - Display + +func padRight(_ s: String, _ width: Int) -> String { + s.count >= width ? String(s.prefix(width)) : s + String(repeating: " ", count: width - s.count) +} + +func padLeft(_ s: String, _ width: Int) -> String { + s.count >= width ? s : String(repeating: " ", count: width - s.count) + s +} + +func printPrices(_ entries: [PriceEntry], productId: String) { + print("\nSubscription Prices for: \(productId)") + print(String(repeating: "─", count: 72)) + print("\(padRight("Territory", 30)) \(padRight("Curr", 6)) \(padLeft("Price", 10)) \(padLeft("Proceeds", 10))") + print(String(repeating: "─", count: 72)) + + for entry in entries { + print("\(padRight(String(entry.territory.prefix(30)), 30)) \(padRight(entry.currency, 6)) \(padLeft(entry.customerPrice, 10)) \(padLeft(entry.proceeds, 10))") + } + + print(String(repeating: "─", count: 72)) + print("Total territories: \(entries.count)") +} + +// MARK: - CSV Export + +func csvEscape(_ field: String) -> String { + if field.contains(",") || field.contains("\"") || field.contains("\n") { + return "\"\(field.replacingOccurrences(of: "\"", with: "\"\""))\"" + } + return field +} + +func buildCSVRows(productId: String, entries: [PriceEntry]) -> [String] { + entries.map { entry in + [productId, entry.territory, entry.currency, entry.customerPrice, entry.proceeds] + .map { csvEscape($0) } + .joined(separator: ",") + } +} + +func sanitizeFilename(_ name: String) -> String { + let allowed = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) + return String(name.unicodeScalars.map { allowed.contains($0) ? Character($0) : Character("_") }) +} + +func writeCSV(rows: [String], name: String, to directory: String) throws { + let fm = FileManager.default + if !fm.fileExists(atPath: directory) { + try fm.createDirectory(atPath: directory, withIntermediateDirectories: true) + } + let filename = sanitizeFilename(name) + ".csv" + let path = (directory as NSString).appendingPathComponent(filename) + let header = "Product ID,Territory,Currency,Customer Price,Proceeds" + let content = ([header] + rows).joined(separator: "\n") + "\n" + try content.write(toFile: path, atomically: true, encoding: .utf8) + print(" CSV exported: \(path)") +} + +// MARK: - Argument Parsing + +func findArgValue(_ flag: String) -> String? { + let args = CommandLine.arguments + guard let idx = args.firstIndex(of: flag), idx + 1 < args.count else { return nil } + return args[idx + 1] +} + +func hasFlag(_ flag: String) -> Bool { + CommandLine.arguments.contains(flag) +} + +// Strip known flags from arguments to get positional args +func positionalArgs() -> [String] { + let args = Array(CommandLine.arguments.dropFirst()) // drop executable + let flagsWithValue = ["--group", "--csv"] + var positional: [String] = [] + var i = 0 + while i < args.count { + if flagsWithValue.contains(args[i]) { + i += 2 // skip flag and its value + } else if args[i].hasPrefix("--") { + i += 1 // skip standalone flag + } else { + positional.append(args[i]) + i += 1 + } + } + return positional +} + +// MARK: - Main + +let pos = positionalArgs() + +guard !pos.isEmpty else { + fputs("Usage: swift run pricing [product-id] [options]\n", stderr) + fputs(" swift run pricing --group [--csv ]\n", stderr) + fputs("\n", stderr) + fputs(" app-id: Numeric Apple ID (e.g. 6737844884)\n", stderr) + fputs(" product-id: Single subscription product ID\n", stderr) + fputs(" --group: Fetch all subscriptions in a named group\n", stderr) + fputs(" --csv: Export results to CSV files in this directory (one per subscription)\n", stderr) + exit(1) +} + +let appId = pos[0] +let csvDir = findArgValue("--csv") +let groupName = findArgValue("--group") +let singleProductId = pos.count >= 2 ? pos[1] : nil + +do { + let config = try Config.fromEnvironment() + let token = try generateJWT(config: config) + print("Authenticating with App Store Connect API...") + + if let groupName { + let (group, subscriptions) = try await findSubscriptionsInGroup( + appId: appId, groupName: groupName, token: token) + + print( + "Group: \(group.attributes?.referenceName ?? group.id) — \(subscriptions.count) subscription(s)\n" + ) + + for (index, sub) in subscriptions.enumerated() { + let name = sub.attributes.name ?? sub.attributes.productId ?? sub.id + let pid = sub.attributes.productId ?? sub.id + let state = sub.attributes.state ?? "unknown" + print("[\(name)] (state: \(state))") + + let prices = try await fetchPrices(subscriptionId: sub.id, token: token) + printPrices(prices, productId: pid) + + if let csvDir { + let rows = buildCSVRows(productId: pid, entries: prices) + try writeCSV(rows: rows, name: name, to: csvDir) + } + print("") + + // Brief pause between subscriptions to avoid rate limiting + if index < subscriptions.count - 1 { + try await Task.sleep(nanoseconds: 1_000_000_000) + } + } + } else if let singleProductId { + let subscription = try await findSubscription( + appId: appId, productId: singleProductId, token: token) + let name = subscription.attributes.name ?? singleProductId + print( + "Found subscription: \(name) (state: \(subscription.attributes.state ?? "unknown"))" + ) + + let prices = try await fetchPrices(subscriptionId: subscription.id, token: token) + printPrices(prices, productId: singleProductId) + + if let csvDir { + let rows = buildCSVRows(productId: singleProductId, entries: prices) + try writeCSV(rows: rows, name: name, to: csvDir) + } + } else { + fputs("Error: provide a product-id or --group \n", stderr) + exit(1) + } +} catch { + fputs("Error: \(error.localizedDescription)\n", stderr) + exit(1) +}