Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Bitkit.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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" */ = {
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Bitkit/Services/PaykitPaymentProofService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
)
Expand Down
12 changes: 10 additions & 2 deletions Bitkit/Services/PaykitPaymentRequestService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -133,6 +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) }
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) }
Expand Down Expand Up @@ -666,6 +670,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(
Expand Down Expand Up @@ -795,6 +801,8 @@ struct PaykitPaymentRequestService {
proposalExpiresAt: Self.timestamp(draft.expiresAt),
recurrence: recurrence,
acceptedPaymentEndpointIdentifiers: endpoints,
conversion: nil,
paymentDeadline: nil,
metadata: Paykit.PrivateJsonObject(text: metadataText)
)
}
Expand Down Expand Up @@ -1937,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 &&
Expand Down Expand Up @@ -2033,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) }
}
Expand Down
6 changes: 5 additions & 1 deletion Bitkit/Services/PaykitSubscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -475,6 +476,7 @@ struct PaykitSubscription: Identifiable, Hashable {

func isProposalActionable(at date: Date) -> Bool {
isProposalVisible(at: date) &&
!hasPaymentDeadline &&
Comment thread
ovitrif marked this conversation as resolved.
recurrence.unit.isSupported &&
recurrence.canMaterializePeriods &&
!acceptedPaymentEndpointIdentifiers.isEmpty
Expand Down Expand Up @@ -555,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(
Expand Down Expand Up @@ -594,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)
}
Expand Down Expand Up @@ -719,6 +722,7 @@ actor PaykitSubscriptionNotificationScheduler {
.filter {
$0.isPayer &&
$0.isActive(at: now) &&
!$0.hasPaymentDeadline &&
$0.recurrence.unit.isSupported &&
acceptedAt[$0.id] != nil
}
Expand Down
2 changes: 1 addition & 1 deletion Bitkit/Views/Subscriptions/SubscriptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions BitkitTests/PaykitIssuerInteropTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -110,6 +112,7 @@ final class PaykitIssuerInteropTests: XCTestCase {
rejectedOutboundStatus: nil,
canceledEventId: nil,
canceledOutboundStatus: nil,
conversionQuotes: [],
paymentProofs: [],
lastStreamItemId: 1,
lastOutboundMessageId: nil,
Expand Down
7 changes: 7 additions & 0 deletions BitkitTests/PaykitPaymentProofServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1229,6 +1229,8 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
proposalExpiresAt: nil,
recurrence: recurrence,
acceptedPaymentEndpointIdentifiers: endpoints,
conversion: nil,
paymentDeadline: nil,
metadata: PrivateJsonObject(text: "{}")
),
acceptedEventId: nil,
Expand All @@ -1237,6 +1239,7 @@ final class PaykitPaymentProofServiceTests: XCTestCase {
rejectedOutboundStatus: nil,
canceledEventId: nil,
canceledOutboundStatus: nil,
conversionQuotes: [],
paymentProofs: paymentProofs,
lastStreamItemId: 1,
lastOutboundMessageId: nil,
Expand All @@ -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"
)
Expand Down Expand Up @@ -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"
))
Expand Down
86 changes: 80 additions & 6 deletions BitkitTests/PaykitPaymentRequestServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -360,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),
Expand All @@ -371,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,
Expand Down Expand Up @@ -1063,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(
Expand Down Expand Up @@ -1121,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(
Expand Down Expand Up @@ -1591,6 +1650,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),
]),
Expand All @@ -1599,10 +1659,18 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {

await manager.refresh()

let subscription = try XCTUnwrap(manager.subscriptions.first)
XCTAssertEqual(subscription.paymentRequestId, "unsupported")
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
Expand Down Expand Up @@ -3639,6 +3707,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 = "{}",
Expand All @@ -3664,6 +3733,8 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
proposalExpiresAt: expiresAt,
recurrence: recurrence,
acceptedPaymentEndpointIdentifiers: endpoints,
conversion: nil,
paymentDeadline: paymentDeadline,
metadata: PrivateJsonObject(text: metadata)
),
acceptedEventId: acceptedEventId,
Expand All @@ -3672,6 +3743,7 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
rejectedOutboundStatus: nil,
canceledEventId: nil,
canceledOutboundStatus: nil,
conversionQuotes: [],
paymentProofs: paymentProofs,
lastStreamItemId: 1,
lastOutboundMessageId: nil,
Expand All @@ -3694,6 +3766,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"
)
Expand Down
2 changes: 2 additions & 0 deletions BitkitTests/PaykitSubscriptionProposalTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))
)
}
Expand Down
Loading
Loading