From 02fddd0edfdf9bde8ad6b7b7406151e2971bb47b Mon Sep 17 00:00:00 2001 From: benk10 Date: Mon, 28 Sep 2026 22:05:17 +0200 Subject: [PATCH 1/4] chore: update paykit to rc56 --- Bitkit.xcodeproj/project.pbxproj | 2 +- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- Bitkit/Services/PaykitPaymentProofService.swift | 2 ++ Bitkit/Services/PaykitPaymentRequestService.swift | 6 ++++++ Bitkit/Services/PaykitSubscription.swift | 1 + BitkitTests/PaykitIssuerInteropTests.swift | 3 +++ BitkitTests/PaykitPaymentProofServiceTests.swift | 7 +++++++ BitkitTests/PaykitPaymentRequestServiceTests.swift | 12 ++++++++++++ BitkitTests/PaykitSubscriptionProposalTests.swift | 2 ++ BitkitTests/PaymentNavigationHelperTests.swift | 3 +++ 10 files changed, 40 insertions(+), 4 deletions(-) diff --git a/Bitkit.xcodeproj/project.pbxproj b/Bitkit.xcodeproj/project.pbxproj index 327f85c10..d1c1db580 100644 --- a/Bitkit.xcodeproj/project.pbxproj +++ b/Bitkit.xcodeproj/project.pbxproj @@ -1178,7 +1178,7 @@ repositoryURL = "https://github.com/pubky/paykit-rs"; requirement = { kind = exactVersion; - version = "0.1.0-rc55"; + version = "0.1.0-rc56"; }; }; 18D65DFE2EB9649F00252335 /* XCRemoteSwiftPackageReference "vss-rust-client-ffi" */ = { diff --git a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 81b2ef402..0d91d3e52 100644 --- a/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Bitkit.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "746cacd44d171f1c992b592ea1224d0562117bffd2907a54dee9d441395ac6b4", + "originHash" : "86914da7e92df3dff5ecfc5ee3ce34ea4b3f911278d0c81ac20b137bacf28367", "pins" : [ { "identity" : "bitkit-core", @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pubky/paykit-rs", "state" : { - "revision" : "4613beee25d1680ed8bc849446498e9bfb2cdb16", - "version" : "0.1.0-rc55" + "revision" : "24162ebbcc703251d8038f2e176d1f4cfb117c6a", + "version" : "0.1.0-rc56" } }, { diff --git a/Bitkit/Services/PaykitPaymentProofService.swift b/Bitkit/Services/PaykitPaymentProofService.swift index 9a42330b4..a2ec54aa9 100644 --- a/Bitkit/Services/PaykitPaymentProofService.swift +++ b/Bitkit/Services/PaykitPaymentProofService.swift @@ -688,6 +688,8 @@ actor PaykitPaymentProofService { proof: Paykit.PaymentProofSubmission( billingPeriod: pendingProof.billingPeriod?.sdkValue, paymentEndpointIdentifier: pendingProof.paymentEndpointIdentifier, + allowanceId: nil, + conversionQuoteId: nil, proof: Paykit.PrivateJsonObject(text: proofText) ) ) diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index ecba43b75..1e3f0ceec 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -13,6 +13,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { case missingTerms = "missing_terms" case recurringRequest = "recurring_request" case unsupportedAsset = "unsupported_asset" + case unsupportedPaymentDeadline = "unsupported_payment_deadline" case invalidAmount = "invalid_amount" case amountOutOfRange = "amount_out_of_range" case noSupportedEndpoint = "no_supported_endpoint" @@ -133,6 +134,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable { guard record.state != .activeRecurring else { return .failure(.recurringRequest) } guard let terms = record.terms else { return .failure(.missingTerms) } guard terms.recurrence == nil else { return .failure(.recurringRequest) } + guard terms.paymentDeadline == nil else { return .failure(.unsupportedPaymentDeadline) } guard terms.amount.asset == PaykitIssuerInterop.bitcoinAsset else { return .failure(.unsupportedAsset) } guard let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value) else { return .failure(.invalidAmount) } guard amountSats <= UInt64.max / 1000 else { return .failure(.amountOutOfRange) } @@ -666,6 +668,8 @@ struct PaykitPaymentRequestService { proposalExpiresAt: Self.timestamp(draft.expiresAt), recurrence: nil, acceptedPaymentEndpointIdentifiers: acceptedPaymentEndpointIdentifiers, + conversion: nil, + paymentDeadline: nil, metadata: Paykit.PrivateJsonObject(text: metadataText) ) let record = try await sdk.proposePaymentRequest( @@ -795,6 +799,8 @@ struct PaykitPaymentRequestService { proposalExpiresAt: Self.timestamp(draft.expiresAt), recurrence: recurrence, acceptedPaymentEndpointIdentifiers: endpoints, + conversion: nil, + paymentDeadline: nil, metadata: Paykit.PrivateJsonObject(text: metadataText) ) } diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 5ca678875..74636591b 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -536,6 +536,7 @@ struct PaykitSubscription: Identifiable, Hashable { } guard let terms = record.terms, + terms.paymentDeadline == nil, let recurrence = terms.recurrence.flatMap(PaykitSubscriptionRecurrence.init), terms.amount.asset == PaykitIssuerInterop.bitcoinAsset, let amountSats = PaykitPaymentRequest.sats(fromBitcoinAmount: terms.amount.value), diff --git a/BitkitTests/PaykitIssuerInteropTests.swift b/BitkitTests/PaykitIssuerInteropTests.swift index a5a018f91..f04ed035e 100644 --- a/BitkitTests/PaykitIssuerInteropTests.swift +++ b/BitkitTests/PaykitIssuerInteropTests.swift @@ -102,6 +102,8 @@ final class PaykitIssuerInteropTests: XCTestCase { proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: endpointIdentifiers, + conversion: nil, + paymentDeadline: nil, metadata: PrivateJsonObject(text: #"{"order":"713"}"#) ), acceptedEventId: nil, @@ -110,6 +112,7 @@ final class PaykitIssuerInteropTests: XCTestCase { rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, + conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, diff --git a/BitkitTests/PaykitPaymentProofServiceTests.swift b/BitkitTests/PaykitPaymentProofServiceTests.swift index 6d30e47a4..b7b7ddd4a 100644 --- a/BitkitTests/PaykitPaymentProofServiceTests.swift +++ b/BitkitTests/PaykitPaymentProofServiceTests.swift @@ -1229,6 +1229,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { proposalExpiresAt: nil, recurrence: recurrence, acceptedPaymentEndpointIdentifiers: endpoints, + conversion: nil, + paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), acceptedEventId: nil, @@ -1237,6 +1239,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase { rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, + conversionQuotes: [], paymentProofs: paymentProofs, lastStreamItemId: 1, lastOutboundMessageId: nil, @@ -1260,6 +1263,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase { paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, paymentEndpointIdentifier: endpoint, + allowanceId: nil, + conversionQuoteId: nil, proof: PrivateJsonObject(text: "{\"data\":\"\(data)\",\"type\":\"\(kind.rawValue)\"}"), recordedAt: "2027-01-15T08:01:00Z" ) @@ -1442,6 +1447,8 @@ private actor PaymentProofSdkMock: PaykitPaymentProofSdkHandling { paymentReference: paymentReference, billingPeriod: proof.billingPeriod, paymentEndpointIdentifier: proof.paymentEndpointIdentifier, + allowanceId: nil, + conversionQuoteId: nil, proof: proof.proof, recordedAt: "2027-01-15T08:01:00Z" )) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 97e863a9f..69062942f 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -234,6 +234,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { (paymentRequestRecord(id: "unknown-role", role: .unknown), .unsupportedLocalRole), (paymentRequestRecord(id: "missing-terms"), .missingTerms), (paymentRequestRecord(id: "wrong-asset", asset: "BTC"), .unsupportedAsset), + ( + paymentRequestRecord(id: "payment-deadline", paymentDeadline: .at(timestamp: timestamp(now.addingTimeInterval(3600)))), + .unsupportedPaymentDeadline + ), (paymentRequestRecord(id: "invalid-amount", amount: "not-bitcoin"), .invalidAmount), (paymentRequestRecord(id: "amount-out-of-range", amount: "184467440737.09551615"), .amountOutOfRange), (paymentRequestRecord(id: "unsupported-endpoint", endpoints: ["btc-unsupported-method"]), .noSupportedEndpoint), @@ -1591,6 +1595,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let manager = try paymentRequestManager( sdk: PaymentRequestSdkMock(records: [ paymentRequestRecord(id: "malformed", expiresAt: "not-a-timestamp", recurrence: recurrence), + paymentRequestRecord(id: "deadline", paymentDeadline: .periodStart(seconds: 3600), recurrence: recurrence), paymentRequestRecord(id: "unsupported", recurrence: recurrence, endpoints: ["btc-unsupported-method"]), paymentRequestRecord(id: "ended", recurrence: endedRecurrence), ]), @@ -1601,6 +1606,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { let subscription = try XCTUnwrap(manager.subscriptions.first) XCTAssertEqual(subscription.paymentRequestId, "unsupported") + XCTAssertFalse(manager.subscriptions.contains { $0.paymentRequestId == "deadline" }) XCTAssertFalse(subscription.isProposalActionable(at: Date(timeIntervalSince1970: 1_800_000_000))) XCTAssertEqual(manager.subscriptionProposalForPresentation()?.id, subscription.id) XCTAssertEqual( @@ -3639,6 +3645,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { amount: String = "0.001", asset: String = "btc", expiresAt: String? = nil, + paymentDeadline: PaymentDeadline? = nil, recurrence: PaymentRequestRecurrence? = nil, endpoints: [String] = [PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue], metadata: String = "{}", @@ -3664,6 +3671,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { proposalExpiresAt: expiresAt, recurrence: recurrence, acceptedPaymentEndpointIdentifiers: endpoints, + conversion: nil, + paymentDeadline: paymentDeadline, metadata: PrivateJsonObject(text: metadata) ), acceptedEventId: acceptedEventId, @@ -3672,6 +3681,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, + conversionQuotes: [], paymentProofs: paymentProofs, lastStreamItemId: 1, lastOutboundMessageId: nil, @@ -3694,6 +3704,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { paymentReference: PaymentReference(text: "invoice-123"), billingPeriod: billingPeriod, paymentEndpointIdentifier: endpoint, + allowanceId: nil, + conversionQuoteId: nil, proof: PrivateJsonObject(text: "{\"data\":\"proof\",\"type\":\"\(kind.rawValue)\"}"), recordedAt: "2027-01-15T08:01:00Z" ) diff --git a/BitkitTests/PaykitSubscriptionProposalTests.swift b/BitkitTests/PaykitSubscriptionProposalTests.swift index 8b2f549b1..20490f698 100644 --- a/BitkitTests/PaykitSubscriptionProposalTests.swift +++ b/BitkitTests/PaykitSubscriptionProposalTests.swift @@ -58,6 +58,8 @@ final class PaykitSubscriptionProposalTests: XCTestCase { endsAt: nil ), acceptedPaymentEndpointIdentifiers: ["bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"], + conversion: nil, + paymentDeadline: nil, metadata: PrivateJsonObject(text: String(decoding: data, as: UTF8.self)) ) } diff --git a/BitkitTests/PaymentNavigationHelperTests.swift b/BitkitTests/PaymentNavigationHelperTests.swift index 0a2ac2110..340a411c4 100644 --- a/BitkitTests/PaymentNavigationHelperTests.swift +++ b/BitkitTests/PaymentNavigationHelperTests.swift @@ -256,6 +256,8 @@ final class PaymentNavigationHelperTests: XCTestCase { proposalExpiresAt: nil, recurrence: nil, acceptedPaymentEndpointIdentifiers: [endpointIdentifier], + conversion: nil, + paymentDeadline: nil, metadata: PrivateJsonObject(text: "{}") ), acceptedEventId: nil, @@ -264,6 +266,7 @@ final class PaymentNavigationHelperTests: XCTestCase { rejectedOutboundStatus: nil, canceledEventId: nil, canceledOutboundStatus: nil, + conversionQuotes: [], paymentProofs: [], lastStreamItemId: 1, lastOutboundMessageId: nil, From 482beede2774194524b45e614daa2605f68a592a Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 29 Sep 2026 07:22:16 -0500 Subject: [PATCH 2/4] fix: retain paykit history for deadline requests --- .../PaykitPaymentRequestService.swift | 8 +- Bitkit/Services/PaykitSubscription.swift | 7 +- .../PaykitPaymentRequestServiceTests.swift | 76 +++++++++++++++++-- journeys/payment-requests/README.md | 10 ++- .../payment-deadline-history.xml | 22 ++++++ 5 files changed, 110 insertions(+), 13 deletions(-) create mode 100644 journeys/payment-requests/payment-deadline-history.xml diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift index 1e3f0ceec..780f26a99 100644 --- a/Bitkit/Services/PaykitPaymentRequestService.swift +++ b/Bitkit/Services/PaykitPaymentRequestService.swift @@ -134,7 +134,9 @@ struct PaykitPaymentRequest: Identifiable, Hashable { guard record.state != .activeRecurring else { return .failure(.recurringRequest) } guard let terms = record.terms else { return .failure(.missingTerms) } guard terms.recurrence == nil else { return .failure(.recurringRequest) } - guard terms.paymentDeadline == nil else { return .failure(.unsupportedPaymentDeadline) } + if requiresActionableRequest, terms.paymentDeadline != nil { + return .failure(.unsupportedPaymentDeadline) + } guard terms.amount.asset == PaykitIssuerInterop.bitcoinAsset else { return .failure(.unsupportedAsset) } guard let amountSats = Self.sats(fromBitcoinAmount: terms.amount.value) else { return .failure(.invalidAmount) } guard amountSats <= UInt64.max / 1000 else { return .failure(.amountOutOfRange) } @@ -1943,7 +1945,7 @@ final class PaykitPaymentRequestManager { dismissedSubscriptionPaymentIds.formIntersection(activeRecurringRequestIds) persistSubscriptionState() let recurringPending = recurringRequestsBySubscription - .filter { $0.0.lifecycleState == .activeRecurring } + .filter { $0.0.lifecycleState == .activeRecurring && !$0.0.hasPaymentDeadline } .flatMap { _, requests in requests.filter { $0.lifecycleState != .proofSubmitted && @@ -2039,7 +2041,7 @@ final class PaykitPaymentRequestManager { subscription.requests(through: date, acceptedAt: $0) } ?? [] pendingRequests.removeAll { $0.belongs(to: subscription) } - if subscription.lifecycleState == .activeRecurring { + if subscription.lifecycleState == .activeRecurring, !subscription.hasPaymentDeadline { pendingRequests.append(contentsOf: recurringRequests.filter { $0.lifecycleState != .proofSubmitted }) pendingRequests.sort { ($0.createdAt ?? .distantFuture) < ($1.createdAt ?? .distantFuture) } } diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 74636591b..11cb4bafa 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -440,6 +440,7 @@ struct PaykitSubscription: Identifiable, Hashable { let note: String? let createdAt: Date? let proposalExpiresAt: Date? + let hasPaymentDeadline: Bool let recurrence: PaykitSubscriptionRecurrence let metadata: PaykitSubscriptionMetadata let acceptedPaymentEndpointIdentifiers: [String] @@ -475,6 +476,7 @@ struct PaykitSubscription: Identifiable, Hashable { func isProposalActionable(at date: Date) -> Bool { isProposalVisible(at: date) && + !hasPaymentDeadline && recurrence.unit.isSupported && recurrence.canMaterializePeriods && !acceptedPaymentEndpointIdentifiers.isEmpty @@ -536,7 +538,6 @@ struct PaykitSubscription: Identifiable, Hashable { } guard let terms = record.terms, - terms.paymentDeadline == nil, let recurrence = terms.recurrence.flatMap(PaykitSubscriptionRecurrence.init), terms.amount.asset == PaykitIssuerInterop.bitcoinAsset, let amountSats = PaykitPaymentRequest.sats(fromBitcoinAmount: terms.amount.value), @@ -556,6 +557,7 @@ struct PaykitSubscription: Identifiable, Hashable { note = PaykitPaymentRequest.note(from: terms.metadata).map { String($0.prefix(256)) } createdAt = record.lastEventAt.flatMap(PaykitPaymentRequest.parseDate) self.proposalExpiresAt = proposalExpiresAt + hasPaymentDeadline = terms.paymentDeadline != nil self.recurrence = recurrence metadata = PaykitSubscriptionMetadata(terms.metadata) acceptedPaymentEndpointIdentifiers = PaykitIssuerInterop.supportedEndpointIdentifiers( @@ -595,7 +597,7 @@ struct PaykitSubscription: Identifiable, Hashable { } func paymentDueOnAcceptance(at date: Date) -> PaykitPaymentRequest? { - guard isPayer else { return nil } + guard isPayer, !hasPaymentDeadline else { return nil } guard let period = recurrence.periods(through: date, acceptedAt: PaykitPreciseInstant(date: date)).first else { return nil } return PaykitPaymentRequest(subscription: self, billingPeriod: period, lifecycleState: .activeRecurring) } @@ -720,6 +722,7 @@ actor PaykitSubscriptionNotificationScheduler { .filter { $0.isPayer && $0.isActive(at: now) && + !$0.hasPaymentDeadline && $0.recurrence.unit.isSupported && acceptedAt[$0.id] != nil } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 69062942f..b764843ff 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -364,6 +364,11 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { paymentRequestRecord(id: "outgoing", state: .proposed, role: .payee), paymentRequestRecord(id: "recurring", state: .activeRecurring), paymentRequestRecord(id: "unsupported", state: .canceled, endpoints: ["btc-unsupported-method"]), + paymentRequestRecord(id: "deadline-proposed", paymentDeadline: .at(timestamp: timestamp(now))), + paymentRequestRecord(id: "deadline-accepted", state: .accepted, paymentDeadline: .at(timestamp: timestamp(now))), + paymentRequestRecord(id: "deadline-paid", state: .proofSubmitted, paymentDeadline: .at(timestamp: timestamp(now))), + paymentRequestRecord(id: "deadline-canceled", state: .canceled, paymentDeadline: .at(timestamp: timestamp(now))), + paymentRequestRecord(id: "deadline-rejected", state: .rejected, paymentDeadline: .at(timestamp: timestamp(now))), ] let manager = paymentRequestManager( sdk: PaymentRequestSdkMock(records: records), @@ -375,7 +380,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(manager.pendingRequests.map(\.paymentRequestId), ["incoming", "accepted"]) XCTAssertEqual( Set(manager.historyRequests.map(\.paymentRequestId)), - Set(["incoming", "accepted", "rejected", "expired", "outgoing", "unsupported"]) + Set([ + "incoming", "accepted", "rejected", "expired", "outgoing", "unsupported", + "deadline-proposed", "deadline-accepted", "deadline-paid", "deadline-canceled", "deadline-rejected", + ]) ) XCTAssertEqual( manager.historyRequests.first { $0.paymentRequestId == "accepted" }?.lifecycleState, @@ -1067,7 +1075,9 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { anchor: "2027-01-01T08:00:00Z", endsAt: nil ) - let record = try paymentRequestRecord(state: .activeRecurring, recurrence: recurrence) + let record = try paymentRequestRecord( + state: .activeRecurring, paymentDeadline: .periodStart(seconds: 3600), recurrence: recurrence + ) let subscription = try XCTUnwrap(PaykitSubscription(record: record)) let request = try XCTUnwrap(subscription.requests(through: now, acceptedAt: PaykitPreciseInstant(date: now)).first) let manager = paymentRequestManager( @@ -1125,6 +1135,51 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { } } + func testDeadlineSubscriptionsKeepPaidPeriodsAndCancellationWithoutOfferingPayments() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-15T08:00:00Z")) + let recurrence = PaymentRequestRecurrence( + every: 1, unit: "month", startsAt: "2027-01-01T08:00:00Z", anchor: "2027-01-01T08:00:00Z", endsAt: nil + ) + let proof = try paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z") + ) + let records = try [PaymentRequestLocalRole.payer, .payee].map { (role: PaymentRequestLocalRole) in + try paymentRequestRecord( + id: "deadline-\(role)", state: .activeRecurring, role: role, + paymentDeadline: .periodStart(seconds: 3600), recurrence: recurrence, paymentProofs: [proof] + ) + } + let center = PaykitSubscriptionNotificationCenterMock() + let scheduler = PaykitSubscriptionNotificationScheduler(center: center) + let manager = paymentRequestManager( + sdk: PaymentRequestSdkMock(records: records), clock: PaymentRequestTestClock(now) + ) + + await manager.refresh() + + XCTAssertTrue(manager.pendingRequests.isEmpty) + XCTAssertEqual(manager.subscriptions.count, 2) + for subscription in manager.subscriptions { + XCTAssertEqual(subscription.paidPeriods.count, 1) + XCTAssertTrue(subscription.canCancel(at: now)) + XCTAssertNil(subscription.paymentDueOnAcceptance(at: now)) + } + let paid = try XCTUnwrap(manager.historyRequests.first) + XCTAssertEqual(manager.historyRequests.count, 1) + XCTAssertEqual(paid.lifecycleState, .proofSubmitted) + XCTAssertEqual(paid.paymentProofKind, .lightning) + XCTAssertEqual(manager.subscriptions.first { $0.isCreatedByUser }?.receivedPaymentRequests().count, 1) + await scheduler.synchronize( + manager.subscriptions, + acceptedAt: Dictionary(uniqueKeysWithValues: manager.subscriptions.map { ($0.id, PaykitPreciseInstant(date: now)) }), + pendingRequestIds: [], payerIdentity: "payer", notificationsEnabled: true, now: now + ) + let pendingNotifications = await center.pendingIdentifiers + XCTAssertTrue(pendingNotifications.isEmpty) + } + func testInFlightSubscriptionPaymentIsNotOfferedOrMarkedPaid() async throws { let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) let recurrence = PaymentRequestRecurrence( @@ -1604,11 +1659,18 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { await manager.refresh() - let subscription = try XCTUnwrap(manager.subscriptions.first) - XCTAssertEqual(subscription.paymentRequestId, "unsupported") - XCTAssertFalse(manager.subscriptions.contains { $0.paymentRequestId == "deadline" }) - XCTAssertFalse(subscription.isProposalActionable(at: Date(timeIntervalSince1970: 1_800_000_000))) - XCTAssertEqual(manager.subscriptionProposalForPresentation()?.id, subscription.id) + let deadlineSubscription = try XCTUnwrap(manager.subscriptions.first { $0.paymentRequestId == "deadline" }) + let subscription = try XCTUnwrap(manager.subscriptions.first { $0.paymentRequestId == "unsupported" }) + XCTAssertFalse(subscription.isProposalActionable(at: expiration)) + XCTAssertFalse(deadlineSubscription.isProposalActionable(at: expiration)) + XCTAssertNil(deadlineSubscription.paymentDueOnAcceptance(at: expiration)) + XCTAssertEqual(manager.subscriptionProposalForPresentation()?.id, deadlineSubscription.id) + do { + _ = try await manager.accept(deadlineSubscription) + XCTFail("Unsupported payment terms must not be accepted") + } catch { + XCTAssertEqual(error as? PaykitPaymentRequestError, .requestUnavailable) + } XCTAssertEqual( manager.subscriptions.first { $0.paymentRequestId == "ended" }?.lifecycleState, .proposalExpired diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 7a9208498..38e67f451 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -48,7 +48,7 @@ That run established the issuer shapes captured by the fixture: lowercase `btc`, The failure reason vocabulary is: - Parse: `missing_local_role`, `outgoing_request`, `unsupported_local_role`, `missing_terms`, - `recurring_request`, `unsupported_asset`, `invalid_amount`, `amount_out_of_range`, + `recurring_request`, `unsupported_asset`, `unsupported_payment_deadline`, `invalid_amount`, `amount_out_of_range`, `no_supported_endpoint`, `invalid_expiration`, `expired`. - Resolution: `no_supported_endpoint`, `endpoint_not_payable`, `payment_details_pending`, `resolution_failed`. @@ -105,3 +105,11 @@ the SDK and can push the Pay step well past the budget. - Payment Request amount screen: `PaymentRequestAmount`. - Terminal feedback: `PaymentRequestUnavailableToast`. - Expiration feedback: `PaymentRequestExpiredToast`. + +## Payment deadline history + +`payment-deadline-history.xml` covers rc56 requests with actual-payment deadlines. +Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, +but does not accept them, offer payments, or schedule payment reminders. The journey +requires a controlled rc56 peer to prepare the accepted and paid records; repository +tests cover these states without sending funds. diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml new file mode 100644 index 000000000..541a73827 --- /dev/null +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -0,0 +1,22 @@ + + + Requests with actual-payment deadlines are visible but cannot be paid by this version of Bitkit. + Use a disposable Paykit test identity linked to a controlled rc56 peer. Prepare one-time BTC + requests with deadlines in proposed, accepted, rejected, canceled and proof-submitted states, + plus an accepted monthly BTC subscription with a period-start deadline, one paid period and + one unpaid period. Acceptance and proof submission must be prepared through the controlled + client because Bitkit intentionally cannot accept these terms. Use valid regtest payment proofs. + Do not reset or replace a funded wallet to prepare this fixture. + + + Open Subscriptions and select the Payments tab after the controlled peer's records have synchronized + Verify each one-time request remains visible with its lifecycle status, including the completed payment + Open the proposed and accepted one-time requests and verify neither offers a Pay action + Open the active subscription and verify its paid period remains visible and Cancel is available + Verify the unpaid period is not offered for payment in the Payments tab or pending-request sheet + Have the controlled peer send a new subscription proposal with a period-start deadline + Open that proposal and verify it is visible but cannot be accepted + Cancel the accepted subscription and verify its paid-period history remains accessible + Restart Bitkit and verify the one-time and subscription payment history is still visible + + From 56e63fd6f051704c257a73dbf6173140ba229c27 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 29 Sep 2026 14:42:56 +0100 Subject: [PATCH 3/4] test: fix deadline history journey route --- journeys/payment-requests/README.md | 5 ++++- .../payment-deadline-history.xml | 20 ++++++++++++++++--- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index 38e67f451..ca8cb250f 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -112,4 +112,7 @@ the SDK and can push the Pay step well past the budget. Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, but does not accept them, offer payments, or schedule payment reminders. The journey requires a controlled rc56 peer to prepare the accepted and paid records; repository -tests cover these states without sending funds. +tests cover these states without sending funds. On iOS, payment-history rows show dates +rather than lifecycle labels, and active subscriptions are opened from Overview. The +iOS journey therefore records each fixture's payment request id, checks its full row +accessibility identifier, and includes the required back and tab transitions. diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index 541a73827..7c3eafb86 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -6,16 +6,30 @@ plus an accepted monthly BTC subscription with a period-start deadline, one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled client because Bitkit intentionally cannot accept these terms. Use valid regtest payment proofs. - Do not reset or replace a funded wallet to prepare this fixture. + Do not reset or replace a funded wallet to prepare this fixture. Record the payment request id + for each one-time state because iOS history rows show dates instead of status labels. Open Subscriptions and select the Payments tab after the controlled peer's records have synchronized - Verify each one-time request remains visible with its lifecycle status, including the completed payment - Open the proposed and accepted one-time requests and verify neither offers a Pay action + Verify the proposed row (id "PaymentRequestRow-<proposed-payment-request-id>-one-time") is visible + Verify the accepted row (id "PaymentRequestRow-<accepted-payment-request-id>-one-time") is visible + Verify the rejected row (id "PaymentRequestRow-<rejected-payment-request-id>-one-time") is visible + Verify the canceled row (id "PaymentRequestRow-<canceled-payment-request-id>-one-time") is visible + Verify the completed row (id "PaymentRequestRow-<completed-payment-request-id>-one-time") is visible + Open proposed row (id "PaymentRequestRow-<proposed-payment-request-id>-one-time") and verify no Pay action appears + Return to Payments + Open accepted row (id "PaymentRequestRow-<accepted-payment-request-id>-one-time") and verify no Pay action appears + Return to Payments + Select the Overview tab Open the active subscription and verify its paid period remains visible and Cancel is available + Return to Subscriptions + Select the Payments tab Verify the unpaid period is not offered for payment in the Payments tab or pending-request sheet Have the controlled peer send a new subscription proposal with a period-start deadline + Select the Overview tab after the proposal has synchronized Open that proposal and verify it is visible but cannot be accepted + Dismiss the proposal to return to Overview + Open the accepted subscription Cancel the accepted subscription and verify its paid-period history remains accessible Restart Bitkit and verify the one-time and subscription payment history is still visible From 91b43c8b947dcf07d4e25f2f70ae6b9df796c5c4 Mon Sep 17 00:00:00 2001 From: benk10 Date: Tue, 29 Sep 2026 10:51:09 -0500 Subject: [PATCH 4/4] fix: explain unsupported payment deadlines --- Bitkit/Views/Subscriptions/SubscriptionsView.swift | 2 +- Docs/paykit-issuer-interoperability.md | 3 +++ changelog.d/next/823.fixed.md | 1 + journeys/payment-requests/README.md | 12 +++++++----- .../payment-requests/payment-deadline-history.xml | 9 +++++---- 5 files changed, 17 insertions(+), 10 deletions(-) create mode 100644 changelog.d/next/823.fixed.md diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 40eef3db1..9668e750a 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -657,7 +657,7 @@ struct SubscriptionSheet: View { BodyMText(t("subscriptions__unsupported_description"), textColor: .white64) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 16) - } else if subscription.acceptedPaymentEndpointIdentifiers.isEmpty { + } else if subscription.hasPaymentDeadline || subscription.acceptedPaymentEndpointIdentifiers.isEmpty { BodyMText(t("subscriptions__unsupported_payment_description"), textColor: .white64) .frame(maxWidth: .infinity, alignment: .leading) .padding(.top, 16) diff --git a/Docs/paykit-issuer-interoperability.md b/Docs/paykit-issuer-interoperability.md index 526e49959..72f0d8869 100644 --- a/Docs/paykit-issuer-interoperability.md +++ b/Docs/paykit-issuer-interoperability.md @@ -13,10 +13,13 @@ An actionable request must satisfy all of these requirements: - The amount is a positive decimal Bitcoin value with at most eight significant fractional digits and no more than `18,446,744,073,709,551` satoshis. - The request is a one-time proposal: the local role is payer, lifecycle state is proposed, and recurrence is absent. - The proposal expiration is absent or is a valid future ISO 8601 timestamp. +- `paymentDeadline` is absent. Bitkit does not yet enforce actual-payment deadlines. - `acceptedPaymentEndpointIdentifiers` retains at least one identifier supported on the wallet's current network. Bitkit filters `acceptedPaymentEndpointIdentifiers` in issuer order, removes duplicates after their first occurrence, and drops unknown or wrong-network identifiers. The request remains actionable when at least one identifier survives. +Requests with a payment deadline remain visible in history but are unavailable for payment. This restriction is separate from proposal expiration, which controls acceptance. + ### Endpoint identifiers Lightning identifiers are chain-independent and are accepted on every network: diff --git a/changelog.d/next/823.fixed.md b/changelog.d/next/823.fixed.md new file mode 100644 index 000000000..748763dd5 --- /dev/null +++ b/changelog.d/next/823.fixed.md @@ -0,0 +1 @@ +Keep Paykit requests with unsupported payment deadlines in history and explain why their subscriptions cannot be accepted. diff --git a/journeys/payment-requests/README.md b/journeys/payment-requests/README.md index ca8cb250f..6c0583bb6 100644 --- a/journeys/payment-requests/README.md +++ b/journeys/payment-requests/README.md @@ -92,7 +92,7 @@ the SDK and can push the Pay step well past the budget. - Pending-request bell: `PaymentRequestsBell`. - Incoming sheet: `PaymentRequestsSheet`. - Screen: `PaymentRequestsScreen`. -- Request row: `PaymentRequestRow----one-time`; construct the complete value from the fixture issuer public key and negotiated receiver path because `wait-for-ui` does not support prefix matching. +- Request row: `PaymentRequestRow--one-time` for a one-time request; `wait-for-ui` requires the complete identifier. - Pay action: `PaymentRequestPay-`. - Dismiss action: `PaymentRequestDismiss-`. - Payment confirmation: `PaymentRequestConfirm`. @@ -112,7 +112,9 @@ the SDK and can push the Pay step well past the budget. Bitkit keeps their lifecycle and paid-period history, and subscription cancellation, but does not accept them, offer payments, or schedule payment reminders. The journey requires a controlled rc56 peer to prepare the accepted and paid records; repository -tests cover these states without sending funds. On iOS, payment-history rows show dates -rather than lifecycle labels, and active subscriptions are opened from Overview. The -iOS journey therefore records each fixture's payment request id, checks its full row -accessibility identifier, and includes the required back and tab transitions. +tests cover these states without sending funds. On both platforms, payment-history rows +show notes or dates rather than lifecycle labels, and active subscriptions are opened +from Overview. The journeys therefore record each fixture's payment request id, check +its full row identifier, and include the required back and tab transitions. The accepted +subscription must have no end date so cancellation is available. The proposal review +must explain that its payment details are unsupported and offer no Subscribe control. diff --git a/journeys/payment-requests/payment-deadline-history.xml b/journeys/payment-requests/payment-deadline-history.xml index 7c3eafb86..77a8de5aa 100644 --- a/journeys/payment-requests/payment-deadline-history.xml +++ b/journeys/payment-requests/payment-deadline-history.xml @@ -3,11 +3,11 @@ Requests with actual-payment deadlines are visible but cannot be paid by this version of Bitkit. Use a disposable Paykit test identity linked to a controlled rc56 peer. Prepare one-time BTC requests with deadlines in proposed, accepted, rejected, canceled and proof-submitted states, - plus an accepted monthly BTC subscription with a period-start deadline, one paid period and - one unpaid period. Acceptance and proof submission must be prepared through the controlled + plus an incoming, accepted monthly BTC subscription with no end date, a period-start deadline, + one paid period and one unpaid period. Acceptance and proof submission must be prepared through the controlled client because Bitkit intentionally cannot accept these terms. Use valid regtest payment proofs. Do not reset or replace a funded wallet to prepare this fixture. Record the payment request id - for each one-time state because iOS history rows show dates instead of status labels. + for each one-time state because history rows show notes or dates instead of status labels. Open Subscriptions and select the Payments tab after the controlled peer's records have synchronized @@ -27,7 +27,8 @@ Verify the unpaid period is not offered for payment in the Payments tab or pending-request sheet Have the controlled peer send a new subscription proposal with a period-start deadline Select the Overview tab after the proposal has synchronized - Open that proposal and verify it is visible but cannot be accepted + Open that proposal and verify it explains that its payment details are not supported yet + Verify no Subscribe swipe control appears Dismiss the proposal to return to Overview Open the accepted subscription Cancel the accepted subscription and verify its paid-period history remains accessible