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) }