iap: handle login and logout
This commit adapts the functionality around login/logout with relation
to Damus Purple In-App purchases (IAP). Due to (apparent) limitations on
Renewable subscription In-app purchases (It seems that there can only be
one active IAP subscription per device or Apple ID), these changes add
support for only one IAP subscription at a time.
To prevent confusion, a customer who logs out and logs into a separate
account will see a message indicating the limitation. Any other Nostr
account won't be able to manage IAP on a device that contains an IAP
registered to a different user.
To make this feature possible, the following changes were made to the
code:
1. IAP purchases are now associated with an account UUID. This account
UUID is generated by the server. Each npub gets one and only one UUID
for this purpose.
2. This UUID is used to determine which npub owns the IAP on the device.
It is used as the source of truth when determining whether a
particular Purple account is manageable on a device or not
3. `DamusPurple` was changed to adhere to a new IAP flow API design
changes. Previously, the client would create an (inactive) account,
and then send the IAP receipt to the server for activation. Now, the
client fetches the npub's UUID from the server, associates it with an
IAP during purchase, and sends the IAP receipt to the server. The
server will then bump the expiry (if it's a renewal) or create a new
active account (if it's the first time).
4. Several changes were made to the StoreKit handling code to improve
handling:
a. The `DamusPurple.StoreKitManager` class now records all purchased
product updates, and sends them to the delegate each time the
delegate is updated. This helps ensure we do not miss purchased
product updates regardless of when and if `DamusPurpleView` is ever
instantiated.
b. `DamusPurple.StoreKitManager` is now used by `DamusPurple` in a
singleton pattern via `DamusPurple.StoreKitManager.standard`. This
helps maintain the local purchase history consistent (and avoid
losing such data) after `DamusState` or its `DamusPurple` are
destroyed and re-initialized.
c. Added logs (using the logger) to help us debug/troubleshoot
problems in the future
5. Changed the views around DamusPurple, to only show IAP
purchase/management options if applicable to a particular account. It
also shows instructive messages in other scenarios.
Testing
-------
damus: This commit
damus-api: d3956ee004a358a39c8570fdbd481d2f5f6f94ab
Device: iPhone 15 simulator
iOS: 17.2
Setup:
- Xcode (local) StoreKit environment
- All StoreKit transactions deleted before starting
- Running `damus` app target (which contains test StoreKit products)
- Local damus-api server running with `npm run dev` and
`MOCK_VERIFY=true` to disable real receipt verification
- Damus setup with experimental IAP support enabled, and Purple
environment set to "Test (local)" (localhost)
- Two `nsecs` readily available for account switching
- Clean DB (Delete db files before starting)
Steps:
1. Open the app and sign in to the first account
2. Go to Damus Purple screen. Marketing screen with buttons to purchase
products should be visible. PASS
3. Buy a product and monitor server logs as well as the screen.
a. IAP confirmation dialog should appear. PASS
b. After confirmation, server logs should show a receipt was sent
IMMEDIATELY and the response should be an HTTP 200. PASS
c. The welcome and onboarding screens should appear as normal. PASS
d. Once the onboarding sheet goes away, the Purple screen should now
show the account information. PASS
e. The account information should be correct. PASS
f. Under the account information, there should be a "manage" button. PASS
4. Click on "manage" and verify that the iOS subscription management
screen appears. PASS
5. Now log out and sign in to the second account
6. Go to Damus Purple screen.
a. Marketing screen should be visible. PASS
b. There should be no purchase buttons. instead, there should be a
message indicating that there can only be one active subscription
at a time, and that the app is unable to manage subscription for
this second acocunt. PASS
7. Log out and sign in to the first account. Go to the Purple screen.
a. Account info with the manage button should be visible like before. PASS
8. Through Xcode, delete transactions, and restart the app. This will
simulate the case where the user bought the subscription externally.
9. Go to the Purple screen.
a. Account info should be visible and correct. PASS
b. Below the account info, there should be a small note telling the
user to visit the website to manage their billing. PASS
Closes: https://github.com/damus-io/damus/issues/1815
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Reviewed-by: William Casarin <jb55@jb55.com>
Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
committed by
William Casarin
parent
d694c26b83
commit
4a4a58c7b5
@@ -15,12 +15,15 @@ class DamusPurple: StoreObserverDelegate {
|
||||
|
||||
@MainActor
|
||||
var account_cache: [Pubkey: Account]
|
||||
@MainActor
|
||||
var account_uuid_cache: [Pubkey: UUID]
|
||||
|
||||
init(settings: UserSettingsStore, keypair: Keypair) {
|
||||
self.settings = settings
|
||||
self.keypair = keypair
|
||||
self.account_cache = [:]
|
||||
self.storekit_manager = .init()
|
||||
self.account_uuid_cache = [:]
|
||||
self.storekit_manager = StoreKitManager.standard // Use singleton to avoid losing local purchase data
|
||||
}
|
||||
|
||||
// MARK: Functions
|
||||
@@ -96,13 +99,35 @@ class DamusPurple: StoreObserverDelegate {
|
||||
throw PurpleError.error_processing_response
|
||||
}
|
||||
|
||||
func create_account(pubkey: Pubkey) async throws {
|
||||
let url = environment.api_base_url().appendingPathComponent("accounts")
|
||||
|
||||
Log.info("Creating account with Damus Purple server", for: .damus_purple)
|
||||
|
||||
func make_iap_purchase(product: Product) async throws {
|
||||
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
|
||||
let result = try await self.storekit_manager.purchase(product: product, id: account_uuid)
|
||||
switch result {
|
||||
case .success(.verified(let tx)):
|
||||
// Record the purchase with the storekit manager, to make sure we have the update on the UIs as soon as possible.
|
||||
// During testing I found that the purchase initiated via `purchase` was not emitted via the listener `StoreKit.Transaction.updates` until the app was restarted.
|
||||
self.storekit_manager.record_purchased_product(StoreKitManager.PurchasedProduct(tx: tx, product: product))
|
||||
// Send the receipt to the server
|
||||
await self.send_receipt()
|
||||
default:
|
||||
// Any time we get a non-verified result, it means that the purchase was not successful, and thus we should throw an error.
|
||||
throw PurpleError.iap_purchase_error(result: result)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func get_maybe_cached_uuid_for_account() async throws -> UUID {
|
||||
if let account_uuid = self.account_uuid_cache[self.keypair.pubkey] {
|
||||
return account_uuid
|
||||
}
|
||||
return try await fetch_uuid_for_account()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func fetch_uuid_for_account() async throws -> UUID {
|
||||
let url = self.environment.api_base_url().appending(path: "/accounts/\(self.keypair.pubkey)/account-uuid")
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
method: .get,
|
||||
url: url,
|
||||
payload: nil,
|
||||
payload_type: nil,
|
||||
@@ -112,22 +137,16 @@ class DamusPurple: StoreObserverDelegate {
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
switch httpResponse.statusCode {
|
||||
case 200:
|
||||
Log.info("Created an account with Damus Purple server", for: .damus_purple)
|
||||
Log.info("Got user UUID from Damus Purple server", for: .damus_purple)
|
||||
default:
|
||||
Log.error("Error in creating account with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
Log.error("Error in getting user UUID with Damus Purple. HTTP status code: %d; Response: %s", for: .damus_purple, httpResponse.statusCode, String(data: data, encoding: .utf8) ?? "Unknown")
|
||||
throw PurpleError.http_response_error(status_code: httpResponse.statusCode, response: data)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func create_account_if_not_existing(pubkey: Pubkey) async throws {
|
||||
guard await !(self.account_exists(pubkey: pubkey) ?? false) else { return }
|
||||
try await self.create_account(pubkey: pubkey)
|
||||
}
|
||||
|
||||
func make_iap_purchase(product: Product) async throws -> Product.PurchaseResult {
|
||||
return try await self.storekit_manager.purchase(product: product)
|
||||
let account_uuid_info = try JSONDecoder().decode(AccountUUIDInfo.self, from: data)
|
||||
self.account_uuid_cache[self.keypair.pubkey] = account_uuid_info.account_uuid
|
||||
return account_uuid_info.account_uuid
|
||||
}
|
||||
|
||||
func send_receipt() async {
|
||||
@@ -135,19 +154,22 @@ class DamusPurple: StoreObserverDelegate {
|
||||
if let appStoreReceiptURL = Bundle.main.appStoreReceiptURL,
|
||||
FileManager.default.fileExists(atPath: appStoreReceiptURL.path) {
|
||||
|
||||
try? await create_account_if_not_existing(pubkey: keypair.pubkey)
|
||||
|
||||
do {
|
||||
let receiptData = try Data(contentsOf: appStoreReceiptURL, options: .alwaysMapped)
|
||||
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/app-store-receipt")
|
||||
let receipt_base64_string = receiptData.base64EncodedString()
|
||||
let account_uuid = try await self.get_maybe_cached_uuid_for_account()
|
||||
let json_text: [String: String] = ["receipt": receipt_base64_string, "account_uuid": account_uuid.uuidString]
|
||||
let json_data = try JSONSerialization.data(withJSONObject: json_text)
|
||||
|
||||
let url = environment.api_base_url().appendingPathComponent("accounts/\(keypair.pubkey.hex())/apple-iap/app-store-receipt")
|
||||
|
||||
Log.info("Sending in-app purchase receipt to Damus Purple server", for: .damus_purple)
|
||||
|
||||
let (data, response) = try await make_nip98_authenticated_request(
|
||||
method: .post,
|
||||
url: url,
|
||||
payload: receiptData,
|
||||
payload_type: .binary,
|
||||
payload: json_data,
|
||||
payload_type: .json,
|
||||
auth_keypair: self.keypair
|
||||
)
|
||||
|
||||
@@ -270,6 +292,10 @@ extension DamusPurple {
|
||||
let expiry: UInt64?
|
||||
let active: Bool
|
||||
}
|
||||
|
||||
fileprivate struct AccountUUIDInfo: Codable {
|
||||
let account_uuid: UUID
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Helper structures
|
||||
@@ -279,6 +305,7 @@ extension DamusPurple {
|
||||
case translation_error(status_code: Int, response: Data)
|
||||
case http_response_error(status_code: Int, response: Data)
|
||||
case error_processing_response
|
||||
case iap_purchase_error(result: Product.PurchaseResult)
|
||||
case translation_no_response
|
||||
case checkout_npub_verification_error
|
||||
}
|
||||
|
||||
@@ -9,15 +9,40 @@ import Foundation
|
||||
import StoreKit
|
||||
|
||||
extension DamusPurple {
|
||||
struct StoreKitManager {
|
||||
var delegate: DamusPurpleStoreKitManagerDelegate? = nil
|
||||
class StoreKitManager { // Has to be a class to get around Swift-imposed limitations of mutations on concurrently executing code associated with the purchase update monitoring task.
|
||||
// The delegate is any object that wants to be notified of successful purchases. (e.g. A view that needs to update its UI)
|
||||
var delegate: DamusPurpleStoreKitManagerDelegate? = nil {
|
||||
didSet {
|
||||
// Whenever the delegate is set, send it all recorded transactions to make sure it's up to date.
|
||||
Task {
|
||||
Log.info("Delegate changed. Try sending all recorded valid product transactions", for: .damus_purple)
|
||||
guard let new_delegate = delegate else {
|
||||
Log.info("Delegate is nil. Cannot send recorded product transactions", for: .damus_purple)
|
||||
return
|
||||
}
|
||||
Log.info("Sending all %d recorded valid product transactions", for: .damus_purple, self.recorded_purchased_products.count)
|
||||
|
||||
for purchased_product in self.recorded_purchased_products {
|
||||
new_delegate.product_was_purchased(product: purchased_product)
|
||||
Log.info("Sent StoreKit tx to delegate", for: .damus_purple)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keep track of all recorded purchases so that we can send them to the delegate when it's set (whenever it's set)
|
||||
var recorded_purchased_products: [PurchasedProduct] = []
|
||||
|
||||
// Helper struct to keep track of a purchased product and its transaction
|
||||
struct PurchasedProduct {
|
||||
let tx: StoreKit.Transaction
|
||||
let product: Product
|
||||
}
|
||||
|
||||
// Singleton instance of StoreKitManager. To avoid losing purchase updates, there should only be one instance of StoreKitManager on the app.
|
||||
static let standard = StoreKitManager()
|
||||
|
||||
init() {
|
||||
Log.info("Initiliazing StoreKitManager", for: .damus_purple)
|
||||
self.start()
|
||||
}
|
||||
|
||||
@@ -31,7 +56,17 @@ extension DamusPurple {
|
||||
return try await Product.products(for: DamusPurpleType.allCases.map({ $0.rawValue }))
|
||||
}
|
||||
|
||||
// Use this function to manually and immediately record a purchased product update
|
||||
func record_purchased_product(_ purchased_product: PurchasedProduct) {
|
||||
self.recorded_purchased_products.append(purchased_product)
|
||||
self.delegate?.product_was_purchased(product: purchased_product)
|
||||
}
|
||||
|
||||
// This function starts a task that monitors StoreKit updates and sends them to the delegate.
|
||||
// This function will run indefinitely (It should never return), so it is important to run this as a background task.
|
||||
private func monitor_updates() async throws {
|
||||
Log.info("Monitoring StoreKit updates", for: .damus_purple)
|
||||
// StoreKit.Transaction.updates is an async stream that emits updates whenever a purchase is verified.
|
||||
for await update in StoreKit.Transaction.updates {
|
||||
switch update {
|
||||
case .verified(let tx):
|
||||
@@ -42,7 +77,11 @@ extension DamusPurple {
|
||||
let expiration = tx.expirationDate,
|
||||
Date.now < expiration
|
||||
{
|
||||
self.delegate?.product_was_purchased(product: PurchasedProduct(tx: tx, product: prod))
|
||||
Log.info("Received valid transaction update from StoreKit", for: .damus_purple)
|
||||
let purchased_product = PurchasedProduct(tx: tx, product: prod)
|
||||
self.recorded_purchased_products.append(purchased_product)
|
||||
self.delegate?.product_was_purchased(product: purchased_product)
|
||||
Log.info("Sent tx to delegate (if exists)", for: .damus_purple)
|
||||
}
|
||||
case .unverified:
|
||||
continue
|
||||
@@ -50,13 +89,17 @@ extension DamusPurple {
|
||||
}
|
||||
}
|
||||
|
||||
func purchase(product: Product) async throws -> Product.PurchaseResult {
|
||||
return try await product.purchase(options: [])
|
||||
// Use this function to complete a StoreKit purchase
|
||||
// Specify the product and the app account token (UUID) to complete the purchase
|
||||
// The account token is used to associate with the user's account on the server.
|
||||
func purchase(product: Product, id: UUID) async throws -> Product.PurchaseResult {
|
||||
return try await product.purchase(options: [.appAccountToken(id)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension DamusPurple.StoreKitManager {
|
||||
// This helper struct is used to encapsulate StoreKit products, metadata, and supplement with additional information
|
||||
enum DamusPurpleType: String, CaseIterable {
|
||||
case yearly = "purpleyearly"
|
||||
case monthly = "purple"
|
||||
@@ -81,6 +124,7 @@ extension DamusPurple.StoreKitManager {
|
||||
}
|
||||
}
|
||||
|
||||
// This protocol is used to describe the delegate of the StoreKitManager, which will receive updates.
|
||||
protocol DamusPurpleStoreKitManagerDelegate {
|
||||
func product_was_purchased(product: DamusPurple.StoreKitManager.PurchasedProduct)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user