diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index 4d38e284f..b88524ed8 100644 --- a/Bitkit/AppScene.swift +++ b/Bitkit/AppScene.swift @@ -1139,6 +1139,8 @@ struct AppScene: View { guard let identity = pubkyProfile.publicKey else { return } await paykitPaymentRequestManager.refresh() await paykitAllowanceManager.refresh() + let acceptedOffers = await paykitAllowanceManager.acceptOffersFromAllowers() + announceAcceptedAllowanceOffers(acceptedOffers) let handledAutomatically = await paykitAllowanceManager.processIncomingRequests(paykitPaymentRequestManager.pendingRequests) let adoptedAcceptances = paykitPaymentRequestManager.reloadAcceptedRequestIds() if handledAutomatically || adoptedAcceptances { @@ -1527,6 +1529,16 @@ struct AppScene: View { } } + private func announceAcceptedAllowanceOffers(_ offers: [PaykitAllowanceEntry]) { + for offer in offers { + let title = t("subscriptions__allowance_offer_accepted_title") + let description = t("subscriptions__allowance_offer_accepted_description", variables: ["name": contactName(offer.counterparty)]) + PaykitAllowanceNotifier.post(title: title, body: description, fallback: { + app.toast(type: .success, title: title, description: description, accessibilityIdentifier: "AllowanceOfferAcceptedToast") + }) + } + } + private func contactName(_ publicKey: String) -> String { contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, publicKey) }?.displayName ?? PubkyPublicKeyFormat.displayTruncated(publicKey) diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings index e14e674c6..b249256cc 100644 --- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings +++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings @@ -1801,4 +1801,6 @@ "subscriptions__allowance_executed_description" = "Auto-pay sent {amount} to {name}"; "subscriptions__allowance_limit_title" = "Limit Reached"; "subscriptions__allowance_limit_description" = "A {amount} request from {name} is above your allowance. Review it to pay."; +"subscriptions__allowance_offer_accepted_title" = "Allowance Added"; +"subscriptions__allowance_offer_accepted_description" = "{name} set up an allowance for you"; "subscriptions__allowance_auto_paid" = "Auto-paid"; diff --git a/Bitkit/Services/PaykitAllowance.swift b/Bitkit/Services/PaykitAllowance.swift index a2e00bb1d..04d499752 100644 --- a/Bitkit/Services/PaykitAllowance.swift +++ b/Bitkit/Services/PaykitAllowance.swift @@ -121,6 +121,11 @@ struct PaykitAllowance: Identifiable, Hashable { lifecycleState == .proposed && !isProposedByMe } + /// A received proposal whose proposer took the allower role, which leaves this wallet the allowee. The money and the + /// decision are the allower's, so Bitkit accepts it without asking. A received proposal that makes this wallet the + /// allower (a payee asking for an allowance) is not an offer and waits for the user's decision. + var isOfferFromAllower: Bool { isAnswerable && role == .allowee } + static func isMonthly(_ period: Paykit.AllowancePeriod) -> Bool { period.kind() == "anchored" && period.every() == 1 && period.unit() == "month" } diff --git a/Bitkit/Services/PaykitAllowanceManager.swift b/Bitkit/Services/PaykitAllowanceManager.swift index 1ce9c1c7f..7927c174a 100644 --- a/Bitkit/Services/PaykitAllowanceManager.swift +++ b/Bitkit/Services/PaykitAllowanceManager.swift @@ -51,6 +51,7 @@ final class PaykitAllowanceManager { @ObservationIgnored private let canPayNow: @MainActor () -> Bool @ObservationIgnored private var identity: String? @ObservationIgnored private var isProcessingRequests = false + @ObservationIgnored private var isAcceptingOffers = false @ObservationIgnored private var manualRequestIds: [PaykitPaymentRequest.ID: Int] = [:] init( @@ -220,6 +221,30 @@ final class PaykitAllowanceManager { await refresh() } + /// Accepts the offers that make this wallet the allowee, and returns the ones now active so the caller can tell the + /// user. A failed accept stays unanswered and is tried again on the next refresh. + func acceptOffersFromAllowers() async -> [PaykitAllowanceEntry] { + guard identity != nil, !isAcceptingOffers else { return [] } + let offers = entries.filter { entry in + let answerable = entry.allowances.filter(\.isAnswerable) + return !answerable.isEmpty && answerable.allSatisfy(\.isOfferFromAllower) + } + guard !offers.isEmpty else { return [] } + + isAcceptingOffers = true + defer { isAcceptingOffers = false } + var accepted: [PaykitAllowanceEntry] = [] + for offer in offers { + do { + try await accept(offer) + accepted.append(offer) + } catch { + Logger.warn("Failed to accept an allowance offer: \(error)", context: "PaykitAllowance") + } + } + return accepted + } + private func respond(to entry: PaykitAllowanceEntry, _ response: (PaykitAllowance) async throws -> Paykit.AllowanceRecord) async throws { isWorking = true defer { isWorking = false } @@ -241,7 +266,7 @@ final class PaykitAllowanceManager { func proposalForPresentation() -> PaykitAllowanceEntry? { entries.first { entry in - entry.allowances.contains(where: \.isAnswerable) && + entry.allowances.contains(where: { $0.isAnswerable && !$0.isOfferFromAllower }) && !entry.allowances.contains { localState.presentedProposalIds.contains($0.allowanceId) } } } diff --git a/BitkitTests/PaykitAllowanceExecutorTests.swift b/BitkitTests/PaykitAllowanceExecutorTests.swift index 21b53d552..7df38630a 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -659,6 +659,73 @@ final class PaykitAllowanceExecutorTests: XCTestCase { XCTAssertTrue(try manager.coversRequest(Fixtures.paymentRequest(createdAt: "2026-09-24T11:45:00Z"))) } + // MARK: Incoming offers + + @MainActor + func testManagerAcceptsAnOfferFromTheAllowerWithoutAReviewSheet() async throws { + let harness = AllowanceHarness() + // The proposer took the allower role, so this wallet is the allowee. + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allowee, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + XCTAssertNil(manager.proposalForPresentation(), "An offer from the allower never opens the review sheet") + + let accepted = await manager.acceptOffersFromAllowers() + + XCTAssertEqual(accepted.map(\.counterparty), [Fixtures.counterpartyKey]) + let acceptedIds = await harness.sdk.acceptedAllowanceIds + XCTAssertEqual(acceptedIds, [Fixtures.walletAllowanceId]) + XCTAssertEqual(manager.entries.first?.status(at: Fixtures.now), .active) + XCTAssertEqual(manager.entries.first?.role, .allowee) + XCTAssertEqual(manager.entries.first?.canEnd, true, "The allowee can still end it") + XCTAssertNil(manager.proposalForPresentation()) + + let acceptedAgain = await manager.acceptOffersFromAllowers() + XCTAssertTrue(acceptedAgain.isEmpty, "An accepted offer is not accepted or announced twice") + let acceptedIdsAfter = await harness.sdk.acceptedAllowanceIds + XCTAssertEqual(acceptedIdsAfter, [Fixtures.walletAllowanceId]) + } + + @MainActor + func testManagerLeavesAnAskFromTheAlloweeForTheReviewSheet() async throws { + let harness = AllowanceHarness() + // The proposer took the allowee role, so this wallet is the allower and pays: the user decides. + try await harness.sdk.setRecords([ + Fixtures.record(localRole: .allower, state: .proposed, terms: Fixtures.standardTerms(), proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + let accepted = await manager.acceptOffersFromAllowers() + + XCTAssertTrue(accepted.isEmpty) + let acceptedIds = await harness.sdk.acceptedAllowanceIds + XCTAssertTrue(acceptedIds.isEmpty) + XCTAssertFalse(harness.log.entries.contains("acceptAllowance")) + XCTAssertEqual(manager.proposalForPresentation()?.counterparty, Fixtures.counterpartyKey) + XCTAssertEqual(manager.entries.first?.status(at: Fixtures.now), .awaitingMyAnswer) + } + + @MainActor + func testManagerNeverAcceptsAProposalItSentOrOneAlreadyAnswered() async throws { + let harness = AllowanceHarness() + let terms = try Fixtures.standardTerms() + try await harness.sdk.setRecords([ + Fixtures.record(allowanceId: "sent", localRole: .allower, state: .proposed, terms: terms, proposedByMe: true), + Fixtures.record(allowanceId: "declined", localRole: .allowee, state: .rejected, terms: terms, proposedByMe: false), + Fixtures.record(allowanceId: "ended", localRole: .allowee, state: .ended, terms: terms, proposedByMe: false), + ]) + let manager = PaykitAllowanceManager(sdk: harness.sdk, executor: harness.executor, now: { PaykitAllowanceFixtures.now }) + await manager.activate(identity: Fixtures.identityKey) + + let accepted = await manager.acceptOffersFromAllowers() + + XCTAssertTrue(accepted.isEmpty) + XCTAssertFalse(harness.log.entries.contains("acceptAllowance")) + } + // MARK: Helpers private static func waitUntil(timeout: TimeInterval = 5, _ condition: () -> Bool) async { @@ -1061,6 +1128,7 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { private var manualReservation: Paykit.PaymentAttemptDecision? private var beginDecision: Paykit.PaymentAttemptDecision? private var acceptError: Error? + private(set) var acceptedAllowanceIds: [String] = [] private(set) var evaluatedTrustedTimes: [String] = [] private(set) var selections: [Paykit.AllowanceSelectionInput] = [] private(set) var acceptedEndpointIdentifiers: [String] = [] @@ -1135,7 +1203,11 @@ private actor AllowanceSdkMock: PaykitAllowanceSdkHandling { func acceptAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { log.append("acceptAllowance") - throw AllowanceMockError.unsupported + guard let index = records.firstIndex(where: { $0.allowanceId == allowanceId }) + else { throw AllowanceMockError.unsupported } + records[index].state = .accepted + acceptedAllowanceIds.append(allowanceId) + return records[index] } func rejectAllowance(counterparty: String, allowanceId: String) async throws -> Paykit.AllowanceRecord { diff --git a/BitkitTests/PaykitAllowanceTests.swift b/BitkitTests/PaykitAllowanceTests.swift index 5467dbe23..2d7eee6a1 100644 --- a/BitkitTests/PaykitAllowanceTests.swift +++ b/BitkitTests/PaykitAllowanceTests.swift @@ -296,6 +296,21 @@ final class PaykitAllowanceTests: XCTestCase { // MARK: Grouping + func testOnlyAReceivedProposalFromTheAllowerIsAnOffer() { + typealias Fixtures = PaykitAllowanceFixtures + let fromAllower = Fixtures.allowance(role: .allowee, state: .proposed, isProposedByMe: false) + XCTAssertTrue(fromAllower.isOfferFromAllower) + XCTAssertTrue(fromAllower.isAnswerable) + + let fromAllowee = Fixtures.allowance(role: .allower, state: .proposed, isProposedByMe: false) + XCTAssertFalse(fromAllowee.isOfferFromAllower) + XCTAssertTrue(fromAllowee.isAnswerable) + + XCTAssertFalse(Fixtures.allowance(role: .allower, state: .proposed, isProposedByMe: true).isOfferFromAllower) + XCTAssertFalse(Fixtures.allowance(role: .allowee, state: .proposed, isProposedByMe: true).isOfferFromAllower) + XCTAssertFalse(Fixtures.allowance(role: .allowee, state: .accepted, isProposedByMe: false).isOfferFromAllower) + } + func testEntryPrimaryIsTheGrantsAllowance() { let allowance = Fixtures.allowance(allowanceId: Fixtures.walletAllowanceId) @@ -420,13 +435,14 @@ enum PaykitAllowanceFixtures { perPaymentMaxSats: UInt64? = 5000, monthlyLimitSats: UInt64? = 50000, monthlyAnchor: Date? = septemberAnchor, - expiresAt: Date? = nil + expiresAt: Date? = nil, + isProposedByMe: Bool? = nil ) -> PaykitAllowance { PaykitAllowance( id: PaykitAllowance.ID(counterparty: counterparty, allowanceId: allowanceId), role: role, lifecycleState: state, - isProposedByMe: role == .allower, + isProposedByMe: isProposedByMe ?? (role == .allower), perPaymentMaxSats: perPaymentMaxSats, monthlyLimitSats: monthlyLimitSats, monthlyAnchor: monthlyAnchor, diff --git a/changelog.d/next/841.added.md b/changelog.d/next/841.added.md new file mode 100644 index 000000000..70ea6904f --- /dev/null +++ b/changelog.d/next/841.added.md @@ -0,0 +1 @@ +Allowances now activate automatically on the receiving wallet: when a contact sets up an allowance for you, Bitkit accepts it and lets you know. diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index 9ea044255..736626ea2 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -1,7 +1,7 @@ # Allowances journeys Cover the Paykit allowance lifecycle between two Bitkit instances: the payer sets an allowance for a -contact, the payee accepts it, requests within the limits are paid without asking, a request above a +contact, the payee's wallet accepts it by itself, requests within the limits are paid without asking, a request above a limit falls back to the normal Payment Request sheet, and either side ends it. The restart journey pins the one rule that must never break: a payment interrupted mid-flight is never paid twice. @@ -14,8 +14,8 @@ requests). Automatic payments pay the payee's private Lightning invoice first an address otherwise, so the payer needs a spending balance above 50,000 sats with a usable channel, and the payee needs receiving capacity; fund both through the staging LSP. -Allow notifications for Bitkit on the payer when it asks: the "Payment Executed" and "Limit -Reached" events post a local notification when they can, and fall back to an in-app toast that the +Allow notifications for Bitkit on both instances when it asks: the payer's "Payment Executed" and +"Limit Reached" events and the payee's "Allowance Added" event post a local notification when they can, and fall back to an in-app toast that the UI snapshot cannot see. The journeys set $5 a payment and $50 a month, the second stop on each slider, and the cap journey @@ -40,15 +40,18 @@ killed right after handing the payment to the node never paid twice after relaun `AllowancePerPaymentStop-` and `AllowanceMonthlyStop-`, `AllowanceSummary`, `AllowanceSave` - List row: `AllowanceRow-` with its status line `AllowanceRowStatus` -- Review sheet (payee): `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, - `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline` +- Offer notification (payee): the toast `AllowanceOfferAcceptedToast` when notifications are off +- Review sheet: `AllowanceReview`, `AllowanceCounterparty`, `AllowancePerPaymentValue`, + `AllowanceMonthlyValue`, `AllowanceAccept`, `AllowanceDecline`; it opens only on the allower's wallet + for an allowance the payee asked for, never for the payer's own offer - Detail sheet: `AllowanceDetail`, `AllowancePaidSoFar`, `AllowanceDetailStatus` - Payments tab: `Tab-payments`, `PaymentRequestRequestPayment`, `PaymentRequestRow--one-time` whose subtitle ends with "· Auto-paid" for an automatic payment - Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet` -The review sheet on the payee opens on its own about a second after the proposal arrives; no tap is -needed to reach it. The file names, journey names and step prose match +The payee's wallet accepts the payer's offer by itself once the offer has arrived, which can take a +while between contacts that have never exchanged a private message, and posts " set up an +allowance for you"; no review sheet opens and no tap is needed. The file names, journey names and step prose match [`bitkit-android/journeys/allowances`](https://github.com/synonymdev/bitkit-android/tree/master/journeys/allowances); only the identifier annotations and the kill, relaunch and notification mechanics differ. diff --git a/journeys/allowances/set-and-accept.xml b/journeys/allowances/set-and-accept.xml index 83f2bd35d..5a7ecc2da 100644 --- a/journeys/allowances/set-and-accept.xml +++ b/journeys/allowances/set-and-accept.xml @@ -1,9 +1,10 @@ - The payer sets an allowance for a contact from the Allowances tab, and the payee sees the offer - open by itself and accepts it. Until the payee answers, the payer's row reads "Waiting for an - answer"; after it, both sides show the allowance as Active. The other allowance journeys start - from the Active state this one ends in. + The payer sets an allowance for a contact from the Allowances tab, and the payee's wallet accepts + the offer by itself: no review sheet opens and the payee taps nothing. The payee gets a "<payer> + set up an allowance for you" notification, and both sides show the allowance as Active. Until the + payee's wallet has received the offer, the payer's row reads "Waiting for an answer". The other + allowance journeys start from the Active state this one ends in. Launch the E2E Bitkit app with Paykit UI enabled on both instances, each with the other saved as a linked contact, the payer holding a spendable balance above 50,000 sats with a usable Lightning channel @@ -18,9 +19,8 @@ Tap Save Allowance (id "AllowanceSave") Verify the sheet dismisses and the list shows one row for the payee (id "AllowanceRow-<allowanceId>") whose status line (id "AllowanceRowStatus") reads "Waiting for an answer", with "$ 50.00" over "Monthly limit" on the right Switch to the payee instance and bring Bitkit to the foreground - Verify the Allowance review sheet (id "AllowanceReview") opens on its own within a few seconds, headed "<payer> offers an allowance", with the payer's contact card (id "AllowanceCounterparty"), PER PAYMENT "Up to $5.00" (id "AllowancePerPaymentValue") and EACH MONTH "Up to $50.00" (id "AllowanceMonthlyValue") - Tap Accept (id "AllowanceAccept") - Verify the sheet dismisses; open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit + Verify no Allowance review sheet (id "AllowanceReview") opens on the payee and that a notification "Allowance Added" reading "<payer> set up an allowance for you" arrives (or, with notifications off, the toast with id "AllowanceOfferAcceptedToast") + On the payee, open the drawer menu, tap Subscriptions, tap the Allowances tab and verify the payer's row reads "Active" followed by the per-payment limit, with no Accept or Decline step Switch back to the payer instance and verify its row now reads "Active · $5 a payment"