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
@@ -65,11 +65,6 @@ struct DamusPurpleAccountView: View {
|
||||
.preferredColorScheme(.dark)
|
||||
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
.padding()
|
||||
|
||||
Text(NSLocalizedString("Visit the Damus website on a web browser to manage billing", comment: "Instruction on how to manage billing externally"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate {
|
||||
@State var purchased: PurchasedProduct? = nil
|
||||
@State var selection: DamusPurple.StoreKitManager.DamusPurpleType = .yearly
|
||||
@State var show_welcome_sheet: Bool = false
|
||||
@State var show_manage_subscriptions = false
|
||||
@State var account_uuid: UUID? = nil
|
||||
@State var iap_error: String? = nil // TODO: Display this error to the user in some way.
|
||||
@State private var shouldDismissView = false
|
||||
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@@ -37,7 +38,6 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate {
|
||||
self._products = State(wrappedValue: .loading)
|
||||
self.damus_state = damus_state
|
||||
self.keypair = damus_state.keypair
|
||||
damus_state.purple.storekit_manager.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Top level view
|
||||
@@ -69,6 +69,10 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate {
|
||||
notify(.display_tabbar(false))
|
||||
Task {
|
||||
await self.load_account()
|
||||
// Assign this view as the delegate for the storekit manager to receive purchase updates
|
||||
damus_state.purple.storekit_manager.delegate = self
|
||||
// Fetch the account UUID to use for IAP purchases and to check if an IAP purchase is associated with the account
|
||||
self.account_uuid = try await damus_state.purple.get_maybe_cached_uuid_for_account()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
@@ -86,7 +90,6 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate {
|
||||
}, content: {
|
||||
DamusPurpleNewUserOnboardingView(damus_state: damus_state)
|
||||
})
|
||||
.manageSubscriptionsSheet(isPresented: $show_manage_subscriptions)
|
||||
}
|
||||
|
||||
// MARK: - Complex subviews
|
||||
@@ -100,7 +103,11 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
case .loaded(let account):
|
||||
DamusPurpleAccountView(damus_state: damus_state, account: account)
|
||||
Group {
|
||||
DamusPurpleAccountView(damus_state: damus_state, account: account)
|
||||
|
||||
ProductStateView(account: account)
|
||||
}
|
||||
case .no_account:
|
||||
MarketingContent
|
||||
case .error(let message):
|
||||
@@ -119,20 +126,45 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate {
|
||||
DamusPurpleView.MarketingContentView(purple: damus_state.purple)
|
||||
|
||||
VStack(alignment: .center) {
|
||||
ProductStateView
|
||||
ProductStateView(account: nil)
|
||||
}
|
||||
.padding([.top], 20)
|
||||
}
|
||||
}
|
||||
|
||||
var ProductStateView: some View {
|
||||
func ProductStateView(account: DamusPurple.Account?) -> some View {
|
||||
Group {
|
||||
if damus_state.purple.enable_purple_iap_support {
|
||||
DamusPurpleView.IAPProductStateView(products: products, purchased: purchased, subscribe: subscribe)
|
||||
if account?.active == true && purchased == nil {
|
||||
// Account active + no IAP purchases = Bought through Lightning.
|
||||
// Instruct the user to manage billing on the website
|
||||
ManageOnWebsiteNote
|
||||
}
|
||||
else {
|
||||
// If account is no longer active or was purchased via IAP, then show IAP purchase/manage options
|
||||
if let account_uuid {
|
||||
DamusPurpleView.IAPProductStateView(products: products, purchased: purchased, account_uuid: account_uuid, subscribe: subscribe)
|
||||
}
|
||||
else {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
else {
|
||||
ManageOnWebsiteNote
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var ManageOnWebsiteNote: some View {
|
||||
Text(NSLocalizedString("Visit the Damus website on a web browser to manage billing", comment: "Instruction on how to manage billing externally"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
}
|
||||
|
||||
// MARK: - State management
|
||||
|
||||
func load_account() async {
|
||||
@@ -167,31 +199,12 @@ struct DamusPurpleView: View, DamusPurpleStoreKitManagerDelegate {
|
||||
}
|
||||
|
||||
func subscribe(_ product: Product) async throws {
|
||||
let result = try await self.damus_state.purple.make_iap_purchase(product: product)
|
||||
switch result {
|
||||
case .success(.verified(let tx)):
|
||||
print("success \(tx.debugDescription)")
|
||||
show_welcome_sheet = true
|
||||
case .success(.unverified(let tx, let res)):
|
||||
print("success unverified \(tx.debugDescription) \(res.localizedDescription)")
|
||||
show_welcome_sheet = true
|
||||
case .pending:
|
||||
break
|
||||
case .userCancelled:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
do {
|
||||
try await self.damus_state.purple.make_iap_purchase(product: product)
|
||||
show_welcome_sheet = true
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
// TODO (will): why do this here?
|
||||
//self.damus_state.purple.starred_profiles_cache[keypair.pubkey] = nil
|
||||
Task {
|
||||
await self.damus_state.purple.send_receipt()
|
||||
}
|
||||
default:
|
||||
break
|
||||
catch(let error) {
|
||||
self.iap_error = error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,12 +12,16 @@ import StoreKit
|
||||
|
||||
extension DamusPurpleView {
|
||||
typealias PurchasedProduct = DamusPurple.StoreKitManager.PurchasedProduct
|
||||
static let SHOW_IAP_DEBUG_INFO = false
|
||||
|
||||
struct IAPProductStateView: View {
|
||||
let products: ProductState
|
||||
let purchased: PurchasedProduct?
|
||||
var products: ProductState
|
||||
var purchased: PurchasedProduct?
|
||||
let account_uuid: UUID
|
||||
let subscribe: (Product) async throws -> Void
|
||||
|
||||
@State var show_manage_subscriptions = false
|
||||
|
||||
var body: some View {
|
||||
switch self.products {
|
||||
case .failed:
|
||||
@@ -35,31 +39,65 @@ extension DamusPurpleView {
|
||||
}
|
||||
|
||||
func PurchasedView(_ purchased: PurchasedProduct) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(NSLocalizedString("Purchased!", comment: "User purchased a subscription"))
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
price_description(product: purchased.product)
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.65)
|
||||
.frame(width: 200)
|
||||
Text(NSLocalizedString("Purchased on", comment: "Indicating when the user purchased the subscription"))
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
Text(format_date(date: purchased.tx.purchaseDate))
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.65)
|
||||
if let expiry = purchased.tx.expirationDate {
|
||||
Text(NSLocalizedString("Renews on", comment: "Indicating when the subscription will renew"))
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
Text(format_date(date: expiry))
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.65)
|
||||
return Group {
|
||||
if self.account_uuid == purchased.tx.appAccountToken {
|
||||
// If the In-app purchase is registered to this account, show options to manage the subscription
|
||||
PurchasedManageView(purchased)
|
||||
}
|
||||
else {
|
||||
// If the In-app purchase is registered to a different account, we cannot manage the subscription
|
||||
// This seems to be a limitation of StoreKit where we can only have one renewable subscription for a product at a time.
|
||||
// Therefore, instruct the user about this limitation, or to contact us if they believe this is a mistake.
|
||||
PurchasedUnmanageableView(purchased)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func PurchasedUnmanageableView(_ purchased: PurchasedProduct) -> some View {
|
||||
Text(NSLocalizedString("This device's in-app purchase is registered to a different Nostr account. Unable to manage this Purple account. If you believe this was a mistake, please contact us via support@damus.io.", comment: "Notice label that user cannot manage their In-App purchases"))
|
||||
.font(.caption)
|
||||
.foregroundColor(.white.opacity(0.6))
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
func PurchasedManageView(_ purchased: PurchasedProduct) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
if SHOW_IAP_DEBUG_INFO == true {
|
||||
Text(NSLocalizedString("Purchased!", comment: "User purchased a subscription"))
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
price_description(product: purchased.product)
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.65)
|
||||
.frame(width: 200)
|
||||
Text(NSLocalizedString("Purchased on", comment: "Indicating when the user purchased the subscription"))
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
Text(format_date(date: purchased.tx.purchaseDate))
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.65)
|
||||
if let expiry = purchased.tx.expirationDate {
|
||||
Text(NSLocalizedString("Renews on", comment: "Indicating when the subscription will renew"))
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
Text(format_date(date: expiry))
|
||||
.foregroundColor(.white)
|
||||
.opacity(0.65)
|
||||
}
|
||||
}
|
||||
Button(action: {
|
||||
show_manage_subscriptions = true
|
||||
}, label: {
|
||||
Text(NSLocalizedString("Manage", comment: "Manage the damus subscription"))
|
||||
.padding(.horizontal, 20)
|
||||
})
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
}
|
||||
.manageSubscriptionsSheet(isPresented: $show_manage_subscriptions)
|
||||
.padding()
|
||||
}
|
||||
|
||||
func ProductsView(_ products: [Product]) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Text(NSLocalizedString("Save 20% off on an annual subscription", comment: "Savings for purchasing an annual subscription"))
|
||||
@@ -80,7 +118,7 @@ extension DamusPurpleView {
|
||||
.buttonStyle(GradientButtonStyle())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
.padding()
|
||||
}
|
||||
|
||||
func price_description(product: Product) -> some View {
|
||||
@@ -124,5 +162,5 @@ extension DamusPurpleView {
|
||||
}
|
||||
|
||||
#Preview {
|
||||
DamusPurpleView.IAPProductStateView(products: .loaded([]), purchased: nil, subscribe: {_ in })
|
||||
DamusPurpleView.IAPProductStateView(products: .loaded([]), purchased: nil, account_uuid: UUID(), subscribe: {_ in })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user