From 1a8d75592ff4c0310938889a28468001ce97459b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:16:16 +0200 Subject: [PATCH 1/3] feat: accept allowance offers automatically on the allowee's wallet Refs #807 --- Bitkit/AppScene.swift | 12 +++ .../Localization/en.lproj/Localizable.strings | 2 + Bitkit/Services/PaykitAllowance.swift | 5 ++ Bitkit/Services/PaykitAllowanceManager.swift | 27 ++++++- .../PaykitAllowanceExecutorTests.swift | 74 ++++++++++++++++++- BitkitTests/PaykitAllowanceTests.swift | 20 ++++- changelog.d/next/807.added.md | 1 + journeys/allowances/README.md | 17 +++-- journeys/allowances/set-and-accept.xml | 14 ++-- 9 files changed, 154 insertions(+), 18 deletions(-) create mode 100644 changelog.d/next/807.added.md diff --git a/Bitkit/AppScene.swift b/Bitkit/AppScene.swift index b92ca9324..876cae639 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 { @@ -1523,6 +1525,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 8c371c9cf..609d491e7 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] = [:] /// Covered requests waiting to be paid automatically: kept off the Send sheet until paid or found manual. @ObservationIgnored private var waitingRequestIds: Set = [] @@ -224,6 +225,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 } @@ -245,7 +270,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 1a99c6225..2d2a19cf9 100644 --- a/BitkitTests/PaykitAllowanceExecutorTests.swift +++ b/BitkitTests/PaykitAllowanceExecutorTests.swift @@ -632,6 +632,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 { @@ -1034,6 +1101,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] = [] @@ -1108,7 +1176,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/807.added.md b/changelog.d/next/807.added.md new file mode 100644 index 000000000..70ea6904f --- /dev/null +++ b/changelog.d/next/807.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..a54f2ec4d 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 as soon as it arrives, about a second after the +proposal is received, 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" From 80f5c0f44004e253ba6139786c73faa8637fe7d2 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:18:33 +0200 Subject: [PATCH 2/3] chore: rename changelog fragment --- changelog.d/next/{807.added.md => 841.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{807.added.md => 841.added.md} (100%) diff --git a/changelog.d/next/807.added.md b/changelog.d/next/841.added.md similarity index 100% rename from changelog.d/next/807.added.md rename to changelog.d/next/841.added.md From 0b8e352616f52d532068838628335a413398c0a0 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 00:19:07 +0200 Subject: [PATCH 3/3] docs: drop the acceptance timing from the allowance journeys readme --- journeys/allowances/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/journeys/allowances/README.md b/journeys/allowances/README.md index a54f2ec4d..736626ea2 100644 --- a/journeys/allowances/README.md +++ b/journeys/allowances/README.md @@ -50,8 +50,8 @@ killed right after handing the payment to the node never paid twice after relaun automatic payment - Incoming request: `PaymentRequestsBell`, `PaymentRequestsSheet` -The payee's wallet accepts the payer's offer as soon as it arrives, about a second after the -proposal is received, 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 +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.