init
This commit is contained in:
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -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/
|
||||
10
Package.swift
Normal file
10
Package.swift
Normal file
@@ -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")
|
||||
]
|
||||
)
|
||||
106
README.md
Normal file
106
README.md
Normal file
@@ -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 <app-id> [product-id] [options]
|
||||
swift run pricing <app-id> --group <group-name> [--csv <directory>]
|
||||
```
|
||||
|
||||
### 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 <name>` | Fetch all subscriptions in a named subscription group |
|
||||
| `--csv <directory>` | 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
|
||||
546
Sources/main.swift
Normal file
546
Sources/main.swift
Normal file
@@ -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 <product-id>"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) ?? "<no body>"
|
||||
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<T: Decodable>: Decodable {
|
||||
let data: [T]
|
||||
let included: [IncludedResource]?
|
||||
let links: PageLinks?
|
||||
}
|
||||
|
||||
struct SingleResponse<T: Decodable>: 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<SubscriptionGroup>.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<Subscription>.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<SubscriptionGroup>.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<Subscription>.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<SubscriptionPrice>.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 <app-id> [product-id] [options]\n", stderr)
|
||||
fputs(" swift run pricing <app-id> --group <group-name> [--csv <file>]\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 <name>\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
} catch {
|
||||
fputs("Error: \(error.localizedDescription)\n", stderr)
|
||||
exit(1)
|
||||
}
|
||||
Reference in New Issue
Block a user