Changed AppStoreClient to behave more like a simple Facade (#1)

This commit is contained in:
Fabian Fett 2020-07-21 08:47:47 +02:00 committed by GitHub
parent ff07f16949
commit 06092375bf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 633 additions and 541 deletions

View File

@ -8,51 +8,62 @@ on:
- master
jobs:
"sanity-Tests":
runs-on: macOS-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Install swiftformat
run: brew install swiftformat
- name: Run sanity
run: ./scripts/sanity.sh .
"tuxOS-Tests":
runs-on: ubuntu-latest
strategy:
matrix:
tag: ['5.1']
images:
- swift:5.1
- swift:5.2
- swiftlang/swift:nightly-5.3-bionic
container:
image: swift:${{ matrix.tag }}
volumes:
- $GITHUB_WORKSPACE:/src
options: --workdir /src
image: ${{ matrix.images }}
steps:
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 1
uses: actions/checkout@v2
- name: Install dependencies
run: apt-get update && apt-get install -y zlib1g-dev zip openssl libssl-dev
run: apt-get update && apt-get install -y curl zlib1g-dev zip openssl libssl-dev
- name: Test
run: swift test --enable-code-coverage --enable-test-discovery
- name: Convert coverage files
run: llvm-cov export -format="lcov" .build/debug/swift-app-store-receipt-validationPackageTests.xctest -instr-profile .build/debug/codecov/default.profdata > info.lcov
- name: Upload to codecov.io
uses: codecov/codecov-action@v1.0.3
uses: codecov/codecov-action@v1
with:
token: ${{secrets.CODECOV_TOKEN}}
file: info.lcov
"macOS-Tests":
runs-on: macOS-latest
strategy:
matrix:
xcode:
- Xcode_11.1.app
- Xcode_11.6.app
- Xcode_12.app
steps:
- name: Checkout
uses: actions/checkout@v1
with:
fetch-depth: 1
uses: actions/checkout@v2
- name: Show all Xcode versions
run: ls -an /Applications/ | grep Xcode*
- name: Change Xcode command line tools
run: sudo xcode-select -s /Applications/Xcode_11.2.app/Contents/Developer
- name: SPM Build
run: swift build
- name: SPM Tests
run: swift test --parallel -Xswiftc -DDEBUG
- name: Swift version
run: swift --version
- name: Xcode Tests
run: |
swift package generate-xcodeproj
xcodebuild -quiet -parallel-testing-enabled YES -scheme swift-app-store-receipt-validation-Package -enableCodeCoverage YES build test
- name: Codecov
run: bash <(curl -s https://codecov.io/bash) -J 'AppStoreReceiptValidation' -t ${{secrets.CODECOV_TOKEN}}
run: bash <(curl -s https://codecov.io/bash) -t ${{secrets.CODECOV_TOKEN}} -f *.coverage.txt

1
.gitignore vendored
View File

@ -2,3 +2,4 @@
.build
/*.xcodeproj
xcuserdata
Package.resolved

View File

@ -1,43 +0,0 @@
{
"object": {
"pins": [
{
"package": "async-http-client",
"repositoryURL": "https://github.com/swift-server/async-http-client.git",
"state": {
"branch": null,
"revision": "51dc885a30ca704b02fa803099b0a9b5b38067b6",
"version": "1.0.0"
}
},
{
"package": "swift-nio",
"repositoryURL": "https://github.com/apple/swift-nio.git",
"state": {
"branch": null,
"revision": "ff01888051cd7efceb1bf8319c1dd3986c4bf6fc",
"version": "2.10.1"
}
},
{
"package": "swift-nio-extras",
"repositoryURL": "https://github.com/apple/swift-nio-extras.git",
"state": {
"branch": null,
"revision": "53808818c2015c45247cad74dc05c7a032c96a2f",
"version": "1.3.2"
}
},
{
"package": "swift-nio-ssl",
"repositoryURL": "https://github.com/apple/swift-nio-ssl.git",
"state": {
"branch": null,
"revision": "ccf96bbe65ecc7c1558ab0dba7ffabdea5c1d31f",
"version": "2.4.4"
}
}
]
},
"version": 1
}

View File

@ -4,33 +4,31 @@
import PackageDescription
let package = Package(
name: "swift-app-store-receipt-validation",
platforms: [
.macOS(.v10_12),
.iOS(.v10),
.watchOS(.v3),
.tvOS(.v10),
],
products: [
// Products define the executables and libraries produced by a package, and make them visible to other packages.
.library(
name: "AppStoreReceiptValidation",
targets: ["AppStoreReceiptValidation"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
.package(url: "https://github.com/apple/swift-nio.git", .upToNextMajor(from: "2.10.0")),
.package(url: "https://github.com/swift-server/async-http-client.git", .upToNextMajor(from: "1.0.0"))
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages which this package depends on.
.target(
name: "AppStoreReceiptValidation",
dependencies: ["AsyncHTTPClient", "NIO", "NIOFoundationCompat"]),
.testTarget(
name: "AppStoreReceiptValidationTests",
dependencies: ["AppStoreReceiptValidation"]),
]
name: "swift-app-store-receipt-validation",
platforms: [
.macOS(.v10_12),
.iOS(.v10),
.watchOS(.v3),
.tvOS(.v10),
],
products: [
.library(
name: "AppStoreReceiptValidation",
targets: ["AppStoreReceiptValidation"]
),
],
dependencies: [
.package(url: "https://github.com/apple/swift-nio.git", .upToNextMajor(from: "2.10.0")),
.package(url: "https://github.com/swift-server/async-http-client.git", .upToNextMajor(from: "1.0.0")),
],
targets: [
.target(name: "AppStoreReceiptValidation", dependencies: [
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "NIO", package: "swift-nio"),
.product(name: "NIOFoundationCompat", package: "swift-nio"),
]),
.testTarget(name: "AppStoreReceiptValidationTests", dependencies: [
.byName(name: "AppStoreReceiptValidation"),
]),
]
)

View File

@ -3,8 +3,6 @@
[![Swift 5.1](https://img.shields.io/badge/Swift-5.1-blue.svg)](https://swift.org/download/)
[![github-actions](https://github.com/fabianfett/swift-aws-lambda/workflows/CI/badge.svg)](https://github.com/fabianfett/swift-aws-lambda/actions)
[![codecov](https://codecov.io/gh/fabianfett/swift-app-store-receipt-validation/branch/master/graph/badge.svg)](https://codecov.io/gh/fabianfett/swift-app-store-receipt-validation)
![macOS](https://img.shields.io/badge/os-macOS-green.svg?style=flat)
![tuxOS](https://img.shields.io/badge/os-tuxOS-green.svg?style=flat)
This package implements the [validating receipts with the app store](https://developer.apple.com/library/archive/releasenotes/General/ValidateAppStoreReceipt/Chapters/ValidateRemotely.html#//apple_ref/doc/uid/TP40010573-CH104-SW1) api.
@ -14,3 +12,41 @@ This package implements the [validating receipts with the app store](https://dev
- [x] Automatic retry, if sandbox receipt was send to production.
- [x] Response object is pure swift struct using enums.
- [x] API Erros are translated into corresponding swift errors.
## Usage
Add `swift-app-store-receipt-validation`, `async-http-client` and `swift-nio` as dependencies to
your project. For this open your `Package.swift` and add this to your dependencies:
```swift
dependencies: [
.package(url: "https://github.com/swift-server/async-http-client", .upToNextMajor(from: "1.1.0")),
.package(url: "https://github.com/apple/swift-nio", .upToNextMajor(from: "2.14.0")),
.package(url: "https://github.com/fabianfett/swift-app-store-receipt-validation", .upToNextMajor(from: "0.1.0")),
]
```
Then, add `AsyncHTTPClient`, `SwiftNIO` and `AppStoreReceiptValidation` as target dependencies.
```swift
targets: [
.target(name: "Hello", dependencies: [
.product(name: "NIO", package: "swift-nio"),
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "AppStoreReceiptValidation", package: "swift-app-store-receipt-validation"),
]
]
```
To verify an AppStore Receipt in your code you need to create an `HTTPClient` first:
```swift
let httpClient = HTTPClient(eventLoopGroupProvider: .createNew)
defer { try? httpClient.syncShutdown() }
let appStoreClient = AppStore.Client(httpClient: httpClient, secret: "abc123")
let base64EncodedReceipt: String = ...
let receipt = try appStoreClient.validateReceipt(base64EncodedReceipt).wait()
```

View File

@ -1,77 +0,0 @@
import Foundation
import NIO
import NIOFoundationCompat
import AsyncHTTPClient
public class AppStoreClient {
let httpClient: HTTPClient
let secret : String?
let allocator = ByteBufferAllocator()
let encoder = JSONEncoder()
let decoder = JSONDecoder() // TBD:
public init(eventLoopGroup: EventLoopGroup, secret: String?) {
self.httpClient = HTTPClient(eventLoopGroupProvider: .shared(eventLoopGroup))
self.secret = secret
self.decoder.dateDecodingStrategy = .custom { (decoder) -> Date in
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
guard let timeIntervalSince1970inMs = Double(string) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: "Expected to have a TimeInterval in ms within the string to decode a date.")
}
return Date(timeIntervalSince1970: timeIntervalSince1970inMs / 1000)
}
}
public func syncShutdown() throws {
try self.httpClient.syncShutdown()
}
public func validateAppStoreReceipt(_ receipt: String, excludeOldTransactions: Bool? = nil)
-> EventLoopFuture<Receipt>
{
let request = Request(
receiptData: receipt,
password: secret,
excludeOldTransactions: excludeOldTransactions)
return self.executeRequest(request, in: .production)
.flatMapError { (error) -> EventLoopFuture<AppStoreClient.Response> in
switch error {
case Error.receiptIsFromTestEnvironmentButWasSentToProductionEnvironment:
return self.executeRequest(request, in: .sandbox)
default:
// TBD: This doesn't look good. Maybe we keep the eventLoopGroup for ourselfs?
return self.httpClient.eventLoopGroup.next().makeFailedFuture(error)
}
}
.map { (response) -> (Receipt) in
return response.receipt
}
}
private func executeRequest(
_ request: Request,
in environment: Environment) -> EventLoopFuture<Response>
{
let buffer = try! encoder.encodeAsByteBuffer(request, allocator: allocator)
return self.httpClient.post(url: environment.url, body: .byteBuffer(buffer), deadline: NIODeadline.now() + .seconds(5))
.flatMapThrowing { (resp) throws -> (Response) in
let status = try self.decoder.decode(Status.self, from: resp.body!)
if status.status != 0 {
throw Error(statusCode: status.status)
}
return try self.decoder.decode(Response.self, from: resp.body!)
}
}
}

View File

@ -0,0 +1,93 @@
import AsyncHTTPClient
import class Foundation.JSONDecoder
import class Foundation.JSONEncoder
import NIO
import NIOFoundationCompat
public protocol AppStoreClientRequestEncoder {
func encodeAsByteBuffer<T: Encodable>(_ value: T, allocator: ByteBufferAllocator) throws -> ByteBuffer
}
public protocol AppStoreClientResponseDecoder {
func decode<T: Decodable>(_ type: T.Type, from: ByteBuffer) throws -> T
}
public enum AppStore {
public struct Client {
let httpClient: HTTPClient
let secret: String?
let allocator = ByteBufferAllocator()
let encoder: AppStoreClientRequestEncoder
let decoder: AppStoreClientResponseDecoder
public init(httpClient: HTTPClient, secret: String?) {
self.init(httpClient: httpClient,
encoder: JSONEncoder(),
decoder: JSONDecoder(),
secret: secret)
}
public init(
httpClient: HTTPClient,
encoder: AppStoreClientRequestEncoder,
decoder: AppStoreClientResponseDecoder,
secret: String?
) {
self.httpClient = httpClient
self.encoder = encoder
self.decoder = decoder
self.secret = secret
}
public func validateReceipt(_ receipt: String, excludeOldTransactions: Bool? = nil, allocator: ByteBufferAllocator? = nil, on eventLoop: EventLoop? = nil)
-> EventLoopFuture<Receipt> {
let eventLoop = eventLoop ?? self.httpClient.eventLoopGroup.next()
let allocator = allocator ?? self.allocator
let request = Request(
receiptData: receipt,
password: self.secret,
excludeOldTransactions: excludeOldTransactions
)
return executeRequest(request, in: .production, allocator: allocator, on: eventLoop)
.flatMapError { (error) -> EventLoopFuture<AppStore.Response> in
switch error {
case Error.receiptIsFromTestEnvironmentButWasSentToProductionEnvironment:
return self.executeRequest(request, in: .sandbox, allocator: allocator, on: eventLoop)
default:
// TBD: This doesn't look good. Maybe we keep the eventLoopGroup for ourselfs?
return eventLoop.makeFailedFuture(error)
}
}
.map { (response) -> (Receipt) in
response.receipt
}
}
private func executeRequest(_ request: Request, in environment: Environment, allocator: ByteBufferAllocator, on eventLoop: EventLoop)
-> EventLoopFuture<Response> {
return eventLoop.makeSucceededFuture(())
.flatMapThrowing { (_) -> HTTPClient.Request in
let buffer = try self.encoder.encodeAsByteBuffer(request, allocator: allocator)
return try HTTPClient.Request(url: environment.url, method: .POST, body: .byteBuffer(buffer))
}
.flatMap { (request) -> EventLoopFuture<HTTPClient.Response> in
self.httpClient.execute(request: request, eventLoop: .delegateAndChannel(on: eventLoop))
}
.flatMapThrowing { (resp) throws -> (Response) in
let status = try self.decoder.decode(Status.self, from: resp.body!)
if status.status != 0 {
throw Error(statusCode: status.status)
}
return try self.decoder.decode(Response.self, from: resp.body!)
}
}
}
}
extension JSONEncoder: AppStoreClientRequestEncoder {}
extension JSONDecoder: AppStoreClientResponseDecoder {}

View File

@ -1,331 +1,385 @@
import Foundation
import struct Foundation.Date
extension AppStoreClient {
enum Environment: String, Codable {
case sandbox = "Sandbox"
case production = "Production"
var url: String {
switch self {
case .sandbox:
return "https://sandbox.itunes.apple.com/verifyReceipt"
case .production:
return "https://buy.itunes.apple.com/verifyReceipt"
}
extension AppStore {
public enum Error: Swift.Error {
/// The App Store could not read the JSON object you provided.
case invalidJSONObject
/// The data in the receipt-data property was malformed or missing.
case receiptDataMalformedOrMissing
/// The receipt could not be authenticated.
case receiptCouldNotBeAuthenticated
/// The shared secret you provided does not match the shared secret on file for your account.
case sharedSecretDoesNotMatchTheSharedSecretOnFileForAccount
/// The receipt server is not currently available.
case receiptServerIsCurrentlyUnavailable
/// This receipt is valid but the subscription has expired. When this status code is returned to your server, the receipt data
/// is also decoded and returned as part of the response.
/// _Only returned for iOS 6 style transaction receipts for auto-renewable subscriptions._
case receiptIsValidButSubscriptionHasExpired
/// This receipt is from the test environment, but it was sent to the production environment for verification.
/// Send it to the test environment instead.
case receiptIsFromTestEnvironmentButWasSentToProductionEnvironment
/// This receipt is from the production environment, but it was sent to the test environment for verification.
/// Send it to the production environment instead.
case receiptIsFromProductionEnvironmentButWasSentToTestEnvironment
/// This receipt could not be authorized. Treat this the same as if a purchase was never made.
case receiptCouldNotBeAuthorized
/// Internal data access error.
case internalDataAccessError
/// Catch all error introduced by this library to handle unknown status codes
case unknownError
init(statusCode: Int) {
switch statusCode {
case 21000:
self = .invalidJSONObject
case 21002:
self = .receiptDataMalformedOrMissing
case 21003:
self = .receiptCouldNotBeAuthenticated
case 21004:
self = .sharedSecretDoesNotMatchTheSharedSecretOnFileForAccount
case 21005:
self = .receiptServerIsCurrentlyUnavailable
case 21006:
self = .receiptIsValidButSubscriptionHasExpired
case 21007:
self = .receiptIsFromTestEnvironmentButWasSentToProductionEnvironment
case 21008:
self = .receiptIsFromProductionEnvironmentButWasSentToTestEnvironment
case 21010:
self = .receiptCouldNotBeAuthorized
case 21100 ... 21199:
self = .internalDataAccessError
default:
self = .unknownError
}
}
}
}
public enum Error: Swift.Error {
/// The App Store could not read the JSON object you provided.
case invalidJSONObject
/// The data in the receipt-data property was malformed or missing.
case receiptDataMalformedOrMissing
/// The receipt could not be authenticated.
case receiptCouldNotBeAuthenticated
/// The shared secret you provided does not match the shared secret on file for your account.
case sharedSecretDoesNotMatchTheSharedSecretOnFileForAccount
/// The receipt server is not currently available.
case receiptServerIsCurrentlyUnavailable
/// This receipt is valid but the subscription has expired. When this status code is returned to your server, the receipt data
/// is also decoded and returned as part of the response.
/// _Only returned for iOS 6 style transaction receipts for auto-renewable subscriptions._
case receiptIsValidButSubscriptionHasExpired
/// This receipt is from the test environment, but it was sent to the production environment for verification.
/// Send it to the test environment instead.
case receiptIsFromTestEnvironmentButWasSentToProductionEnvironment
/// This receipt is from the production environment, but it was sent to the test environment for verification.
/// Send it to the production environment instead.
case receiptIsFromProductionEnvironmentButWasSentToTestEnvironment
/// This receipt could not be authorized. Treat this the same as if a purchase was never made.
case receiptCouldNotBeAuthorized
/// Internal data access error.
case internalDataAccessError
/// Catch all error introduced by this library to handle unknown status codes
case unknownError
init(statusCode: Int) {
switch statusCode {
case 21000:
self = .invalidJSONObject
case 21002:
self = .receiptDataMalformedOrMissing
case 21003:
self = .receiptCouldNotBeAuthenticated
case 21004:
self = .sharedSecretDoesNotMatchTheSharedSecretOnFileForAccount
case 21005:
self = .receiptServerIsCurrentlyUnavailable
case 21006:
self = .receiptIsValidButSubscriptionHasExpired
case 21007:
self = .receiptIsFromTestEnvironmentButWasSentToProductionEnvironment
case 21008:
self = .receiptIsFromProductionEnvironmentButWasSentToTestEnvironment
case 21010:
self = .receiptCouldNotBeAuthorized
case 21100...21199:
self = .internalDataAccessError
default:
self = .unknownError
}
public struct Receipt: Codable {
public let bundleId: String
public let applicationVersion: String
public let inApp: [InAppPurchase]
public let originalApplicationVersion: String
public let receiptCreationDate: Date
public let receiptExpirationDate: Date?
enum CodingKeys: String, CodingKey {
case bundleId = "bundle_id"
case applicationVersion = "application_version"
case inApp = "in_app"
case originalApplicationVersion = "original_application_version"
case receiptCreationDate = "receipt_creation_date_ms"
case receiptExpirationDate = "receipt_expiration_date_ms"
}
}
}
public struct Receipt: Codable {
public let bundleId: String
public let applicationVersion: String
public let inApp: [InAppPurchase]
public let originalApplicationVersion: String
public let receiptCreationDate: Date
public let receiptExpirationDate: Date?
enum CodingKeys: String, CodingKey {
case bundleId = "bundle_id"
case applicationVersion = "application_version"
case inApp = "in_app"
case originalApplicationVersion = "original_application_version"
case receiptCreationDate = "receipt_creation_date_ms"
case receiptExpirationDate = "receipt_expiration_date_ms"
public struct InAppPurchase: Codable {
/// The number of items purchased.
///
/// This value corresponds to the quantity property of the SKPayment object stored in the transactions payment property.
public let quantity: String
/// The product identifier of the item that was purchased.
///
/// This value corresponds to the productIdentifier property of the SKPayment object stored in the transactions payment property.
public let productId: String
/// The transaction identifier of the item that was purchased.
///
/// This value corresponds to the transactions `transactionIdentifier` property.
///
/// For a transaction that restores a previous transaction, this value is different from the transaction identifier of the original
/// purchase transaction. In an auto-renewable subscription receipt, a new value for the transaction identifier is generated every
/// time the subscription automatically renews or is restored on a new device.
public let transactionId: String
/// For a transaction that restores a previous transaction, the transaction identifier of the original transaction. Otherwise, identical
/// to the transaction identifier.
///
/// This value corresponds to the original transactions transactionIdentifier property.
///
/// This value is the same for all receipts that have been generated for a specific subscription. This value is useful for relating
/// together multiple iOS 6 style transaction receipts for the same individual customers subscription.
public let originalTransactionId: String
/// The date and time that the item was purchased.
///
/// This value corresponds to the transactions transactionDate property.
///
/// For a transaction that restores a previous transaction, the purchase date is the same as the original purchase date. Use
/// Original Purchase Date to get the date of the original transaction.
///
/// In an auto-renewable subscription receipt, the purchase date is the date when the subscription was either purchased or
/// renewed (with or without a lapse). For an automatic renewal that occurs on the expiration date of the current period, the
/// purchase date is the start date of the next period, which is identical to the end date of the current period.
public let purchaseDate: Date
/// For a transaction that restores a previous transaction, the date of the original transaction.
///
/// This value corresponds to the original transactions transactionDate property.
///
/// In an auto-renewable subscription receipt, this indicates the beginning of the subscription period, even if the subscription
/// has been renewed.
public let originalPurchaseDate: Date
/// The expiration date for the subscription, expressed as the number of milliseconds since January 1, 1970, 00:00:00 GMT.
///
/// This key is only present for auto-renewable subscription receipts. Use this value to identify the date when the subscription
/// will renew or expire, to determine if a customer should have access to content or service. After validating the latest receipt,
/// if the subscription expiration date for the latest renewal transaction is a past date, it is safe to assume that the subscription
/// has expired.
public let subscriptionExpirationDate: Date?
/// For an expired subscription, the reason for the subscription expiration.
///
/// - 1 - Customer canceled their subscription.
/// - 2 - Billing error; for example customers payment information was no longer valid.
/// - 3 - Customer did not agree to a recent price increase.
/// - 4 - Product was not available for purchase at the time of renewal.
/// - 5 - Unknown error.
///
/// This key is only present for a receipt containing an expired auto-renewable subscription. You can use this value to decide
/// whether to display appropriate messaging in your app for customers to resubscribe.
public let subscriptionExpirationIntent: SubscriptionExpirationIntent?
/// For an expired subscription, whether or not Apple is still attempting to automatically renew the subscription.
///
/// - 1 - App Store is still attempting to renew the subscription.
/// - 0 - App Store has stopped attempting to renew the subscription.
///
/// This key is only present for auto-renewable subscription receipts. If the customers subscription failed to renew because
/// the App Store was unable to complete the transaction, this value will reflect whether or not the App Store is still trying to
/// renew the subscription.
public let subscriptionRetryFlag: SubscriptionRetryFlag?
/// For a subscription, whether or not it is in the free trial period.
///
/// This key is only present for auto-renewable subscription receipts. The value for this key is "true" if the customers
/// subscription is currently in the free trial period, or "false" if not.
///
/// **Note**: If a previous subscription period in the receipt has the value true for either the `is_trial_period` or the
/// `is_in_intro_offer_period` key, the user is not eligible for a free trial or introductory price within that
/// subscription group.
public let subscriptionTrialPeriod: SubscriptionTrialPeriod?
/// For an auto-renewable subscription, whether or not it is in the introductory price period.
///
/// This key is only present for auto-renewable subscription receipts. The value for this key is "true" if the customers
/// subscription is currently in an introductory price period, or "false" if not.
///
/// **Note**: If a previous subscription period in the receipt has the value true for either the `is_trial_period` or the
/// `is_in_intro_offer_period` key, the user is not eligible for a free trial or introductory price within that
/// subscription group.
public let subscriptionIsInIntroductoryPricePeriod: SubscriptionIntroductoryPricePeriod?
/// The current renewal status for the auto-renewable subscription.
///
/// - 1 - Subscription will renew at the end of the current subscription period.
/// - 0 - Customer has turned off automatic renewal for their subscription.
///
/// This key is only present for auto-renewable subscription receipts, for active or expired subscriptions. The value for
/// this key should not be interpreted as the customers subscription status. You can use this value to display an
/// alternative subscription product in your app, for example, a lower level subscription plan that the customer can
/// downgrade to from their current plan.
public let subscriptionAutoRenewStatus: SubscriptionAutoRenewStatus?
/// The current renewal preference for the auto-renewable subscription.
///
/// This key is only present for auto-renewable subscription receipts. The value for this key corresponds to the
/// `productIdentifier` property of the product that the customers subscription renews. You can use this value to
/// present an alternative service level to the customer before the current subscription period ends.
public let subscriptionAutoRenewPreface: String?
/// The current price consent status for a subscription price increase.
///
/// - 1 - Customer has agreed to the price increase. Subscription will renew at the higher price.
/// - 0 - Customer has not taken action regarding the increased price. Subscription expires if the customer
/// takes no action before the renewal date.
///
/// This key is only present for auto-renewable subscription receipts if the subscription price was
/// increased without keeping the existing price for active subscribers. You can use this value to
/// track customer adoption of the new price and take appropriate action.
public let subscriptionPriceConsentStatus: SubscriptionPriceConsentStatus?
/// For a transaction that was canceled by Apple customer support, the time and date of the cancellation. For an
/// auto-renewable subscription plan that was upgraded, the time and date of the upgrade transaction.
///
/// Treat a canceled receipt the same as if no purchase had ever been made.
///
/// **Note**: A canceled in-app purchase remains in the receipt indefinitely. Only applicable if the refund was for a
/// non-consumable product, an auto-renewable subscription, a non-renewing subscription, or for a
/// free subscription.
public let cancellationDate: Date?
/// For a transaction that was canceled, the reason for cancellation.
///
/// - 1 - Customer canceled their transaction due to an actual or perceived issue within your app.
/// - 0 - Transaction was canceled for another reason, for example, if the customer made the purchase accidentally.
///
/// Use this value along with the cancellation date to identify possible issues in your app that may lead customers to
/// contact Apple customer support.
public let cancellationReason: CancellationReason?
/// A string that the App Store uses to uniquely identify the application that created the transaction.
///
/// If your server supports multiple applications, you can use this value to differentiate between them. Apps are assigned an
/// identifier only in the production environment, so this key is not present for receipts created in the test environment.
///
/// This field is not present for Mac apps.
public let appItemId: String?
/// An arbitrary number that uniquely identifies a revision of your application.
///
/// This key is not present for receipts created in the test environment. Use this value to identify the version of the app that
/// the customer bought.
public let externalVersionIdentifier: String?
/// The primary key for identifying subscription purchases.
///
/// This value is a unique ID that identifies purchase events across devices, including subscription renewal purchase events.
public let webOrderLineItemId: String?
enum CodingKeys: String, CodingKey {
case quantity
case productId = "product_id"
case transactionId = "transaction_id"
case originalTransactionId = "original_transaction_id"
case purchaseDate = "purchase_date_ms"
case originalPurchaseDate = "original_purchase_date_ms"
case subscriptionExpirationDate = "subscription_expiration_date_ms"
case subscriptionExpirationIntent = "expiration_intent"
case subscriptionRetryFlag = "is_in_billing_retry_period"
case subscriptionTrialPeriod = "is_trial_period"
case subscriptionIsInIntroductoryPricePeriod = "is_in_intro_offer_period"
case subscriptionAutoRenewStatus = "auto_renew_status"
case subscriptionAutoRenewPreface = "auto_renew_product_id"
case subscriptionPriceConsentStatus = "price_consent_status"
case cancellationDate = "cancellationDateMS"
case cancellationReason = "cancellation_reason"
case appItemId = "app_item_id"
case externalVersionIdentifier = "version_external_identifier"
case webOrderLineItemId = "web_order_line_item_id"
}
}
}
public struct InAppPurchase: Codable {
/// The number of items purchased.
///
/// This value corresponds to the quantity property of the SKPayment object stored in the transactions payment property.
public let quantity : String
/// The product identifier of the item that was purchased.
///
/// This value corresponds to the productIdentifier property of the SKPayment object stored in the transactions payment property.
public let productId : String
/// The transaction identifier of the item that was purchased.
///
/// This value corresponds to the transactions `transactionIdentifier` property.
///
/// For a transaction that restores a previous transaction, this value is different from the transaction identifier of the original
/// purchase transaction. In an auto-renewable subscription receipt, a new value for the transaction identifier is generated every
/// time the subscription automatically renews or is restored on a new device.
public let transactionId : String
/// For a transaction that restores a previous transaction, the transaction identifier of the original transaction. Otherwise, identical
/// to the transaction identifier.
///
/// This value corresponds to the original transactions transactionIdentifier property.
///
/// This value is the same for all receipts that have been generated for a specific subscription. This value is useful for relating
/// together multiple iOS 6 style transaction receipts for the same individual customers subscription.
public let originalTransactionId : String
/// The date and time that the item was purchased.
///
/// This value corresponds to the transactions transactionDate property.
///
/// For a transaction that restores a previous transaction, the purchase date is the same as the original purchase date. Use
/// Original Purchase Date to get the date of the original transaction.
///
/// In an auto-renewable subscription receipt, the purchase date is the date when the subscription was either purchased or
/// renewed (with or without a lapse). For an automatic renewal that occurs on the expiration date of the current period, the
/// purchase date is the start date of the next period, which is identical to the end date of the current period.
public let purchaseDate : Date
/// For a transaction that restores a previous transaction, the date of the original transaction.
///
/// This value corresponds to the original transactions transactionDate property.
///
/// In an auto-renewable subscription receipt, this indicates the beginning of the subscription period, even if the subscription
/// has been renewed.
public let originalPurchaseDate : Date
/// The expiration date for the subscription, expressed as the number of milliseconds since January 1, 1970, 00:00:00 GMT.
///
/// This key is only present for auto-renewable subscription receipts. Use this value to identify the date when the subscription
/// will renew or expire, to determine if a customer should have access to content or service. After validating the latest receipt,
/// if the subscription expiration date for the latest renewal transaction is a past date, it is safe to assume that the subscription
/// has expired.
public let subscriptionExpirationDate : Date?
/// For an expired subscription, the reason for the subscription expiration.
///
/// - 1 - Customer canceled their subscription.
/// - 2 - Billing error; for example customers payment information was no longer valid.
/// - 3 - Customer did not agree to a recent price increase.
/// - 4 - Product was not available for purchase at the time of renewal.
/// - 5 - Unknown error.
///
/// This key is only present for a receipt containing an expired auto-renewable subscription. You can use this value to decide
/// whether to display appropriate messaging in your app for customers to resubscribe.
public let subscriptionExpirationIntent : SubscriptionExpirationIntent?
/// For an expired subscription, whether or not Apple is still attempting to automatically renew the subscription.
///
/// - 1 - App Store is still attempting to renew the subscription.
/// - 0 - App Store has stopped attempting to renew the subscription.
///
/// This key is only present for auto-renewable subscription receipts. If the customers subscription failed to renew because
/// the App Store was unable to complete the transaction, this value will reflect whether or not the App Store is still trying to
/// renew the subscription.
public let subscriptionRetryFlag : SubscriptionRetryFlag?
/// For a subscription, whether or not it is in the free trial period.
///
/// This key is only present for auto-renewable subscription receipts. The value for this key is "true" if the customers
/// subscription is currently in the free trial period, or "false" if not.
///
/// **Note**: If a previous subscription period in the receipt has the value true for either the `is_trial_period` or the
/// `is_in_intro_offer_period` key, the user is not eligible for a free trial or introductory price within that
/// subscription group.
public let subscriptionTrialPeriod : SubscriptionTrialPeriod?
/// For an auto-renewable subscription, whether or not it is in the introductory price period.
///
/// This key is only present for auto-renewable subscription receipts. The value for this key is "true" if the customers
/// subscription is currently in an introductory price period, or "false" if not.
///
/// **Note**: If a previous subscription period in the receipt has the value true for either the `is_trial_period` or the
/// `is_in_intro_offer_period` key, the user is not eligible for a free trial or introductory price within that
/// subscription group.
public let subscriptionIsInIntroductoryPricePeriod : SubscriptionIntroductoryPricePeriod?
/// The current renewal status for the auto-renewable subscription.
///
/// - 1 - Subscription will renew at the end of the current subscription period.
/// - 0 - Customer has turned off automatic renewal for their subscription.
///
/// This key is only present for auto-renewable subscription receipts, for active or expired subscriptions. The value for
/// this key should not be interpreted as the customers subscription status. You can use this value to display an
/// alternative subscription product in your app, for example, a lower level subscription plan that the customer can
/// downgrade to from their current plan.
public let subscriptionAutoRenewStatus : SubscriptionAutoRenewStatus?
/// The current renewal preference for the auto-renewable subscription.
///
/// This key is only present for auto-renewable subscription receipts. The value for this key corresponds to the
/// `productIdentifier` property of the product that the customers subscription renews. You can use this value to
/// present an alternative service level to the customer before the current subscription period ends.
public let subscriptionAutoRenewPreface : String?
/// The current price consent status for a subscription price increase.
///
/// - 1 - Customer has agreed to the price increase. Subscription will renew at the higher price.
/// - 0 - Customer has not taken action regarding the increased price. Subscription expires if the customer
/// takes no action before the renewal date.
///
/// This key is only present for auto-renewable subscription receipts if the subscription price was
/// increased without keeping the existing price for active subscribers. You can use this value to
/// track customer adoption of the new price and take appropriate action.
public let subscriptionPriceConsentStatus : SubscriptionPriceConsentStatus?
/// For a transaction that was canceled by Apple customer support, the time and date of the cancellation. For an
/// auto-renewable subscription plan that was upgraded, the time and date of the upgrade transaction.
///
/// Treat a canceled receipt the same as if no purchase had ever been made.
///
/// **Note**: A canceled in-app purchase remains in the receipt indefinitely. Only applicable if the refund was for a
/// non-consumable product, an auto-renewable subscription, a non-renewing subscription, or for a
/// free subscription.
public let cancellationDate : Date?
/// For a transaction that was canceled, the reason for cancellation.
///
/// - 1 - Customer canceled their transaction due to an actual or perceived issue within your app.
/// - 0 - Transaction was canceled for another reason, for example, if the customer made the purchase accidentally.
///
/// Use this value along with the cancellation date to identify possible issues in your app that may lead customers to
/// contact Apple customer support.
public let cancellationReason : CancellationReason?
/// A string that the App Store uses to uniquely identify the application that created the transaction.
///
/// If your server supports multiple applications, you can use this value to differentiate between them. Apps are assigned an
/// identifier only in the production environment, so this key is not present for receipts created in the test environment.
///
/// This field is not present for Mac apps.
public let appItemId : String?
/// An arbitrary number that uniquely identifies a revision of your application.
///
/// This key is not present for receipts created in the test environment. Use this value to identify the version of the app that
/// the customer bought.
public let externalVersionIdentifier : String?
/// The primary key for identifying subscription purchases.
///
/// This value is a unique ID that identifies purchase events across devices, including subscription renewal purchase events.
public let webOrderLineItemId : String?
enum CodingKeys: String, CodingKey {
case quantity = "quantity"
case productId = "product_id"
case transactionId = "transaction_id"
case originalTransactionId = "original_transaction_id"
case purchaseDate = "purchase_date_ms"
case originalPurchaseDate = "original_purchase_date_ms"
case subscriptionExpirationDate = "subscription_expiration_date_ms"
case subscriptionExpirationIntent = "expiration_intent"
case subscriptionRetryFlag = "is_in_billing_retry_period"
case subscriptionTrialPeriod = "is_trial_period"
case subscriptionIsInIntroductoryPricePeriod = "is_in_intro_offer_period"
case subscriptionAutoRenewStatus = "auto_renew_status"
case subscriptionAutoRenewPreface = "auto_renew_product_id"
case subscriptionPriceConsentStatus = "price_consent_status"
case cancellationDate = "cancellationDateMS"
case cancellationReason = "cancellation_reason"
case appItemId = "app_item_id"
case externalVersionIdentifier = "version_external_identifier"
case webOrderLineItemId = "web_order_line_item_id"
public enum SubscriptionExpirationIntent: String, Codable {
case customerCancelled = "1"
case billingError = "2"
case customerDidNotAgreeWithPriceIncrease = "3"
case productWasUnavailableAtTimeOfRenewal = "4"
case unknownError = "5"
}
public enum SubscriptionRetryFlag: String, Codable {
case appStoreStillAttemptingToRenewSubscription = "0"
case appStoreHasStoppedAttemptingToRenewTheSubscription = "1"
}
public enum SubscriptionTrialPeriod: String, Codable {
case isInFreeTrial = "true"
case isNotInFreeTrial = "false"
}
public enum SubscriptionIntroductoryPricePeriod: String, Codable {
case isInIntroductoryPricePeriod = "true"
case isNotInIntroductoryPricePeriod = "false"
}
public enum SubscriptionAutoRenewStatus: String, Codable {
case customerHasTurnedOffAutomaticRenewal = "0"
case willRenew = "1"
}
public enum SubscriptionPriceConsentStatus: String, Codable {
case customerHasNotTakenAction = "0"
case customerHasAgreedToPriceIncrease = "1"
}
public enum CancellationReason: String, Codable {
case actualOrPercivedIssueWithinTheApp = "1"
case otherReason = "0"
}
}
extension AppStore.Receipt {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.bundleId = try container.decode(String.self, forKey: .bundleId)
self.applicationVersion = try container.decode(String.self, forKey: .applicationVersion)
self.inApp = try container.decode([AppStore.InAppPurchase].self, forKey: .inApp)
self.originalApplicationVersion = try container.decode(String.self, forKey: .originalApplicationVersion)
self.receiptCreationDate = try container.decodeAppStoreDate(forKey: .receiptCreationDate)
self.receiptExpirationDate = try container.decodeAppStoreDateIfPresent(forKey: .receiptExpirationDate)
}
}
extension AppStore.InAppPurchase {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.quantity = try container.decode(String.self, forKey: .quantity)
self.productId = try container.decode(String.self, forKey: .productId)
self.transactionId = try container.decode(String.self, forKey: .transactionId)
self.originalTransactionId = try container.decode(String.self, forKey: .originalTransactionId)
self.purchaseDate = try container.decodeAppStoreDate(forKey: .purchaseDate)
self.originalPurchaseDate = try container.decodeAppStoreDate(forKey: .originalPurchaseDate)
self.subscriptionExpirationDate = try container.decodeAppStoreDateIfPresent(forKey: .subscriptionExpirationDate)
self.subscriptionExpirationIntent = try container.decodeIfPresent(AppStore.SubscriptionExpirationIntent.self, forKey: .subscriptionExpirationIntent)
self.subscriptionRetryFlag = try container.decodeIfPresent(AppStore.SubscriptionRetryFlag.self, forKey: .subscriptionRetryFlag)
self.subscriptionTrialPeriod = try container.decodeIfPresent(AppStore.SubscriptionTrialPeriod.self, forKey: .subscriptionTrialPeriod)
self.subscriptionIsInIntroductoryPricePeriod = try container.decodeIfPresent(AppStore.SubscriptionIntroductoryPricePeriod.self, forKey: .subscriptionIsInIntroductoryPricePeriod)
self.subscriptionAutoRenewStatus = try container.decodeIfPresent(AppStore.SubscriptionAutoRenewStatus.self, forKey: .subscriptionAutoRenewStatus)
self.subscriptionAutoRenewPreface = try container.decodeIfPresent(String.self, forKey: .subscriptionAutoRenewPreface)
self.subscriptionPriceConsentStatus = try container.decodeIfPresent(AppStore.SubscriptionPriceConsentStatus.self, forKey: .subscriptionPriceConsentStatus)
self.cancellationDate = try container.decodeAppStoreDateIfPresent(forKey: .cancellationDate)
self.cancellationReason = try container.decodeIfPresent(AppStore.CancellationReason.self, forKey: .cancellationReason)
self.appItemId = try container.decodeIfPresent(String.self, forKey: .appItemId)
self.externalVersionIdentifier = try container.decodeIfPresent(String.self, forKey: .externalVersionIdentifier)
self.webOrderLineItemId = try container.decodeIfPresent(String.self, forKey: .webOrderLineItemId)
}
}
extension KeyedDecodingContainer {
func decodeAppStoreDate(forKey key: K) throws -> Date {
let string = try self.decode(String.self, forKey: key)
guard let timeIntervalSince1970inMs = Double(string) else {
throw DecodingError.dataCorruptedError(
forKey: key,
in: self,
debugDescription: "Expected to have a TimeInterval in ms within the string to decode a date."
)
}
return Date(timeIntervalSince1970: timeIntervalSince1970inMs / 1000)
}
func decodeAppStoreDateIfPresent(forKey key: K) throws -> Date? {
guard let string = try self.decodeIfPresent(String.self, forKey: key) else {
return nil
}
guard let timeIntervalSince1970inMs = Double(string) else {
throw DecodingError.dataCorruptedError(
forKey: key,
in: self,
debugDescription: "Expected to have a TimeInterval in ms within the string to decode a date."
)
}
return Date(timeIntervalSince1970: timeIntervalSince1970inMs / 1000)
}
}
public enum SubscriptionExpirationIntent: String, Codable {
case customerCancelled = "1"
case billingError = "2"
case customerDidNotAgreeWithPriceIncrease = "3"
case productWasUnavailableAtTimeOfRenewal = "4"
case unknownError = "5"
}
public enum SubscriptionRetryFlag: String, Codable {
case appStoreStillAttemptingToRenewSubscription = "0"
case appStoreHasStoppedAttemptingToRenewTheSubscription = "1"
}
public enum SubscriptionTrialPeriod: String, Codable {
case isInFreeTrial = "true"
case isNotInFreeTrial = "false"
}
public enum SubscriptionIntroductoryPricePeriod: String, Codable {
case isInIntroductoryPricePeriod = "true"
case isNotInIntroductoryPricePeriod = "false"
}
public enum SubscriptionAutoRenewStatus: String, Codable {
case customerHasTurnedOffAutomaticRenewal = "0"
case willRenew = "1"
}
public enum SubscriptionPriceConsentStatus: String, Codable {
case customerHasNotTakenAction = "0"
case customerHasAgreedToPriceIncrease = "1"
}
public enum CancellationReason: String, Codable {
case actualOrPercivedIssueWithinTheApp = "1"
case otherReason = "0"
}
}

View File

@ -1,48 +1,54 @@
import Foundation
import NIO
extension AppStoreClient {
struct Request: Codable {
let receiptData: String
let password: String?
let excludeOldTransactions: Bool?
enum CodingKeys: String, CodingKey {
case receiptData = "receipt-data"
case password = "password"
case excludeOldTransactions = "exclude-old-transactions"
extension AppStore {
enum Environment: String, Codable {
case sandbox = "Sandbox"
case production = "Production"
var url: String {
switch self {
case .sandbox:
return "https://sandbox.itunes.apple.com/verifyReceipt"
case .production:
return "https://buy.itunes.apple.com/verifyReceipt"
}
}
}
}
}
extension AppStoreClient {
struct Status: Codable {
let status: Int
}
struct Response: Codable {
let receipt: Receipt // json
let latestReceipt: String?
let latestReceiptInfo: Receipt? // json
extension AppStore {
struct Request: Codable {
let receiptData: String
let password: String?
let excludeOldTransactions: Bool?
enum CodingKeys: String, CodingKey {
case receiptData = "receipt-data"
case password
case excludeOldTransactions = "exclude-old-transactions"
}
}
}
extension AppStore {
struct Status: Codable {
let status: Int
}
struct Response: Codable {
let receipt: Receipt // json
let latestReceipt: String?
let latestReceiptInfo: Receipt? // json
// let latestExpiredReceiptInfo: Any? // json
// let pendingRenewalInfo: Any?
let isRetryable: Bool?
let environment: Environment
enum CodingKeys: String, CodingKey {
case receipt = "receipt"
case latestReceipt = "latest_receipt"
case latestReceiptInfo = "latest_receipt_info"
case isRetryable = "is-retryable"
case environment = "environment"
}
let isRetryable: Bool?
let environment: Environment
}
enum CodingKeys: String, CodingKey {
case receipt
case latestReceipt = "latest_receipt"
case latestReceiptInfo = "latest_receipt_info"
case isRetryable = "is-retryable"
case environment
}
}
}

View File

@ -1,11 +1,7 @@
@testable import AppStoreReceiptValidation
import Foundation
import XCTest
@testable import AppStoreReceiptValidation
class AppStoreClientTests: XCTestCase {
func testNothing() {
}
func testNothing() {}
}

17
scripts/sanity.sh Executable file
View File

@ -0,0 +1,17 @@
#!/bin/bash
set -eu
here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
printf "=> Checking format... "
FIRST_OUT="$(git status --porcelain)"
swiftformat . > /dev/null 2>&1
SECOND_OUT="$(git status --porcelain)"
if [[ "$FIRST_OUT" != "$SECOND_OUT" ]]; then
printf "\033[0;31mformatting issues!\033[0m\n"
git --no-pager diff
exit 1
else
printf "\033[0;32mokay.\033[0m\n"
fi