diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json
new file mode 100644
index 000000000..3561b11b1
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/Contents.json
@@ -0,0 +1,12 @@
+{
+ "images" : [
+ {
+ "filename" : "subscription-default-icon.png",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png
new file mode 100644
index 000000000..830189723
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/subscription-default-icon.imageset/subscription-default-icon.png differ
diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json
new file mode 100644
index 000000000..a53c8c713
--- /dev/null
+++ b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/Contents.json
@@ -0,0 +1,13 @@
+{
+ "images" : [
+ {
+ "filename" : "subscription-intro-clock.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png
new file mode 100644
index 000000000..bedb79895
Binary files /dev/null and b/Bitkit/Assets.xcassets/Illustrations/subscription-intro-clock.imageset/subscription-intro-clock.png differ
diff --git a/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json
new file mode 100644
index 000000000..c3aa265df
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/asterisk.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "asterisk.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg b/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg
new file mode 100644
index 000000000..ca8d20d1f
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/asterisk.imageset/asterisk.svg
@@ -0,0 +1,7 @@
+
diff --git a/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json
new file mode 100644
index 000000000..68f946750
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/Contents.json
@@ -0,0 +1,16 @@
+{
+ "images" : [
+ {
+ "filename" : "timer-outline.svg",
+ "idiom" : "universal"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ },
+ "properties" : {
+ "preserves-vector-representation" : true,
+ "template-rendering-intent" : "template"
+ }
+}
diff --git a/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg
new file mode 100644
index 000000000..192285d6f
--- /dev/null
+++ b/Bitkit/Assets.xcassets/icons/timer-outline.imageset/timer-outline.svg
@@ -0,0 +1,8 @@
+
diff --git a/Bitkit/Components/MoneyCell.swift b/Bitkit/Components/MoneyCell.swift
index 14fd219d6..d8aa5a4a7 100644
--- a/Bitkit/Components/MoneyCell.swift
+++ b/Bitkit/Components/MoneyCell.swift
@@ -5,6 +5,7 @@ struct MoneyCell: View {
let sats: Int
let prefix: String
var enableHide: Bool = false
+ var symbol: Bool?
var body: some View {
VStack(alignment: .trailing, spacing: 2) {
@@ -12,6 +13,7 @@ struct MoneyCell: View {
sats: sats,
unitType: .primary,
size: .bodyMSB,
+ symbol: symbol,
enableHide: enableHide,
prefix: prefix,
color: .textPrimary,
diff --git a/Bitkit/Components/MoneyText.swift b/Bitkit/Components/MoneyText.swift
index 720baa80b..b96686a48 100644
--- a/Bitkit/Components/MoneyText.swift
+++ b/Bitkit/Components/MoneyText.swift
@@ -30,6 +30,7 @@ struct MoneyText: View {
var color: Color = .textPrimary
var symbolColor: Color?
var testIdentifier: String?
+ var fillsWidth: Bool = true
@EnvironmentObject var currency: CurrencyViewModel
@EnvironmentObject var settings: SettingsViewModel
@@ -105,7 +106,8 @@ extension MoneyText {
case .display:
DisplayText(
// Cap symbol font weight to ExtraBold
- text, textColor: color, accentColor: symbolColor ?? .textSecondary, accentFont: size == .display ? Fonts.extraBold : nil
+ text, textColor: color, accentColor: symbolColor ?? .textSecondary,
+ accentFont: Fonts.extraBold, fillsWidth: fillsWidth
)
case .title:
TitleText(text, textColor: color, accentColor: symbolColor ?? .textSecondary)
diff --git a/Bitkit/Components/NoteTextEditor.swift b/Bitkit/Components/NoteTextEditor.swift
index 33bc657b6..c12dccda3 100644
--- a/Bitkit/Components/NoteTextEditor.swift
+++ b/Bitkit/Components/NoteTextEditor.swift
@@ -5,6 +5,9 @@ struct NoteTextEditor: View {
let placeholder: String
let testIdentifier: String
let isFocused: FocusState.Binding
+ var minHeight: CGFloat = 30
+ var maxHeight: CGFloat = 50
+ var backgroundColor: Color = .white06
var body: some View {
ZStack(alignment: .topLeading) {
@@ -20,13 +23,13 @@ struct NoteTextEditor: View {
.submitLabel(.done)
.scrollContentBackground(.hidden)
.padding(EdgeInsets(top: -8, leading: -5, bottom: -5, trailing: -5))
- .frame(minHeight: 30, maxHeight: 50)
+ .frame(minHeight: minHeight, maxHeight: maxHeight)
.dismissKeyboardOnReturn(text: $text, isFocused: isFocused)
.accessibilityValue(text)
.accessibilityIdentifier(testIdentifier)
}
.padding()
- .background(Color.white06)
+ .background(backgroundColor)
.cornerRadius(8)
}
}
diff --git a/Bitkit/Components/PubkyContactRow.swift b/Bitkit/Components/PubkyContactRow.swift
index 919060b9c..534c29bab 100644
--- a/Bitkit/Components/PubkyContactRow.swift
+++ b/Bitkit/Components/PubkyContactRow.swift
@@ -15,8 +15,8 @@ struct PubkyContactRow: View {
HStack(spacing: 16) {
PubkyContactAvatar(contact: contact, size: 48)
- VStack(alignment: .leading, spacing: 4) {
- CaptionText(contact.profile.truncatedPublicKey.localizedUppercase)
+ VStack(alignment: .leading, spacing: 0) {
+ CaptionMText(contact.profile.truncatedPublicKey.localizedUppercase, textColor: .white64)
.lineLimit(1)
BodyMSBText(contact.displayName)
diff --git a/Bitkit/Components/PubkyImage.swift b/Bitkit/Components/PubkyImage.swift
index ab0e7b4cc..6c1a72003 100644
--- a/Bitkit/Components/PubkyImage.swift
+++ b/Bitkit/Components/PubkyImage.swift
@@ -7,6 +7,7 @@ import SwiftUI
struct PubkyImage: View {
let uri: String
let size: CGFloat
+ var cornerRadius: CGFloat?
@State private var uiImage: UIImage?
@State private var hasFailed = false
@@ -24,7 +25,7 @@ struct PubkyImage: View {
}
}
.frame(width: size, height: size)
- .clipShape(Circle())
+ .clipShape(RoundedRectangle(cornerRadius: cornerRadius ?? size / 2))
.accessibilityLabel(Text("Profile photo"))
.task(id: uri) {
await loadImage()
@@ -32,7 +33,7 @@ struct PubkyImage: View {
}
private var placeholder: some View {
- Circle()
+ Rectangle()
.fill(Color.gray5)
.overlay {
Image("user-square")
diff --git a/Bitkit/Components/SegmentedControl.swift b/Bitkit/Components/SegmentedControl.swift
index 2f1a1b8c0..551674a18 100644
--- a/Bitkit/Components/SegmentedControl.swift
+++ b/Bitkit/Components/SegmentedControl.swift
@@ -4,11 +4,13 @@ struct TabItem {
let tab: T
let activeColor: Color?
let badge: Int?
+ let accessibilityIdentifier: String?
- init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil) {
+ init(_ tab: T, activeColor: Color? = nil, badge: Int? = nil, accessibilityIdentifier: String? = nil) {
self.tab = tab
self.activeColor = activeColor
self.badge = badge
+ self.accessibilityIdentifier = accessibilityIdentifier
}
}
@@ -16,18 +18,21 @@ struct SegmentedControl: View {
@Binding var selectedTab: T
private let tabItems: [TabItem]
private let defaultActiveColor: Color
+ private let inactiveColor: Color?
@Namespace private var underlineNamespace
- init(selectedTab: Binding, tabs: [T], activeColor: Color = .textPrimary) {
+ init(selectedTab: Binding, tabs: [T], activeColor: Color = .textPrimary, inactiveColor: Color? = nil) {
_selectedTab = selectedTab
tabItems = tabs.map { TabItem($0) }
defaultActiveColor = activeColor
+ self.inactiveColor = inactiveColor
}
- init(selectedTab: Binding, tabItems: [TabItem], defaultActiveColor: Color = .textPrimary) {
+ init(selectedTab: Binding, tabItems: [TabItem], defaultActiveColor: Color = .textPrimary, inactiveColor: Color? = nil) {
_selectedTab = selectedTab
self.tabItems = tabItems
self.defaultActiveColor = defaultActiveColor
+ self.inactiveColor = inactiveColor
}
var body: some View {
@@ -40,7 +45,7 @@ struct SegmentedControl: View {
}) {
VStack(spacing: 8) {
HStack(spacing: 8) {
- CaptionBText(tabItem.tab.description, textColor: selectedTab == tabItem.tab ? .white : .secondary)
+ CaptionBText(tabItem.tab.description, textColor: selectedTab == tabItem.tab ? .white : inactiveColor ?? .secondary)
if let badge = tabItem.badge, badge > 0 {
CaptionBText("\(badge)", textColor: .black)
@@ -54,7 +59,7 @@ struct SegmentedControl: View {
ZStack {
Rectangle()
.frame(height: 2)
- .foregroundColor(Color.white64)
+ .foregroundColor(inactiveColor ?? .white64)
if selectedTab == tabItem.tab {
Rectangle()
@@ -68,7 +73,7 @@ struct SegmentedControl: View {
.contentShape(Rectangle())
}
.buttonStyle(PlainButtonStyle())
- .accessibilityIdentifier("Tab-\(tabItem.tab.description.lowercased())")
+ .accessibilityIdentifier(tabItem.accessibilityIdentifier ?? "Tab-\(tabItem.tab.description.lowercased())")
}
}
.frame(maxWidth: .infinity)
diff --git a/Bitkit/Components/TextField.swift b/Bitkit/Components/TextField.swift
index e4277552e..20c1dc49f 100644
--- a/Bitkit/Components/TextField.swift
+++ b/Bitkit/Components/TextField.swift
@@ -7,6 +7,7 @@ struct TextField: View {
let axis: Axis
let testIdentifier: String?
let submitLabel: SubmitLabel
+ let contentPadding: CGFloat
@Binding var text: String
init(
@@ -16,7 +17,8 @@ struct TextField: View {
font: Font = .custom(Fonts.semiBold, size: 15),
axis: Axis = .horizontal,
testIdentifier: String? = nil,
- submitLabel: SubmitLabel = .return
+ submitLabel: SubmitLabel = .return,
+ contentPadding: CGFloat = 16
) {
self.placeholder = placeholder
self.backgroundColor = backgroundColor
@@ -24,6 +26,7 @@ struct TextField: View {
self.axis = axis
self.testIdentifier = testIdentifier
self.submitLabel = submitLabel
+ self.contentPadding = contentPadding
_text = text
}
@@ -41,7 +44,7 @@ struct TextField: View {
.submitLabel(submitLabel)
.accessibilityIdentifierIfPresent(testIdentifier)
}
- .padding()
+ .padding(contentPadding)
.background(backgroundColor)
.cornerRadius(8)
}
diff --git a/Bitkit/Resources/Localization/en.lproj/Localizable.strings b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
index 9dd8d3083..478287fd8 100644
--- a/Bitkit/Resources/Localization/en.lproj/Localizable.strings
+++ b/Bitkit/Resources/Localization/en.lproj/Localizable.strings
@@ -1538,9 +1538,22 @@
"subscriptions__active" = "Active";
"subscriptions__cancel" = "Cancel";
"subscriptions__cancel_subscription" = "Cancel Subscription";
+"subscriptions__choose_recipient" = "Choose Recipient";
+"subscriptions__create" = "Create";
+"subscriptions__content_too_long" = "Shorten the subscription name or description and try again.";
+"subscriptions__icon_error" = "Could not load this image. Choose another image and try again.";
+"subscriptions__create_subscription" = "Create Subscription";
+"subscriptions__created" = "Created";
+"subscriptions__created_summary" = "1 subscriber · {count} payments";
+"subscriptions__created_summary_single_payment" = "1 subscriber · 1 payment";
+"subscriptions__custom_icon" = "Icon";
+"subscriptions__custom_icon_description" = "Tap to upload a custom icon";
"subscriptions__daily" = "Daily";
-"subscriptions__daily_subscription" = "Daily Subscription";
+"subscriptions__daily_subscription" = "Daily subscription";
"subscriptions__details" = "Subscription Details";
+"subscriptions__delete_subscription" = "Delete Subscription";
+"subscriptions__description" = "Description";
+"subscriptions__description_placeholder" = "What is this subscription for?";
"subscriptions__empty_description" = "At the moment, you don’t have any active subscriptions from any providers.";
"subscriptions__empty_headline" = "Welcome To\nSubscriptions";
"subscriptions__every_days" = "Every {count} days";
@@ -1557,7 +1570,9 @@
"subscriptions__more_info" = "More Info";
"subscriptions__monthly" = "Monthly";
"subscriptions__monthly_cost" = "Monthly Cost";
-"subscriptions__monthly_subscription" = "Monthly Subscription";
+"subscriptions__monthly_subscription" = "Monthly subscription";
+"subscriptions__name" = "Subscription Name";
+"subscriptions__name_placeholder" = "Subscription name";
"subscriptions__ongoing" = "Ongoing";
"subscriptions__overview" = "Overview";
"subscriptions__payment_due_description" = "Open Bitkit to review a subscription payment.";
@@ -1567,6 +1582,15 @@
"subscriptions__per_month" = "per month";
"subscriptions__per_week" = "per week";
"subscriptions__per_year" = "per year";
+"subscriptions__pending" = "Pending";
+"subscriptions__proposal_queued_description" = "Your subscription proposal is queued and will send automatically.";
+"subscriptions__proposal_queued_headline" = "Queued\nProposal";
+"subscriptions__proposal_queued_title" = "Queued";
+"subscriptions__proposal_queued_status" = "Proposal queued";
+"subscriptions__proposal_sent_description" = "You have sent a subscription proposal to";
+"subscriptions__proposal_sent_headline" = "Sent\nProposal";
+"subscriptions__proposal_sent_status" = "Proposal sent";
+"subscriptions__propose_subscription" = "Propose Subscription";
"subscriptions__proposals" = "Proposals";
"subscriptions__renews" = "Renews";
"subscriptions__retry_payment" = "Retry Payment";
@@ -1575,7 +1599,9 @@
"subscriptions__status" = "Status";
"subscriptions__subscribed" = "Subscribed";
"subscriptions__subscription" = "Subscription";
+"subscriptions__subscribers" = "Subscribers";
"subscriptions__swipe_to_cancel" = "Swipe To Cancel";
+"subscriptions__swipe_to_delete" = "Swipe To Delete";
"subscriptions__swipe_to_subscribe" = "Swipe To Subscribe";
"subscriptions__swipe_to_subscribe_and_pay" = "Swipe To Subscribe & Pay";
"subscriptions__title" = "Subscriptions";
@@ -1584,9 +1610,9 @@
"subscriptions__unsupported_frequency" = "Unsupported frequency";
"subscriptions__unsupported_payment_description" = "This subscription uses payment details that Bitkit does not support yet.";
"subscriptions__weekly" = "Weekly";
-"subscriptions__weekly_subscription" = "Weekly Subscription";
+"subscriptions__weekly_subscription" = "Weekly subscription";
"subscriptions__yearly" = "Yearly";
-"subscriptions__yearly_subscription" = "Yearly Subscription";
+"subscriptions__yearly_subscription" = "Yearly subscription";
"wallet__instant_payment_received" = "Received Instant Bitcoin";
"wallet__error_create_tx" = "Transaction Creation Failed";
"wallet__error_create_tx_msg" = "An error occurred. Please try again {raw}";
diff --git a/Bitkit/Services/PaykitPaymentRequestService.swift b/Bitkit/Services/PaykitPaymentRequestService.swift
index 47c82228a..288ffd605 100644
--- a/Bitkit/Services/PaykitPaymentRequestService.swift
+++ b/Bitkit/Services/PaykitPaymentRequestService.swift
@@ -1,6 +1,8 @@
import Foundation
+import ImageIO
import LDKNode
import Paykit
+import UIKit
struct PaykitPaymentRequest: Identifiable, Hashable {
enum Direction: Hashable {
@@ -183,7 +185,8 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
subscription: PaykitSubscription,
billingPeriod: PaykitBillingPeriod,
lifecycleState: Paykit.PaymentRequestLifecycleState,
- paymentProofKind: PaykitPaymentProofKind? = nil
+ paymentProofKind: PaykitPaymentProofKind? = nil,
+ direction: Direction = .incoming
) {
paymentRequestId = subscription.paymentRequestId
counterparty = subscription.counterparty
@@ -195,7 +198,7 @@ struct PaykitPaymentRequest: Identifiable, Hashable {
expiresAt = nil
acceptedPaymentEndpointIdentifiers = subscription.acceptedPaymentEndpointIdentifiers
deliveryStatus = nil
- direction = .incoming
+ self.direction = direction
self.lifecycleState = lifecycleState
self.billingPeriod = billingPeriod
self.paymentProofKind = paymentProofKind
@@ -324,6 +327,26 @@ struct PaykitPaymentRequestDraft: Hashable {
let expiresAt: Date
}
+struct PaykitSubscriptionDraft: Hashable {
+ var amountSats: UInt64
+ var name: String
+ var description: String
+ var frequency: PaykitSubscriptionRecurrence.Unit
+ var expiresAt: Date
+ var iconData: Data?
+
+ static var empty: PaykitSubscriptionDraft {
+ PaykitSubscriptionDraft(
+ amountSats: 0,
+ name: "",
+ description: "",
+ frequency: .month,
+ expiresAt: Date().addingTimeInterval(7 * 24 * 60 * 60),
+ iconData: nil
+ )
+ }
+}
+
struct PaykitPaymentRequestSnapshot: Equatable {
let incoming: [PaykitPaymentRequest]
let history: [PaykitPaymentRequest]
@@ -345,6 +368,7 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable {
case requestExpired
case operationInProgress
case amountMismatch
+ case subscriptionTooLong
var errorDescription: String? {
switch self {
@@ -356,6 +380,8 @@ enum PaykitPaymentRequestError: LocalizedError, Equatable {
t("wallet__payment_request_in_progress")
case .amountMismatch:
t("wallet__payment_request_mismatch")
+ case .subscriptionTooLong:
+ t("subscriptions__content_too_long")
}
}
}
@@ -373,6 +399,7 @@ protocol PaykitPaymentRequestSdkHandling: Sendable {
terms: Paykit.PaymentRequestTerms,
expectedIdentity: String
) async throws -> Paykit.PaymentRequestRecord
+ func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String?) async throws -> String
func acceptPaymentRequest(
counterparty: String,
counterpartyReceiverPath: String,
@@ -428,7 +455,7 @@ struct PaykitPaymentRequestService {
let history = records.compactMap {
PaykitPaymentRequest(historyRecord: $0, now: synchronizationDate)
}
- let subscriptions = records.compactMap(PaykitSubscription.init)
+ let subscriptions = records.compactMap { PaykitSubscription(record: $0) }
return PaykitPaymentRequestSnapshot(
incoming: incoming,
history: history,
@@ -525,6 +552,116 @@ struct PaykitPaymentRequestService {
)
}
+ func proposeSubscription(
+ _ draft: PaykitSubscriptionDraft,
+ to target: PaykitPaymentRequestTarget,
+ savedPublicKeys: [String],
+ expectedIdentity: String,
+ validateBeforeProposing: @MainActor () throws -> Void
+ ) async throws -> PaykitSubscription {
+ let acceptedPaymentEndpointIdentifiers = Self.acceptedPaymentEndpointIdentifiers()
+ let validationDate = now()
+ let name = draft.name.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard draft.amountSats > 0,
+ !name.isEmpty,
+ draft.frequency.isSupported,
+ !acceptedPaymentEndpointIdentifiers.isEmpty
+ else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ guard draft.expiresAt > validationDate else {
+ throw PaykitPaymentRequestError.requestExpired
+ }
+ guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+
+ try PaykitSubscriptionProposal.validate(subscriptionTerms(
+ draft,
+ iconURI: draft.iconData == nil ? nil : PaykitSubscriptionProposal.reservedIconURI,
+ endpoints: acceptedPaymentEndpointIdentifiers,
+ proposalDate: validationDate
+ ))
+ let iconURI: String? = if let iconData = draft.iconData {
+ try await sdk.uploadProfileAvatar(
+ bytes: Self.compressedSubscriptionIcon(iconData),
+ contentType: "image/jpeg",
+ expectedIdentity: expectedIdentity
+ )
+ } else {
+ nil
+ }
+ guard try await eligibleTargets(savedPublicKeys: savedPublicKeys, expectedIdentity: expectedIdentity).contains(target) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ try await validateBeforeProposing()
+ guard Self.acceptedPaymentEndpointIdentifiers() == acceptedPaymentEndpointIdentifiers else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ let proposalDate = now()
+ guard draft.expiresAt > proposalDate else {
+ throw PaykitPaymentRequestError.requestExpired
+ }
+ let terms = try subscriptionTerms(
+ draft,
+ iconURI: iconURI,
+ endpoints: acceptedPaymentEndpointIdentifiers,
+ proposalDate: proposalDate
+ )
+ try PaykitSubscriptionProposal.validate(terms)
+ let record = try await sdk.proposePaymentRequest(
+ counterparty: target.publicKey,
+ counterpartyReceiverPath: target.receiverPath,
+ terms: terms,
+ expectedIdentity: expectedIdentity
+ )
+ let reports = await (try? processPendingMessages()) ?? []
+ let deliveryStatus = proposalWasSent(record, reports: reports)
+ ? PaykitPaymentRequest.DeliveryStatus.sent
+ : .queued
+ guard let subscription = PaykitSubscription(record: record, deliveryStatusOverride: deliveryStatus) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ return subscription
+ }
+
+ private func subscriptionTerms(
+ _ draft: PaykitSubscriptionDraft,
+ iconURI: String?,
+ endpoints: [String],
+ proposalDate: Date
+ ) throws -> Paykit.PaymentRequestTerms {
+ var subscriptionMetadata: [String: Any] = [
+ "version": 1,
+ "description": draft.description.trimmingCharacters(in: .whitespacesAndNewlines),
+ "benefits": [],
+ ]
+ if let iconURI {
+ subscriptionMetadata["icon_uri"] = iconURI
+ }
+ let metadataData = try JSONSerialization.data(withJSONObject: [
+ "note": draft.name.trimmingCharacters(in: .whitespacesAndNewlines),
+ "subscription": subscriptionMetadata,
+ ])
+ let metadataText = String(decoding: metadataData, as: UTF8.self)
+ let timestamp = Self.timestamp(proposalDate)
+ let recurrence = Paykit.PaymentRequestRecurrence(
+ every: 1,
+ unit: draft.frequency.rawValue,
+ startsAt: timestamp,
+ anchor: timestamp,
+ endsAt: nil
+ )
+ return try Paykit.PaymentRequestTerms(
+ amount: Paykit.PaymentRequestAmount(value: WalletViewModel.formatBitcoinAmount(sats: draft.amountSats), asset: "btc"),
+ paymentReference: Paykit.PaymentReference(text: "bitkit-\(UUID().uuidString)"),
+ proposalExpiresAt: Self.timestamp(draft.expiresAt),
+ recurrence: recurrence,
+ acceptedPaymentEndpointIdentifiers: endpoints,
+ metadata: Paykit.PrivateJsonObject(text: metadataText)
+ )
+ }
+
func accept(_ request: PaykitPaymentRequest) async throws {
guard !request.isExpired(at: now()) else {
throw PaykitPaymentRequestError.requestExpired
@@ -580,7 +717,7 @@ struct PaykitPaymentRequestService {
}
func cancel(_ subscription: PaykitSubscription) async throws -> PaykitSubscription {
- guard subscription.isActive(at: now()) else {
+ guard subscription.canCancel(at: now()) else {
throw PaykitPaymentRequestError.requestUnavailable
}
@@ -615,6 +752,22 @@ struct PaykitPaymentRequestService {
return formatter.string(from: date)
}
+ static func compressedSubscriptionIcon(_ data: Data) throws -> Data {
+ guard let source = CGImageSourceCreateWithData(data as CFData, [kCGImageSourceShouldCache: false] as CFDictionary),
+ let thumbnail = CGImageSourceCreateThumbnailAtIndex(source, 0, [
+ kCGImageSourceCreateThumbnailFromImageAlways: true,
+ kCGImageSourceCreateThumbnailWithTransform: true,
+ kCGImageSourceThumbnailMaxPixelSize: 400,
+ ] as CFDictionary)
+ else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ guard let compressed = UIImage(cgImage: thumbnail).jpegData(compressionQuality: 0.8) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ return compressed
+ }
+
@discardableResult
private func processPendingMessages() async throws -> [Paykit.OutboundPrivateCounterpartySendReport] {
do {
@@ -645,6 +798,7 @@ struct PaykitPaymentRequestService {
_ record: Paykit.PaymentRequestRecord,
reports: [Paykit.OutboundPrivateCounterpartySendReport]
) -> Bool {
+ if case .sent? = record.proposalOutboundStatus { return true }
guard let messageId = record.proposalOutboundMessageId else { return false }
return reports.contains { report in
PubkyPublicKeyFormat.matches(report.counterparty, record.counterparty) &&
@@ -922,6 +1076,56 @@ final class PaykitPaymentRequestManager {
return request
}
+ func proposeSubscription(
+ _ draft: PaykitSubscriptionDraft,
+ to target: PaykitPaymentRequestTarget
+ ) async throws -> PaykitSubscription {
+ guard draft.amountSats > 0,
+ isAvailable(),
+ let activeIdentity,
+ eligibleTargets.contains(target)
+ else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ guard !isCreatingRequest else {
+ throw PaykitPaymentRequestError.operationInProgress
+ }
+
+ let actionGeneration = stateGeneration
+ let savedPublicKeysSnapshot = savedPublicKeys
+ isCreatingRequest = true
+ defer {
+ if actionGeneration == stateGeneration {
+ isCreatingRequest = false
+ }
+ }
+ let subscription = try await service.proposeSubscription(
+ draft,
+ to: target,
+ savedPublicKeys: savedPublicKeysSnapshot,
+ expectedIdentity: activeIdentity,
+ validateBeforeProposing: {
+ guard actionGeneration == self.stateGeneration,
+ self.isAvailable(),
+ PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity),
+ self.savedPublicKeys.contains(where: { PubkyPublicKeyFormat.matches($0, target.publicKey) })
+ else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ }
+ )
+ if actionGeneration == stateGeneration,
+ isAvailable(),
+ PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity)
+ {
+ invalidateRefresh()
+ subscriptions.removeAll { $0.id == subscription.id }
+ subscriptions.append(subscription)
+ discardExpiredRequests()
+ }
+ return subscription
+ }
+
func refresh() async {
await refresh(excludingProtectedRequestId: nil)
}
@@ -1022,6 +1226,7 @@ final class PaykitPaymentRequestManager {
func requestSubscriptionPresentation(_ subscription: PaykitSubscription) {
guard subscriptions.contains(where: { $0.id == subscription.id }),
+ subscription.isPayer,
subscription.isProposalVisible(at: now()),
!isProcessingSubscription
else { return }
@@ -1031,11 +1236,11 @@ final class PaykitPaymentRequestManager {
func subscriptionProposalForPresentation() -> PaykitSubscription? {
if let requestedSubscriptionProposalId {
return subscriptions.first {
- $0.id == requestedSubscriptionProposalId && $0.isProposalVisible(at: now())
+ $0.id == requestedSubscriptionProposalId && $0.isPayer && $0.isProposalVisible(at: now())
}
}
return subscriptions.first {
- $0.isProposalVisible(at: now()) && !presentedSubscriptionProposalIds.contains($0.id)
+ $0.isPayer && $0.isProposalVisible(at: now()) && !presentedSubscriptionProposalIds.contains($0.id)
}
}
@@ -1076,6 +1281,7 @@ final class PaykitPaymentRequestManager {
guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress }
guard let current = subscriptions.first(where: { $0.id == subscription.id }),
current == subscription,
+ current.isPayer,
current.isProposalActionable(at: now()),
let activeIdentity
else { throw PaykitPaymentRequestError.requestUnavailable }
@@ -1107,7 +1313,7 @@ final class PaykitPaymentRequestManager {
func cancel(_ subscription: PaykitSubscription) async throws {
guard !isProcessingSubscription else { throw PaykitPaymentRequestError.operationInProgress }
guard let current = subscriptions.first(where: { $0.id == subscription.id }),
- current.isActive(at: now()),
+ current.canCancel(at: now()),
let activeIdentity
else {
throw PaykitPaymentRequestError.requestUnavailable
@@ -1121,12 +1327,14 @@ final class PaykitPaymentRequestManager {
}
}
- let protectedRequestIds = try await protectedRequestIdsForSubscriptionCancellation(activeIdentity, current.id)
- guard actionGeneration == stateGeneration,
- PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity)
- else { return }
- guard protectedRequestIds.isEmpty else {
- throw PaykitPaymentRequestError.operationInProgress
+ if current.isPayer {
+ let protectedRequestIds = try await protectedRequestIdsForSubscriptionCancellation(activeIdentity, current.id)
+ guard actionGeneration == stateGeneration,
+ PubkyPublicKeyFormat.matches(self.activeIdentity, activeIdentity)
+ else { return }
+ guard protectedRequestIds.isEmpty else {
+ throw PaykitPaymentRequestError.operationInProgress
+ }
}
let canceledSubscription = try await service.cancel(current)
@@ -1342,15 +1550,18 @@ final class PaykitPaymentRequestManager {
else { return }
let refreshDate = now()
subscriptions = snapshot.subscriptions.map { $0.withExpiredLifecycle(at: refreshDate) }
- let visibleProposalIds = Set(subscriptions.filter { $0.isProposalVisible(at: refreshDate) }.map(\.id))
+ let visibleProposalIds = Set(subscriptions.filter {
+ $0.isPayer && $0.isProposalVisible(at: refreshDate)
+ }.map(\.id))
presentedSubscriptionProposalIds.formIntersection(visibleProposalIds)
for subscription in subscriptions
- where subscription.wasAccepted &&
+ where subscription.isPayer &&
+ subscription.wasAccepted &&
subscriptionAcceptedAt[subscription.id] == nil
{
subscriptionAcceptedAt[subscription.id] = subscription.paidPeriods.map(\.startsAt).min() ?? subscription.createdAt ?? refreshDate
}
- let recurringRequestsBySubscription = subscriptions.map { subscription in
+ let recurringRequestsBySubscription = subscriptions.filter(\.isPayer).map { subscription in
let requests: [PaykitPaymentRequest] = if let acceptedAt = subscriptionAcceptedAt[subscription.id] {
subscription.requests(through: refreshDate, acceptedAt: acceptedAt)
} else {
@@ -1435,6 +1646,19 @@ final class PaykitPaymentRequestManager {
subscriptions.removeAll { $0.id == subscription.id }
subscriptions.append(subscription)
+ guard subscription.isPayer else {
+ await subscriptionNotificationScheduler.synchronize(
+ subscriptions.filter(\.isPayer),
+ acceptedAt: subscriptionAcceptedAt,
+ pendingRequestIds: Set(pendingRequests.map(\.id)),
+ payerIdentity: activeIdentity,
+ notificationsEnabled: SettingsViewModel.shared.enableNotifications,
+ now: date
+ )
+ discardExpiredRequests()
+ return
+ }
+
let recurringRequests = subscriptionAcceptedAt[subscription.id].map {
subscription.requests(through: date, acceptedAt: $0)
} ?? []
@@ -1526,7 +1750,7 @@ final class PaykitPaymentRequestManager {
pendingRequests.removeAll { $0.isExpired(at: date) }
subscriptions = subscriptions.map { $0.withExpiredLifecycle(at: date) }
presentedSubscriptionProposalIds.formIntersection(
- Set(subscriptions.filter { $0.isProposalVisible(at: date) }.map(\.id))
+ Set(subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: date) }.map(\.id))
)
persistSubscriptionState()
if requestedSubscriptionProposalId.map({ id in
diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift
index 38e867b83..73e5aa5e4 100644
--- a/Bitkit/Services/PaykitSubscription.swift
+++ b/Bitkit/Services/PaykitSubscription.swift
@@ -11,17 +11,17 @@ private struct PaykitPreciseInstant: Comparable, Hashable {
Date(timeIntervalSince1970: Double(seconds) + Double(nanoseconds) / 1_000_000_000)
}
+ var wholeSecondDate: Date {
+ Date(timeIntervalSince1970: TimeInterval(seconds))
+ }
+
init?(timestamp: String) {
let canonical = PaykitSubscriptionTimestamp.canonical(timestamp)
let fraction = PaykitSubscriptionTimestamp.fractionalSeconds(from: canonical) ?? ""
let formatter = ISO8601DateFormatter()
- formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- var parsedDate = formatter.date(from: canonical)
- if parsedDate == nil {
- formatter.formatOptions = [.withInternetDateTime]
- parsedDate = formatter.date(from: canonical)
- }
- guard let date = parsedDate else { return nil }
+ formatter.formatOptions = [.withInternetDateTime]
+ let wholeSecondTimestamp = fraction.isEmpty ? canonical : canonical.replacingOccurrences(of: ".\(fraction)", with: "")
+ guard let date = formatter.date(from: wholeSecondTimestamp) else { return nil }
seconds = Int64(floor(date.timeIntervalSince1970))
nanoseconds = Int(fraction.padding(toLength: 9, withPad: "0", startingAt: 0)) ?? 0
@@ -114,6 +114,7 @@ struct PaykitBillingPeriod: Codable, Hashable {
struct PaykitSubscriptionMetadata: Hashable {
let description: String?
let benefits: [String]
+ let iconURI: String?
init(_ metadata: Paykit.PrivateJsonObject) {
guard let data = metadata.exportText().data(using: .utf8),
@@ -123,6 +124,7 @@ struct PaykitSubscriptionMetadata: Hashable {
else {
description = nil
benefits = []
+ iconURI = nil
return
}
@@ -130,6 +132,8 @@ struct PaykitSubscriptionMetadata: Hashable {
benefits = (subscription["benefits"] as? [String] ?? [])
.prefix(8)
.compactMap { Self.trimmed($0, limit: 160) }
+ iconURI = Self.trimmed(subscription["icon_uri"] as? String, limit: 512)
+ .flatMap { $0.hasPrefix("pubky://") ? $0 : nil }
}
private static func trimmed(_ value: String?, limit: Int) -> String? {
@@ -198,8 +202,18 @@ struct PaykitSubscriptionRecurrence: Hashable {
}
func periods(through date: Date, acceptedAt: Date) -> [PaykitBillingPeriod] {
- let preciseDate = PaykitPreciseInstant(date: date)
- let preciseAcceptedAt = PaykitPreciseInstant(date: acceptedAt)
+ periods(through: PaykitPreciseInstant(date: date), acceptedAt: PaykitPreciseInstant(date: acceptedAt))
+ }
+
+ func contains(_ period: PaykitBillingPeriod) -> Bool {
+ guard let start = PaykitPreciseInstant(timestamp: period.sdkValue.startsAt) else { return false }
+ return periods(through: start, acceptedAt: start).first == period
+ }
+
+ private func periods(
+ through preciseDate: PaykitPreciseInstant,
+ acceptedAt preciseAcceptedAt: PaykitPreciseInstant
+ ) -> [PaykitBillingPeriod] {
guard unit.isSupported, preciseStartsAt <= preciseDate else { return [] }
var periods: [PaykitBillingPeriod] = []
@@ -295,17 +309,17 @@ struct PaykitSubscriptionRecurrence: Hashable {
let boundaryDate: Date? = switch unit {
case .minute:
- calendar.date(byAdding: .minute, value: value, to: preciseAnchor.date)
+ calendar.date(byAdding: .minute, value: value, to: preciseAnchor.wholeSecondDate)
case .hour:
- calendar.date(byAdding: .hour, value: value, to: preciseAnchor.date)
+ calendar.date(byAdding: .hour, value: value, to: preciseAnchor.wholeSecondDate)
case .day:
- calendar.date(byAdding: .day, value: value, to: preciseAnchor.date)
+ calendar.date(byAdding: .day, value: value, to: preciseAnchor.wholeSecondDate)
case .week:
- calendar.date(byAdding: .weekOfYear, value: value, to: preciseAnchor.date)
+ calendar.date(byAdding: .weekOfYear, value: value, to: preciseAnchor.wholeSecondDate)
case .month:
- Self.monthBoundary(from: preciseAnchor.date, offset: value, calendar: calendar)
+ Self.monthBoundary(from: preciseAnchor.wholeSecondDate, offset: value, calendar: calendar)
case .year:
- Self.yearBoundary(from: preciseAnchor.date, offset: value, calendar: calendar)
+ Self.yearBoundary(from: preciseAnchor.wholeSecondDate, offset: value, calendar: calendar)
}
guard let boundaryDate else { return nil }
return PaykitPreciseInstant(seconds: Int64(floor(boundaryDate.timeIntervalSince1970)), nanoseconds: preciseAnchor.nanoseconds)
@@ -316,17 +330,17 @@ struct PaykitSubscriptionRecurrence: Hashable {
calendar.timeZone = TimeZone(secondsFromGMT: 0)!
let result: Date? = switch unit {
case .minute:
- calendar.date(byAdding: .minute, value: every, to: date.date)
+ calendar.date(byAdding: .minute, value: every, to: date.wholeSecondDate)
case .hour:
- calendar.date(byAdding: .hour, value: every, to: date.date)
+ calendar.date(byAdding: .hour, value: every, to: date.wholeSecondDate)
case .day:
- calendar.date(byAdding: .day, value: every, to: date.date)
+ calendar.date(byAdding: .day, value: every, to: date.wholeSecondDate)
case .week:
- calendar.date(byAdding: .weekOfYear, value: every, to: date.date)
+ calendar.date(byAdding: .weekOfYear, value: every, to: date.wholeSecondDate)
case .month:
- calendar.date(byAdding: .month, value: every, to: date.date)
+ calendar.date(byAdding: .month, value: every, to: date.wholeSecondDate)
case .year:
- calendar.date(byAdding: .year, value: every, to: date.date)
+ calendar.date(byAdding: .year, value: every, to: date.wholeSecondDate)
}
guard let result else { return nil }
return PaykitPreciseInstant(seconds: Int64(floor(result.timeIntervalSince1970)), nanoseconds: date.nanoseconds)
@@ -379,6 +393,11 @@ struct PaykitSubscriptionRecurrence: Hashable {
}
struct PaykitSubscription: Identifiable, Hashable {
+ enum LocalRole: Hashable {
+ case payer
+ case payee
+ }
+
struct ID: Codable, Hashable {
let paymentRequestId: String
let counterparty: String
@@ -402,6 +421,8 @@ struct PaykitSubscription: Identifiable, Hashable {
let metadata: PaykitSubscriptionMetadata
let acceptedPaymentEndpointIdentifiers: [String]
let wasAccepted: Bool
+ let localRole: LocalRole
+ let deliveryStatus: PaykitPaymentRequest.DeliveryStatus?
var lifecycleState: Paykit.PaymentRequestLifecycleState
let payments: [Payment]
@@ -421,6 +442,14 @@ struct PaykitSubscription: Identifiable, Hashable {
lifecycleState == .proposed
}
+ var isPayer: Bool {
+ localRole == .payer
+ }
+
+ var isCreatedByUser: Bool {
+ localRole == .payee
+ }
+
func isProposalActionable(at date: Date) -> Bool {
isProposalVisible(at: date) &&
recurrence.unit.isSupported &&
@@ -438,6 +467,22 @@ struct PaykitSubscription: Identifiable, Hashable {
lifecycleState == .activeRecurring && recurrence.endsAt.map { $0 > date } ?? true
}
+ func isCreatedVisible(at date: Date) -> Bool {
+ isCreatedByUser && (isProposalVisible(at: date) || isActive(at: date))
+ }
+
+ func isExpiredVisible(at date: Date) -> Bool {
+ isExpired(at: date) && (isPayer ? wasAccepted : !payments.isEmpty)
+ }
+
+ func canCancel(at date: Date) -> Bool {
+ guard recurrence.endsAt == nil else { return false }
+ if isCreatedByUser {
+ return isProposalVisible(at: date) || isActive(at: date)
+ }
+ return isActive(at: date)
+ }
+
func isExpired(at date: Date) -> Bool {
lifecycleState == .canceled || lifecycleState == .rejected || lifecycleState == .proposalExpired ||
(isProposal && proposalExpiresAt.map { $0 <= date } ?? false) ||
@@ -453,9 +498,21 @@ struct PaykitSubscription: Identifiable, Hashable {
return subscription
}
- init?(record: Paykit.PaymentRequestRecord) {
- guard record.localRole == .payer,
- let terms = record.terms,
+ init?(
+ record: Paykit.PaymentRequestRecord,
+ deliveryStatusOverride: PaykitPaymentRequest.DeliveryStatus? = nil
+ ) {
+ let localRole: LocalRole
+ switch record.localRole {
+ case .payer?:
+ localRole = .payer
+ case .payee?:
+ localRole = .payee
+ case .unknown?, nil:
+ return nil
+ }
+
+ guard let terms = record.terms,
let recurrence = terms.recurrence.flatMap(PaykitSubscriptionRecurrence.init),
terms.amount.asset == PaykitIssuerInterop.bitcoinAsset,
let amountSats = PaykitPaymentRequest.sats(fromBitcoinAmount: terms.amount.value),
@@ -482,18 +539,27 @@ struct PaykitSubscription: Identifiable, Hashable {
network: Env.network
)
wasAccepted = record.acceptedEventId != nil || record.state == .activeRecurring || !record.paymentProofs.isEmpty
+ self.localRole = localRole
+ deliveryStatus = localRole == .payee
+ ? deliveryStatusOverride ?? Self.deliveryStatus(from: record.proposalOutboundStatus)
+ : nil
lifecycleState = record.state
- payments = record.paymentProofs.compactMap { proof in
- guard let billingPeriod = proof.billingPeriod.flatMap(PaykitBillingPeriod.init) else { return nil }
- return Payment(
+ var paymentsByPeriod: [PaykitBillingPeriod: Payment] = [:]
+ for proof in record.paymentProofs {
+ guard let billingPeriod = proof.billingPeriod.flatMap(PaykitBillingPeriod.init),
+ recurrence.contains(billingPeriod)
+ else { continue }
+ paymentsByPeriod[billingPeriod] = Payment(
billingPeriod: billingPeriod,
proofKind: PaykitPaymentProofKind(paymentEndpointIdentifier: proof.paymentEndpointIdentifier)
)
}
+ payments = paymentsByPeriod.values.sorted { $0.billingPeriod.startsAt < $1.billingPeriod.startsAt }
}
func requests(through date: Date, acceptedAt: Date) -> [PaykitPaymentRequest] {
- recurrence.periods(through: date, acceptedAt: acceptedAt).map { period in
+ guard isPayer else { return [] }
+ return recurrence.periods(through: date, acceptedAt: acceptedAt).map { period in
let payment = payments.last { $0.billingPeriod == period }
return PaykitPaymentRequest(
subscription: self,
@@ -505,9 +571,32 @@ struct PaykitSubscription: Identifiable, Hashable {
}
func paymentDueOnAcceptance(at date: Date) -> PaykitPaymentRequest? {
+ guard isPayer else { return nil }
guard let period = recurrence.periods(through: date, acceptedAt: date).first else { return nil }
return PaykitPaymentRequest(subscription: self, billingPeriod: period, lifecycleState: .activeRecurring)
}
+
+ func receivedPaymentRequests() -> [PaykitPaymentRequest] {
+ guard isCreatedByUser else { return [] }
+ return payments.map {
+ PaykitPaymentRequest(
+ subscription: self,
+ billingPeriod: $0.billingPeriod,
+ lifecycleState: .proofSubmitted,
+ paymentProofKind: $0.proofKind,
+ direction: .outgoing
+ )
+ }
+ }
+
+ private static func deliveryStatus(
+ from status: Paykit.OutboundPrivateMessageStatus?
+ ) -> PaykitPaymentRequest.DeliveryStatus {
+ if case .sent? = status {
+ return .sent
+ }
+ return .queued
+ }
}
struct PaykitSubscriptionState: Codable, Equatable {
@@ -588,7 +677,8 @@ actor PaykitSubscriptionNotificationScheduler {
let currentGeneration = generation
let notifications: [(PaykitSubscription, PaykitBillingPeriod)] = notificationsEnabled ? Array(subscriptions
.filter {
- $0.isActive(at: now) &&
+ $0.isPayer &&
+ $0.isActive(at: now) &&
$0.recurrence.unit.isSupported &&
acceptedAt[$0.id] != nil
}
diff --git a/Bitkit/Services/PaykitSubscriptionProposal.swift b/Bitkit/Services/PaykitSubscriptionProposal.swift
new file mode 100644
index 000000000..675a822a0
--- /dev/null
+++ b/Bitkit/Services/PaykitSubscriptionProposal.swift
@@ -0,0 +1,43 @@
+import Foundation
+import Paykit
+
+enum PaykitSubscriptionProposal {
+ // Paykit's proposal wire format is carried in a pubky-noise message capped at 1,000 UTF-8 bytes.
+ static let maximumMessageBytes = 1000
+ static let reservedIconURI = "pubky://" + String(repeating: "x", count: 122)
+
+ static func validate(_ terms: Paykit.PaymentRequestTerms) throws {
+ guard try encodedSize(terms) <= maximumMessageBytes else {
+ throw PaykitPaymentRequestError.subscriptionTooLong
+ }
+ }
+
+ static func encodedSize(_ terms: Paykit.PaymentRequestTerms) throws -> Int {
+ guard let recurrence = terms.recurrence else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ let metadata = try JSONSerialization.jsonObject(with: Data(terms.metadata.exportText().utf8))
+ let uuid = "00000000-0000-0000-0000-000000000000"
+ let wire: [String: Any] = [
+ "version": 1,
+ "kind": "paykit.payment_request",
+ "event_id": uuid,
+ "payment_request_id": uuid,
+ "request": [
+ "amount": ["value": terms.amount.value, "asset": terms.amount.asset],
+ "payment_reference": terms.paymentReference.exportText(),
+ "proposal_expires_at": terms.proposalExpiresAt as Any? ?? NSNull(),
+ "recurrence": [
+ "every": recurrence.every,
+ "unit": recurrence.unit,
+ "starts_at": recurrence.startsAt,
+ "anchor": recurrence.anchor,
+ "ends_at": recurrence.endsAt as Any? ?? NSNull(),
+ ],
+ "accepted_payment_endpoint_identifiers": terms.acceptedPaymentEndpointIdentifiers,
+ "metadata": metadata,
+ ],
+ ]
+ return try JSONSerialization.data(withJSONObject: wire, options: [.withoutEscapingSlashes]).count
+ }
+}
diff --git a/Bitkit/Services/PubkyService.swift b/Bitkit/Services/PubkyService.swift
index 8b3bbaa8a..c8ac03ddb 100644
--- a/Bitkit/Services/PubkyService.swift
+++ b/Bitkit/Services/PubkyService.swift
@@ -576,9 +576,15 @@ actor PaykitSdkService {
}
}
- func uploadProfileAvatar(bytes: Data, contentType: String) async throws -> String {
+ func uploadProfileAvatar(bytes: Data, contentType: String, expectedIdentity: String? = nil) async throws -> String {
let record = try await withStateRevisionTracking { sdk in
- try await sdk.uploadProfileAvatar(bytes: bytes, contentType: contentType)
+ if let expectedIdentity {
+ guard let identity = try await sdk.identityStatus(),
+ identity.liveSessionAvailable,
+ PubkyPublicKeyFormat.matches(identity.publicKey, expectedIdentity)
+ else { throw PaykitPaymentRequestError.requestUnavailable }
+ }
+ return try await sdk.uploadProfileAvatar(bytes: bytes, contentType: contentType)
}
return record.uri
}
diff --git a/Bitkit/Styles/TextStyle.swift b/Bitkit/Styles/TextStyle.swift
index 403564a8f..574999f72 100644
--- a/Bitkit/Styles/TextStyle.swift
+++ b/Bitkit/Styles/TextStyle.swift
@@ -12,6 +12,7 @@ struct DisplayText: View {
var accentColor: Color = .brandAccent
var accentFont: ((CGFloat) -> Font)?
var accentAction: (() -> Void)?
+ var fillsWidth: Bool
private let fontSize: CGFloat = 44
@@ -20,13 +21,15 @@ struct DisplayText: View {
textColor: Color = .textPrimary,
accentColor: Color = .brandAccent,
accentFont: ((CGFloat) -> Font)? = nil,
- accentAction: (() -> Void)? = nil
+ accentAction: (() -> Void)? = nil,
+ fillsWidth: Bool = true
) {
self.text = text
self.textColor = textColor
self.accentColor = accentColor
self.accentFont = accentFont
self.accentAction = accentAction
+ self.fillsWidth = fillsWidth
}
var body: some View {
@@ -42,7 +45,7 @@ struct DisplayText: View {
.environment(\._lineHeightMultiple, 0.83)
.textCase(.uppercase)
.padding(.bottom, -9)
- .frame(maxWidth: .infinity, alignment: .leading)
+ .frame(maxWidth: fillsWidth ? .infinity : nil, alignment: .leading)
.dynamicTypeSize(...DynamicTypeSize.xxLarge)
}
}
diff --git a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
index c3fa47c91..5cbc7bfb0 100644
--- a/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
+++ b/Bitkit/Views/PaymentRequests/CreatePaymentRequestView.swift
@@ -123,10 +123,32 @@ struct RequestOrPayView: View {
}
struct PaymentRequestRecipientView: View {
+ let onSelect: (PaykitPaymentRequestTarget) -> Void
+
+ var body: some View {
+ PaykitRecipientPicker(
+ selectedTarget: nil,
+ onSelect: onSelect,
+ accessibilityIdentifier: "PaymentRequestRecipient",
+ testIdentifierPrefix: "PaymentRequest"
+ ) {
+ SheetHeader(title: t("wallet__payment_request_choose_recipient"), showBackButton: true)
+ } footer: {
+ EmptyView()
+ }
+ }
+}
+
+struct PaykitRecipientPicker: View {
@EnvironmentObject private var contactsManager: ContactsManager
@Environment(PaykitPaymentRequestManager.self) private var paymentRequests
+ let selectedTarget: PaykitPaymentRequestTarget?
let onSelect: (PaykitPaymentRequestTarget) -> Void
+ let accessibilityIdentifier: String
+ let testIdentifierPrefix: String
+ @ViewBuilder let header: () -> Header
+ @ViewBuilder let footer: () -> Footer
@State private var recipientQuery = ""
@@ -145,26 +167,29 @@ struct PaymentRequestRecipientView: View {
var body: some View {
VStack(spacing: 0) {
- SheetHeader(title: t("wallet__payment_request_choose_recipient"), showBackButton: true)
+ header()
recipientInput
- .padding(.bottom, 16)
+ .padding(.bottom, 32)
ScrollView(showsIndicators: false) {
LazyVStack(alignment: .leading, spacing: 0) {
CaptionMText(t("contacts__nav_title").localizedUppercase, textColor: .white64)
- .padding(.vertical, 16)
+ .padding(.bottom, 16)
CustomDivider()
ForEach(recipientTargets) { target in
recipientRow(target)
}
}
}
+
+ footer()
}
.padding(.horizontal, 16)
.sheetBackground()
.navigationBarHidden(true)
- .accessibilityIdentifier("PaymentRequestRecipient")
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier(accessibilityIdentifier)
}
private var recipientInput: some View {
@@ -176,9 +201,10 @@ struct PaymentRequestRecipientView: View {
t("wallet__payment_request_enter_pubky"),
text: $recipientQuery,
backgroundColor: .clear,
- font: .custom(Fonts.regular, size: 17),
- testIdentifier: "PaymentRequestRecipientFilter"
+ testIdentifier: "\(testIdentifierPrefix)RecipientFilter",
+ contentPadding: 0
)
+ .frame(minHeight: 20)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.asciiCapable)
@@ -194,14 +220,15 @@ struct PaymentRequestRecipientView: View {
.scaledToFit()
.frame(width: 16, height: 16)
.accessibilityHidden(true)
- BodyMSBText(t("common__paste"))
+ CaptionBText(t("common__paste"), textColor: .textPrimary)
}
+ .padding(.horizontal, 8)
}
.buttonStyle(.plain)
- .accessibilityIdentifier("PaymentRequestRecipientPaste")
+ .accessibilityIdentifier("\(testIdentifierPrefix)RecipientPaste")
}
.padding(16)
- .background(Color.white08)
+ .background(Color.white10)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
}
@@ -217,10 +244,10 @@ struct PaymentRequestRecipientView: View {
@ViewBuilder
private func recipientRow(_ target: PaykitPaymentRequestTarget) -> some View {
if let contact = contact(for: target) {
- PubkyContactRow(contact: contact, verticalPadding: 20) {
+ PubkyContactRow(contact: contact, verticalPadding: 24, isSelected: selectedTarget == target) {
onSelect(target)
}
- .accessibilityIdentifier("PaymentRequestContact-\(contact.publicKey)")
+ .accessibilityIdentifier("\(testIdentifierPrefix)Contact\(contact.publicKey)")
} else {
Button {
onSelect(target)
@@ -229,12 +256,19 @@ struct PaymentRequestRecipientView: View {
ContactAvatarLetter(source: target.publicKey, size: 48)
BodyMSBText(PubkyPublicKeyFormat.displayTruncated(target.publicKey))
Spacer()
+ if selectedTarget == target {
+ Image("check-mark")
+ .resizable()
+ .frame(width: 24, height: 24)
+ .foregroundColor(.brandAccent)
+ .accessibilityHidden(true)
+ }
}
- .padding(.vertical, 20)
+ .padding(.vertical, 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
- .accessibilityIdentifier("PaymentRequestTarget-\(target.id)")
+ .accessibilityIdentifier("\(testIdentifierPrefix)Target-\(target.id)")
CustomDivider()
}
}
@@ -245,8 +279,10 @@ struct PaymentRequestAmountView: View {
@EnvironmentObject private var currency: CurrencyViewModel
let initialDraft: PaykitPaymentRequestDraft
- let target: PaykitPaymentRequestTarget
+ let target: PaykitPaymentRequestTarget?
let onContinue: (PaykitPaymentRequestDraft) -> Void
+ var onBack: (() -> Void)?
+ var testIdentifierPrefix = "PaymentRequest"
@State private var amountViewModel = AmountInputViewModel()
@@ -255,14 +291,15 @@ struct PaymentRequestAmountView: View {
SheetHeader(
title: t("wallet__payment_request_amount"),
showBackButton: true,
- action: AnyView(targetAvatar)
+ action: target == nil ? nil : AnyView(targetAvatar),
+ onBack: onBack
)
NumberPadTextField(
viewModel: amountViewModel,
showEditButton: false,
isFocused: true,
- testIdentifier: "PaymentRequestAmountField"
+ testIdentifier: "\(testIdentifierPrefix)AmountField"
)
Spacer()
@@ -283,7 +320,7 @@ struct PaymentRequestAmountView: View {
)
)
}
- .accessibilityIdentifier("PaymentRequestAmountContinue")
+ .accessibilityIdentifier("\(testIdentifierPrefix)AmountContinue")
}
.padding(.horizontal, 16)
.sheetBackground()
@@ -295,10 +332,12 @@ struct PaymentRequestAmountView: View {
@ViewBuilder
private var targetAvatar: some View {
- if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) }) {
- PubkyContactAvatar(contact: contact, size: 24)
- } else {
- ContactAvatarLetter(source: target.publicKey, size: 24)
+ if let target {
+ if let contact = contactsManager.contacts.first(where: { PubkyPublicKeyFormat.matches($0.publicKey, target.publicKey) }) {
+ PubkyContactAvatar(contact: contact, size: 24)
+ } else {
+ ContactAvatarLetter(source: target.publicKey, size: 24)
+ }
}
}
}
diff --git a/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift
new file mode 100644
index 000000000..6284a3cc1
--- /dev/null
+++ b/Bitkit/Views/Subscriptions/CreateSubscriptionView.swift
@@ -0,0 +1,373 @@
+import PhotosUI
+import SwiftUI
+
+struct CreateSubscriptionView: View {
+ @EnvironmentObject private var app: AppViewModel
+
+ @Binding var draft: PaykitSubscriptionDraft
+ let onEditAmount: () -> Void
+ let onChooseRecipient: () -> Void
+
+ @State private var selectedPhotoItem: PhotosPickerItem?
+ @FocusState private var isNameFocused: Bool
+ @FocusState private var isDescriptionFocused: Bool
+
+ private var isLoadingIcon: Bool {
+ selectedPhotoItem != nil
+ }
+
+ private var iconImage: UIImage? {
+ draft.iconData.flatMap { UIImage(data: $0) }
+ }
+
+ var body: some View {
+ VStack(spacing: 0) {
+ SheetHeader(title: t("subscriptions__create_subscription"))
+
+ ScrollViewReader { proxy in
+ GeometryReader { geometry in
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 24) {
+ amount
+ frequency
+ name
+ description
+ .id("SubscriptionDescription")
+ customIcon
+ }
+ .padding(.bottom, 24)
+ }
+ .onChange(of: isDescriptionFocused ? geometry.size.height : nil) { _, focusedHeight in
+ if focusedHeight != nil {
+ withAnimation {
+ proxy.scrollTo("SubscriptionDescription", anchor: .bottom)
+ }
+ }
+ }
+ }
+ }
+
+ CustomButton(
+ title: t("subscriptions__choose_recipient"),
+ isDisabled: draft.amountSats == 0 ||
+ draft.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ||
+ isLoadingIcon
+ ) {
+ onChooseRecipient()
+ }
+ .buttonBottomPadding(isFocused: isNameFocused || isDescriptionFocused)
+ .accessibilityIdentifier("SubscriptionChooseRecipient")
+ }
+ .padding(.horizontal, 16)
+ .sheetBackground()
+ .navigationBarHidden(true)
+ .onChange(of: draft.name) { _, value in
+ if value.count > 256 {
+ draft.name = String(value.prefix(256))
+ }
+ }
+ .onChange(of: draft.description) { _, value in
+ if value.count > 1024 {
+ draft.description = String(value.prefix(1024))
+ }
+ }
+ .task(id: selectedPhotoItem) {
+ await loadIcon(selectedPhotoItem)
+ }
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("CreateSubscription")
+ }
+
+ private var amount: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ CaptionMText(t("wallet__payment_request_amount").localizedUppercase, textColor: .white64)
+ Button(action: onEditAmount) {
+ HStack(spacing: 8) {
+ MoneyText(
+ sats: Int(clamping: draft.amountSats),
+ unitType: .primary,
+ size: .display,
+ symbol: true,
+ color: .textPrimary,
+ symbolColor: .textSecondary,
+ fillsWidth: false
+ )
+ .lineLimit(1)
+ .minimumScaleFactor(0.5)
+ Image("pencil")
+ .resizable()
+ .frame(width: 24, height: 24)
+ .foregroundColor(.textPrimary)
+ }
+ }
+ .buttonStyle(.plain)
+ .accessibilityIdentifier("SubscriptionEditAmount")
+ }
+ }
+
+ private var frequency: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("subscriptions__frequency").localizedUppercase, textColor: .white64)
+ SegmentedControl(
+ selectedTab: Binding(
+ get: { SubscriptionFrequencyOption.allCases.first { $0.unit == draft.frequency } ?? .month },
+ set: { draft.frequency = $0.unit }
+ ),
+ tabItems: SubscriptionFrequencyOption.allCases.map {
+ TabItem($0, accessibilityIdentifier: "Tab-\($0.rawValue)")
+ },
+ inactiveColor: .white.opacity(0.5)
+ )
+ }
+ }
+
+ private var name: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("subscriptions__name").localizedUppercase, textColor: .white64)
+ TextField(
+ t("subscriptions__name_placeholder"),
+ text: $draft.name,
+ testIdentifier: "SubscriptionName"
+ )
+ .focused($isNameFocused)
+ }
+ }
+
+ private var description: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("subscriptions__description").localizedUppercase, textColor: .white64)
+ NoteTextEditor(
+ text: $draft.description,
+ placeholder: t("subscriptions__description_placeholder"),
+ testIdentifier: "SubscriptionDescription",
+ isFocused: $isDescriptionFocused,
+ minHeight: 60,
+ maxHeight: 60,
+ backgroundColor: .white10
+ )
+ }
+ }
+
+ private var customIcon: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ CaptionMText(t("subscriptions__custom_icon").localizedUppercase, textColor: .white64)
+ PhotosPicker(selection: $selectedPhotoItem, matching: .images) {
+ HStack(spacing: 16) {
+ Group {
+ if let iconImage {
+ Image(uiImage: iconImage)
+ .resizable()
+ .scaledToFill()
+ } else {
+ SubscriptionDefaultIcon(size: 40)
+ }
+ }
+ .frame(width: 40, height: 40)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+
+ CaptionBText(t("subscriptions__custom_icon_description"), textColor: .white64)
+ Spacer()
+ }
+ .padding(16)
+ .background(Color.white10)
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+ .disabled(isLoadingIcon)
+ .accessibilityIdentifier("SubscriptionIconPicker")
+ }
+ }
+
+ private func loadIcon(_ item: PhotosPickerItem?) async {
+ guard let item else { return }
+ defer { if selectedPhotoItem == item { selectedPhotoItem = nil } }
+ do {
+ guard let data = try await item.loadTransferable(type: Data.self) else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ try Task.checkCancellation()
+ let compressed = try PaykitPaymentRequestService.compressedSubscriptionIcon(data)
+ guard selectedPhotoItem == item else { return }
+ draft.iconData = compressed
+ } catch is CancellationError {
+ return
+ } catch {
+ app.toast(type: .error, title: t("common__error"), description: t("subscriptions__icon_error"))
+ }
+ }
+}
+
+struct SubscriptionAmountView: View {
+ let initialAmountSats: UInt64
+ let onBack: () -> Void
+ let onContinue: (UInt64) -> Void
+
+ var body: some View {
+ PaymentRequestAmountView(
+ initialDraft: PaykitPaymentRequestDraft(amountSats: initialAmountSats, note: "", expiresAt: .distantFuture),
+ target: nil,
+ onContinue: { onContinue($0.amountSats) },
+ onBack: onBack,
+ testIdentifierPrefix: "PaymentRequest"
+ )
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("SubscriptionAmount")
+ }
+}
+
+struct SubscriptionRecipientView: View {
+ @EnvironmentObject private var app: AppViewModel
+ @Environment(PaykitPaymentRequestManager.self) private var paymentRequests
+
+ @Binding var draft: PaykitSubscriptionDraft
+ @Binding var selectedTarget: PaykitPaymentRequestTarget?
+ let onBack: () -> Void
+ let onSent: (PaykitSubscription) -> Void
+
+ var body: some View {
+ PaykitRecipientPicker(
+ selectedTarget: selectedTarget,
+ onSelect: { selectedTarget = $0 },
+ accessibilityIdentifier: "SubscriptionRecipient",
+ testIdentifierPrefix: "Subscription"
+ ) {
+ SheetHeader(
+ title: t("subscriptions__choose_recipient"),
+ showBackButton: true,
+ action: AnyView(expirationMenu),
+ onBack: onBack
+ )
+ } footer: {
+ CustomButton(
+ title: t("subscriptions__propose_subscription"),
+ isDisabled: selectedTarget == nil,
+ isLoading: paymentRequests.isCreatingRequest
+ ) {
+ await propose()
+ }
+ .padding(.bottom, 16)
+ .accessibilityIdentifier("SubscriptionPropose")
+ }
+ .interactiveDismissDisabled(paymentRequests.isCreatingRequest)
+ }
+
+ private var expirationMenu: some View {
+ Menu {
+ ForEach(PaymentRequestExpiration.allCases) { expiration in
+ Button(expiration.description) {
+ draft.expiresAt = expiration.date(from: Date())
+ }
+ }
+ } label: {
+ Image("timer-outline")
+ .resizable()
+ .frame(width: 18, height: 21)
+ .frame(width: 24, height: 24, alignment: .top)
+ .foregroundColor(.textPrimary)
+ }
+ .accessibilityLabel(t("wallet__payment_request_expires"))
+ .accessibilityIdentifier("SubscriptionExpiration")
+ }
+
+ private func propose() async {
+ guard let selectedTarget else { return }
+ do {
+ let subscription = try await paymentRequests.proposeSubscription(draft, to: selectedTarget)
+ guard paymentRequests.subscriptions.contains(where: { $0.id == subscription.id }) else { return }
+ onSent(subscription)
+ } catch {
+ app.toast(error)
+ }
+ }
+}
+
+struct SubscriptionProposalSentView: View {
+ @EnvironmentObject private var contactsManager: ContactsManager
+ @EnvironmentObject private var sheets: SheetViewModel
+
+ let subscription: PaykitSubscription
+
+ private var contact: PubkyContact? {
+ contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, subscription.counterparty) }
+ }
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 0) {
+ SheetHeader(title: t(subscription.deliveryStatus == .sent
+ ? "wallet__payment_request_sent_title"
+ : "subscriptions__proposal_queued_title"))
+ GeometryReader { geometry in
+ ScrollView(showsIndicators: false) {
+ VStack(alignment: .leading, spacing: 0) {
+ Image("check")
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 256, height: 256)
+ .frame(maxWidth: .infinity)
+ .accessibilityHidden(true)
+ DisplayText(t(subscription.deliveryStatus == .sent
+ ? "subscriptions__proposal_sent_headline"
+ : "subscriptions__proposal_queued_headline"), accentColor: .purpleAccent)
+ .padding(.bottom, 8)
+ BodyMText(
+ subscription.deliveryStatus == .sent
+ ? t("subscriptions__proposal_sent_description")
+ : t("subscriptions__proposal_queued_description"),
+ textColor: .white64
+ )
+ .padding(.bottom, 16)
+ if let contact {
+ PubkyContactRow(contact: contact, verticalPadding: 16, showsDivider: false) {}
+ .padding(.horizontal, 16)
+ .background(Color.gray6)
+ .clipShape(RoundedRectangle(cornerRadius: 16))
+ .allowsHitTesting(false)
+ }
+ SubscriptionRow(
+ subscription: subscription,
+ now: Date(),
+ subtitle: subscription.recurrence.subscriptionFrequencyLabel
+ )
+ .padding(.top, 16)
+ Spacer().frame(height: 24)
+ }
+ .frame(minHeight: geometry.size.height, alignment: .bottom)
+ }
+ }
+ CustomButton(title: t("common__ok")) {
+ sheets.hideSheet(reason: "Subscription proposal created")
+ }
+ .padding(.bottom, 16)
+ }
+ .padding(.horizontal, 16)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ .sheetBackground()
+ .navigationBarHidden(true)
+ .allowSwipeBack(false)
+ .accessibilityIdentifier("SubscriptionProposalSent")
+ }
+}
+
+private enum SubscriptionFrequencyOption: String, CaseIterable, CustomStringConvertible {
+ case day
+ case week
+ case month
+ case year
+
+ var unit: PaykitSubscriptionRecurrence.Unit {
+ switch self {
+ case .day: .day
+ case .week: .week
+ case .month: .month
+ case .year: .year
+ }
+ }
+
+ var description: String {
+ switch self {
+ case .day: t("subscriptions__daily")
+ case .week: t("subscriptions__weekly")
+ case .month: t("subscriptions__monthly")
+ case .year: t("subscriptions__yearly")
+ }
+ }
+}
diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift
index cdafd057c..2b77f7360 100644
--- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift
+++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift
@@ -3,6 +3,10 @@ import SwiftUI
struct SubscriptionSheetItem: SheetItem {
enum Route: Hashable {
+ case create
+ case createAmount
+ case createRecipient
+ case proposalSent(PaykitSubscription)
case review(PaykitSubscription)
case success
case details(PaykitSubscription)
@@ -42,21 +46,23 @@ struct SubscriptionsView: View {
}
private var proposals: [PaykitSubscription] {
- paymentRequests.subscriptions.filter { $0.isProposalVisible(at: now) }
+ paymentRequests.subscriptions.filter { $0.isPayer && $0.isProposalVisible(at: now) }
}
private var active: [PaykitSubscription] {
- paymentRequests.subscriptions.filter { $0.isActive(at: now) }
+ paymentRequests.subscriptions.filter { $0.isPayer && $0.isActive(at: now) }
}
private var expired: [PaykitSubscription] {
- paymentRequests.subscriptions.filter {
- $0.isExpired(at: now) && $0.wasAccepted
- }
+ paymentRequests.subscriptions.filter { $0.isExpiredVisible(at: now) }
+ }
+
+ private var created: [PaykitSubscription] {
+ paymentRequests.subscriptions.filter { $0.isCreatedVisible(at: now) }
}
private var hasVisibleSubscriptions: Bool {
- !proposals.isEmpty || !active.isEmpty || !expired.isEmpty
+ !proposals.isEmpty || !active.isEmpty || !expired.isEmpty || !created.isEmpty
}
var body: some View {
@@ -67,7 +73,8 @@ struct SubscriptionsView: View {
tabItems: [
TabItem(.overview),
TabItem(.payments, badge: paymentRequests.pendingRequests.count),
- ]
+ ],
+ inactiveColor: .white.opacity(0.5)
)
if selectedTab == .payments {
@@ -81,10 +88,22 @@ struct SubscriptionsView: View {
section(t("subscriptions__proposals"), subscriptions: proposals)
section(t("subscriptions__active"), subscriptions: active)
section(t("subscriptions__expired"), subscriptions: expired)
+ section(t("subscriptions__created"), subscriptions: created)
}
.padding(.top, 32)
- .padding(.bottom, 120)
+ .padding(.bottom, 32)
+ }
+ }
+
+ if selectedTab == .overview {
+ CustomButton(
+ title: t("subscriptions__create"),
+ variant: .secondary
+ ) {
+ sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .create))
}
+ .padding(.bottom, 16)
+ .accessibilityIdentifier("SubscriptionCreate")
}
}
.padding(.horizontal, 16)
@@ -117,21 +136,21 @@ struct SubscriptionsView: View {
VStack(alignment: .leading, spacing: 0) {
Spacer()
- Image("subscription-clock")
+ Image("subscription-intro-clock")
.resizable()
.aspectRatio(contentMode: .fit)
.frame(width: 256, height: 256)
.frame(maxWidth: .infinity)
.accessibilityHidden(true)
- Spacer()
+ Spacer().frame(height: 32)
DisplayText(t("subscriptions__empty_headline"), accentColor: .purpleAccent)
- Spacer().frame(height: 12)
+ Spacer().frame(height: 8)
BodyMText(t("subscriptions__empty_description"), textColor: .white64)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .padding(.bottom, 24)
+ .padding(.bottom, 32)
}
private var metrics: some View {
@@ -141,17 +160,28 @@ struct SubscriptionsView: View {
sats: monthlyCostSats,
unitType: .primary,
size: .bodyMSB,
- prefix: "",
+ symbol: true,
color: .textPrimary,
symbolColor: .textSecondary
)
+ .lineLimit(1)
+ .minimumScaleFactor(0.5)
}
+ .frame(maxWidth: .infinity, alignment: .leading)
Rectangle()
.fill(Color.white16)
.frame(width: 1, height: 50)
SubscriptionMetric(title: t("subscriptions__active"), icon: "arrows-clockwise") {
BodyMSBText("\(active.count)")
}
+ .fixedSize(horizontal: true, vertical: false)
+ Rectangle()
+ .fill(Color.white16)
+ .frame(width: 1, height: 50)
+ SubscriptionMetric(title: t("subscriptions__created"), icon: "asterisk") {
+ BodyMSBText("\(created.count)")
+ }
+ .fixedSize(horizontal: true, vertical: false)
}
}
@@ -162,7 +192,7 @@ struct SubscriptionsView: View {
CaptionMText(title.localizedUppercase, textColor: .white64)
ForEach(subscriptions) { subscription in
Button {
- if subscription.isProposalVisible(at: now) {
+ if subscription.isPayer && subscription.isProposalVisible(at: now) {
paymentRequests.requestSubscriptionPresentation(subscription)
sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .review(subscription)))
} else {
@@ -194,7 +224,7 @@ func subscriptionMonthlyCostSats(subscriptions: [PaykitSubscription], now: Date)
}
}
let maximum = NSDecimalNumber(value: Int.max)
- return subscriptions.filter { $0.isActive(at: now) }.reduce(into: 0) { total, subscription in
+ return subscriptions.filter { $0.isPayer && $0.isActive(at: now) }.reduce(into: 0) { total, subscription in
var monthlyCost = Decimal(subscription.amountSats) * annualPeriods(subscription.recurrence.unit)
/ Decimal(subscription.recurrence.every) / 12
var roundedMonthlyCost = Decimal()
@@ -222,7 +252,7 @@ private struct SubscriptionMetric: View {
@ViewBuilder let content: () -> Content
var body: some View {
- VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 8) {
CaptionMText(title.localizedUppercase, textColor: .white64)
HStack(spacing: 8) {
Image(icon)
@@ -232,28 +262,28 @@ private struct SubscriptionMetric: View {
content()
}
}
- .frame(maxWidth: .infinity, alignment: .leading)
}
}
struct SubscriptionRow: View {
let subscription: PaykitSubscription
let now: Date
+ var subtitle: String?
var body: some View {
HStack(spacing: 16) {
SubscriptionAvatar(subscription: subscription, size: 40)
- VStack(alignment: .leading, spacing: 4) {
+ VStack(alignment: .leading, spacing: 0) {
BodyMSBText(subscription.note ?? t("subscriptions__subscription"))
.lineLimit(1)
- CaptionText(subscription.rowSubtitle(at: now), textColor: .white64)
+ CaptionBText(subtitle ?? subscription.rowSubtitle(at: now), textColor: .white64)
.lineLimit(1)
}
Spacer(minLength: 8)
- MoneyCell(sats: Int(clamping: subscription.amountSats), prefix: "")
+ MoneyCell(sats: Int(clamping: subscription.amountSats), prefix: "", symbol: true)
}
.padding(16)
.background(Color.gray6)
@@ -277,7 +307,11 @@ struct SubscriptionAvatar: View {
}
var body: some View {
- if let contact {
+ if let iconURI = subscription.metadata.iconURI {
+ PubkyImage(uri: iconURI, size: size, cornerRadius: size / 5)
+ } else if subscription.isCreatedByUser {
+ SubscriptionDefaultIcon(size: size)
+ } else if let contact {
PubkyContactAvatar(contact: contact, size: size)
} else {
ContactAvatarLetter(source: subscription.counterparty, size: size)
@@ -285,6 +319,20 @@ struct SubscriptionAvatar: View {
}
}
+struct SubscriptionDefaultIcon: View {
+ let size: CGFloat
+
+ var body: some View {
+ Image("subscription-default-icon")
+ .resizable()
+ .scaledToFit()
+ .padding(size / 8)
+ .frame(width: size, height: size)
+ .background(Color.white)
+ .clipShape(RoundedRectangle(cornerRadius: size / 5))
+ }
+}
+
struct SubscriptionDetailView: View {
@EnvironmentObject private var sheets: SheetViewModel
@Environment(PaykitPaymentRequestManager.self) private var paymentRequests
@@ -358,7 +406,7 @@ struct SubscriptionDetailView: View {
LabeledDetailCell(title: t("subscriptions__frequency"), value: subscription.recurrence.frequencyValue, icon: "arrows-clockwise")
LabeledDetailCell(
title: t("subscriptions__status"),
- value: subscription.isActive(at: now) ? t("subscriptions__active") : t("subscriptions__expired"),
+ value: subscription.statusLabel(at: now),
icon: "check-mark"
)
if subscription.isActive(at: now) || subscription.recurrence.endsAt != nil {
@@ -368,23 +416,35 @@ struct SubscriptionDetailView: View {
icon: "calendar"
)
}
+ if subscription.isCreatedByUser {
+ LabeledDetailCell(
+ title: t("subscriptions__subscribers"),
+ value: subscription.wasAccepted ? "1" : "0",
+ icon: "users"
+ )
+ LabeledDetailCell(
+ title: t("subscriptions__payments"),
+ value: "\(subscription.payments.count)",
+ icon: "arrow-down"
+ )
+ }
}
}
@ViewBuilder
private func footer(_ subscription: PaykitSubscription) -> some View {
let hasMoreInfo = subscription.metadata.description != nil || !subscription.metadata.benefits.isEmpty
- let canCancel = subscription.isActive(at: now) && subscription.recurrence.endsAt == nil
+ let canCancel = subscription.canCancel(at: now) && subscription.recurrence.endsAt == nil
if hasMoreInfo || canCancel {
HStack(spacing: 16) {
- if hasMoreInfo {
+ if hasMoreInfo && !subscription.isCreatedByUser {
CustomButton(title: t("subscriptions__more_info"), variant: .secondary) {
sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .details(subscription)))
}
}
if canCancel {
CustomButton(
- title: t("subscriptions__cancel"),
+ title: subscription.isCreatedByUser ? t("common__delete") : t("subscriptions__cancel"),
icon: Image("x-mark").resizable().frame(width: 16, height: 16)
) {
sheets.showSheet(.subscription, data: SubscriptionSheetItem(route: .cancel(subscription)))
@@ -397,9 +457,9 @@ struct SubscriptionDetailView: View {
@ViewBuilder
private func payments(_ subscription: PaykitSubscription) -> some View {
- let payments = paymentRequests.historyRequests.filter {
- $0.belongs(to: subscription)
- }
+ let payments = subscription.isCreatedByUser
+ ? subscription.receivedPaymentRequests()
+ : paymentRequests.historyRequests.filter { $0.belongs(to: subscription) }
if !payments.isEmpty {
VStack(alignment: .leading, spacing: 12) {
CaptionMText(t("subscriptions__payments").localizedUppercase, textColor: .white64)
@@ -408,7 +468,7 @@ struct SubscriptionDetailView: View {
request: payment,
subtitleOverride: payment.createdAt.map(Self.dateFormatter.string),
isHighlighted: false,
- paymentDirection: .incoming
+ paymentDirection: payment.direction
)
}
}
@@ -460,6 +520,8 @@ struct SubscriptionSheet: View {
@State private var previousRoute: SubscriptionSheetItem.Route?
@State private var now = Date()
@State private var isAccepting = false
+ @State private var creationDraft = PaykitSubscriptionDraft.empty
+ @State private var selectedCreationTarget: PaykitPaymentRequestTarget?
init(config: SubscriptionSheetItem) {
self.config = config
@@ -470,6 +532,30 @@ struct SubscriptionSheet: View {
var body: some View {
Sheet(id: .subscription, data: config) {
switch route {
+ case .create:
+ CreateSubscriptionView(
+ draft: $creationDraft,
+ onEditAmount: { route = .createAmount },
+ onChooseRecipient: { route = .createRecipient }
+ )
+ case .createAmount:
+ SubscriptionAmountView(
+ initialAmountSats: creationDraft.amountSats,
+ onBack: { route = .create },
+ onContinue: {
+ creationDraft.amountSats = $0
+ route = .create
+ }
+ )
+ case .createRecipient:
+ SubscriptionRecipientView(
+ draft: $creationDraft,
+ selectedTarget: $selectedCreationTarget,
+ onBack: { route = .create },
+ onSent: { route = .proposalSent($0) }
+ )
+ case let .proposalSent(subscription):
+ SubscriptionProposalSentView(subscription: subscription)
case let .review(subscription):
review(subscription)
case .success:
@@ -512,7 +598,7 @@ struct SubscriptionSheet: View {
}
now = Date()
}
- .interactiveDismissDisabled(isAccepting)
+ .interactiveDismissDisabled(isAccepting || paymentRequests.isCreatingRequest)
}
private func review(_ subscription: PaykitSubscription) -> some View {
@@ -520,7 +606,7 @@ struct SubscriptionSheet: View {
return VStack(spacing: 0) {
SheetHeader(title: t("subscriptions__review_and_subscribe"))
SubscriptionAmountHeader(subscription: subscription)
- SubscriptionProviderCard(subscription: subscription) {
+ SubscriptionProviderCard(subscription: subscription, showsCounterparty: true) {
guard !isAccepting else { return }
previousRoute = route
route = .details(subscription)
@@ -737,7 +823,11 @@ struct SubscriptionSheet: View {
private func cancel(_ subscription: PaykitSubscription) -> some View {
VStack(spacing: 0) {
- SheetHeader(title: t("subscriptions__cancel_subscription"))
+ SheetHeader(
+ title: subscription.isCreatedByUser
+ ? t("subscriptions__delete_subscription")
+ : t("subscriptions__cancel_subscription")
+ )
SubscriptionAmountHeader(subscription: subscription)
SubscriptionProviderCard(subscription: subscription, subtitle: subscription.rowSubtitle(at: now)) {
previousRoute = route
@@ -753,7 +843,9 @@ struct SubscriptionSheet: View {
Spacer()
SwipeButton(
- title: t("subscriptions__swipe_to_cancel"),
+ title: subscription.isCreatedByUser
+ ? t("subscriptions__swipe_to_delete")
+ : t("subscriptions__swipe_to_cancel"),
accentColor: .redAccent,
isLoading: paymentRequests.isProcessingSubscription
) {
@@ -821,10 +913,17 @@ private struct SubscriptionAmountHeader: View {
}
private struct SubscriptionProviderCard: View {
+ @EnvironmentObject private var contactsManager: ContactsManager
+
let subscription: PaykitSubscription
+ var showsCounterparty = false
var subtitle: String?
var action: (() -> Void)?
+ private var contact: PubkyContact? {
+ contactsManager.contacts.first { PubkyPublicKeyFormat.matches($0.publicKey, subscription.counterparty) }
+ }
+
var body: some View {
if let action {
Button(action: action) {
@@ -844,6 +943,15 @@ private struct SubscriptionProviderCard: View {
.lineLimit(1)
CaptionText(subtitle ?? subscription.recurrence.subscriptionFrequencyLabel, textColor: .white64)
.lineLimit(1)
+ if showsCounterparty {
+ if let contact {
+ CaptionText(contact.displayName, textColor: .white64)
+ .lineLimit(1)
+ }
+ CaptionText(PubkyPublicKeyFormat.displayTruncated(subscription.counterparty), textColor: .white64)
+ .lineLimit(1)
+ .accessibilityIdentifier("SubscriptionCounterparty")
+ }
}
Spacer()
if showsChevron {
@@ -910,7 +1018,27 @@ extension PaykitSubscriptionRecurrence {
}
private extension PaykitSubscription {
+ func statusLabel(at now: Date) -> String {
+ if isProposalVisible(at: now) {
+ return t("subscriptions__pending")
+ }
+ return isActive(at: now) ? t("subscriptions__active") : t("subscriptions__expired")
+ }
+
func rowSubtitle(at now: Date) -> String {
+ if isCreatedByUser {
+ if isProposalVisible(at: now) {
+ return deliveryStatus == .sent
+ ? t("subscriptions__proposal_sent_status")
+ : t("subscriptions__proposal_queued_status")
+ }
+ if isActive(at: now) {
+ let key = payments.count == 1
+ ? "subscriptions__created_summary_single_payment"
+ : "subscriptions__created_summary"
+ return t(key, variables: ["count": "\(payments.count)"])
+ }
+ }
if isProposalVisible(at: now) || !recurrence.unit.isSupported {
return recurrence.subscriptionFrequencyLabel
}
diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift
index 95be12342..389a5bb3a 100644
--- a/BitkitTests/PaykitPaymentRequestServiceTests.swift
+++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift
@@ -2,6 +2,7 @@
import BitkitCore
import Foundation
import Paykit
+import UIKit
import UserNotifications
import XCTest
@@ -319,6 +320,373 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
XCTAssertEqual(request.billingPeriod?.endsAt, try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z")))
}
+ func testRefreshKeepsCreatorSubscriptionWithoutGeneratingPayerPayment() async throws {
+ let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-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 record = try paymentRequestRecord(
+ id: "creator-recurring",
+ state: .activeRecurring,
+ role: .payee,
+ recurrence: recurrence,
+ metadata: #"{"note":"Creator plan","subscription":{"version":1,"description":"Monthly support","benefits":[],"icon_uri":"pubky://creator/icon"}}"#
+ )
+ let manager = paymentRequestManager(
+ sdk: PaymentRequestSdkMock(records: [record]),
+ clock: PaymentRequestTestClock(now)
+ )
+
+ await manager.refresh()
+
+ let subscription = try XCTUnwrap(manager.subscriptions.first)
+ XCTAssertTrue(subscription.isCreatedByUser)
+ XCTAssertEqual(subscription.note, "Creator plan")
+ XCTAssertEqual(subscription.metadata.description, "Monthly support")
+ XCTAssertEqual(subscription.metadata.iconURI, "pubky://creator/icon")
+ XCTAssertTrue(manager.pendingRequests.isEmpty)
+ XCTAssertTrue(manager.historyRequests.isEmpty)
+ }
+
+ func testCreatorPaymentsAggregateDuplicateProofEventsForOneBillingPeriod() throws {
+ let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z")
+ let first = try paymentProofRecord(
+ endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue,
+ kind: .lightning,
+ billingPeriod: period
+ )
+ var second = first
+ second.eventId = "850e8400-e29b-41d4-a716-446655440000"
+ var offSchedule = first
+ offSchedule.eventId = "950e8400-e29b-41d4-a716-446655440000"
+ offSchedule.billingPeriod = BillingPeriod(startsAt: period.startsAt, endsAt: "2027-02-02T08:00:00Z")
+ let record = try paymentRequestRecord(
+ state: .activeRecurring,
+ role: .payee,
+ recurrence: PaymentRequestRecurrence(
+ every: 1,
+ unit: "month",
+ startsAt: period.startsAt,
+ anchor: period.startsAt,
+ endsAt: nil
+ ),
+ paymentProofs: [first, second, offSchedule]
+ )
+ let subscription = try XCTUnwrap(PaykitSubscription(record: record))
+ XCTAssertEqual(subscription.payments.count, 1)
+ let received = subscription.receivedPaymentRequests()
+ XCTAssertEqual(received.count, 1)
+ XCTAssertEqual(received.first?.direction, .outgoing)
+ XCTAssertEqual(received.first?.lifecycleState, .proofSubmitted)
+ XCTAssertEqual(received.first?.paymentProofKind, .lightning)
+ }
+
+ func testExpiredCreatorSubscriptionsKeepPaidHistoryAccessible() async throws {
+ let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-15T08:00:00Z"))
+ let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z")
+ let proof = try paymentProofRecord(
+ endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue,
+ kind: .lightning,
+ billingPeriod: period
+ )
+ for state in [PaymentRequestLifecycleState.canceled, .activeRecurring] {
+ for hasPayments in [false, true] {
+ let record = try paymentRequestRecord(
+ state: state,
+ role: .payee,
+ recurrence: PaymentRequestRecurrence(
+ every: 1,
+ unit: "month",
+ startsAt: period.startsAt,
+ anchor: period.startsAt,
+ endsAt: state == .activeRecurring ? period.endsAt : nil
+ ),
+ paymentProofs: hasPayments ? [proof] : []
+ )
+ let manager = paymentRequestManager(sdk: PaymentRequestSdkMock(records: [record]), clock: PaymentRequestTestClock(now))
+ await manager.refresh()
+ let subscription = try XCTUnwrap(manager.subscriptions.first)
+ XCTAssertFalse(subscription.isCreatedVisible(at: now))
+ XCTAssertEqual(subscription.isExpiredVisible(at: now), hasPayments)
+ XCTAssertEqual(subscription.receivedPaymentRequests().count, hasPayments ? 1 : 0)
+ XCTAssertFalse(subscription.canCancel(at: now))
+ XCTAssertTrue(manager.pendingRequests.isEmpty)
+ }
+ }
+ }
+
+ func testFractionalBillingProofsRemainPaidForCreatorAndPayer() throws {
+ for fraction in ["123", "123456789", "999999999"] {
+ let first = BillingPeriod(
+ startsAt: "2027-01-15T08:00:00.\(fraction)Z",
+ endsAt: "2027-02-15T08:00:00.\(fraction)Z"
+ )
+ let second = BillingPeriod(
+ startsAt: first.endsAt,
+ endsAt: "2027-03-15T08:00:00.\(fraction)Z"
+ )
+ let proofs = try [first, second].map {
+ try paymentProofRecord(
+ endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue,
+ kind: .lightning,
+ billingPeriod: $0
+ )
+ }
+ for role in [PaymentRequestLocalRole.payee, .payer] {
+ let record = try paymentRequestRecord(
+ state: .activeRecurring,
+ role: role,
+ recurrence: PaymentRequestRecurrence(
+ every: 1,
+ unit: "month",
+ startsAt: first.startsAt,
+ anchor: first.startsAt,
+ endsAt: nil
+ ),
+ paymentProofs: proofs
+ )
+ let subscription = try XCTUnwrap(PaykitSubscription(record: record))
+ XCTAssertEqual(subscription.payments.count, 2)
+ let requests = if role == .payee {
+ subscription.receivedPaymentRequests()
+ } else {
+ try subscription.requests(
+ through: XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-16T08:00:00Z")),
+ acceptedAt: XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:01Z"))
+ )
+ }
+ XCTAssertEqual(requests.count, 2)
+ XCTAssertTrue(requests.allSatisfy { $0.lifecycleState == .proofSubmitted })
+ }
+ }
+ }
+
+ func testCreatorProposalBuildsRecurringTermsAndStaysQueuedUntilDelivery() async throws {
+ let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
+ let expiresAt = now.addingTimeInterval(60)
+ let publicKey = "pubky\(String(repeating: "y", count: 52))"
+ let sdk = PaymentRequestSdkMock(records: [])
+ await sdk.configureRecipients(
+ peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)],
+ receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]
+ )
+ try await sdk.setProposalResult(paymentRequestRecord(
+ id: "creator-proposal",
+ counterparty: publicKey,
+ counterpartyReceiverPath: PaykitReceiverPath.wallet,
+ role: .payee
+ ))
+ let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now))
+ await manager.refreshEligibleTargets(savedPublicKeys: [publicKey])
+
+ let subscription = try await manager.proposeSubscription(
+ PaykitSubscriptionDraft(
+ amountSats: 100_000,
+ name: " Monthly support ",
+ description: " Thank you ",
+ frequency: .month,
+ expiresAt: expiresAt,
+ iconData: nil
+ ),
+ to: XCTUnwrap(manager.eligibleTargets.first)
+ )
+
+ let snapshot = await sdk.snapshot()
+ let proposed = try XCTUnwrap(snapshot.proposedRequests.first)
+ XCTAssertEqual(proposed.amount, "0.001")
+ XCTAssertEqual(proposed.expiresAt, timestamp(expiresAt))
+ XCTAssertEqual(proposed.recurrence?.every, 1)
+ XCTAssertEqual(proposed.recurrence?.unit, "month")
+ XCTAssertEqual(proposed.recurrence?.startsAt, timestamp(now))
+ XCTAssertEqual(proposed.recurrence?.anchor, timestamp(now))
+ XCTAssertNil(proposed.recurrence?.endsAt)
+ let metadataData = try XCTUnwrap(proposed.metadata.data(using: .utf8))
+ let metadata = try XCTUnwrap(JSONSerialization.jsonObject(with: metadataData) as? [String: Any])
+ XCTAssertEqual(metadata["note"] as? String, "Monthly support")
+ let subscriptionMetadata = try XCTUnwrap(metadata["subscription"] as? [String: Any])
+ XCTAssertEqual(subscriptionMetadata["description"] as? String, "Thank you")
+ XCTAssertTrue(subscription.isCreatedByUser)
+ XCTAssertEqual(subscription.deliveryStatus, .queued)
+ XCTAssertEqual(manager.subscriptions, [subscription])
+ }
+
+ func testCreatedSubscriptionSurvivesContactChangesButNotSessionChanges() async throws {
+ let publicKey = "pubky\(String(repeating: "y", count: 52))"
+ let otherKey = "pubky\(String(repeating: "a", count: 52))"
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+
+ for change in ["reordered", "removed", "added", "cleared", "identity"] {
+ let sdk = PaymentRequestSdkMock(records: [])
+ await sdk.configureRecipients(
+ peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)],
+ receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]
+ )
+ try await sdk.setProposalResult(paymentRequestRecord(role: .payee))
+ let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now))
+ await manager.refreshEligibleTargets(savedPublicKeys: [publicKey, otherKey])
+ let target = try XCTUnwrap(manager.eligibleTargets.first)
+ await sdk.pauseNextProcess()
+ let proposal = Task {
+ try await manager.proposeSubscription(
+ PaykitSubscriptionDraft(
+ amountSats: 1000, name: "Support", description: "", frequency: .month,
+ expiresAt: now.addingTimeInterval(60), iconData: nil
+ ),
+ to: target
+ )
+ }
+ try await waitUntil { await sdk.processIsPaused() }
+ switch change {
+ case "reordered": await manager.refreshEligibleTargets(savedPublicKeys: [otherKey, publicKey])
+ case "removed": await manager.refreshEligibleTargets(savedPublicKeys: [])
+ case "added": await manager.refreshEligibleTargets(savedPublicKeys: [publicKey, otherKey, String(repeating: "o", count: 52)])
+ case "cleared": manager.clear()
+ default: manager.activate(identity: otherKey)
+ }
+ await sdk.resumeProcess()
+ let subscription = try await proposal.value
+ let snapshot = await sdk.snapshot()
+ XCTAssertEqual(snapshot.proposedRequests.count, 1, change)
+ XCTAssertEqual(manager.subscriptions, ["cleared", "identity"].contains(change) ? [] : [subscription], change)
+ XCTAssertFalse(manager.isCreatingRequest, change)
+ }
+ }
+
+ func testSubscriptionRevalidatesAfterIconUploadBeforeEnqueue() async throws {
+ let publicKey = "pubky\(String(repeating: "y", count: 52))"
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let image = UIGraphicsImageRenderer(size: CGSize(width: 4, height: 4)).image { context in
+ UIColor.purple.setFill()
+ context.fill(CGRect(x: 0, y: 0, width: 4, height: 4))
+ }
+ let iconData = try XCTUnwrap(image.pngData())
+ let optionKey = PublicPaykitService.lightningPaymentOptionEnabledKey
+ let previousOption = UserDefaults.standard.object(forKey: optionKey)
+ defer { UserDefaults.standard.set(previousOption, forKey: optionKey) }
+
+ for change in ["unchanged", "expired", "removed", "unlinked", "unsupported", "endpoint", "cleared"] {
+ UserDefaults.standard.set(true, forKey: optionKey)
+ let clock = PaymentRequestTestClock(now)
+ let sdk = PaymentRequestSdkMock(records: [])
+ await sdk.configureRecipients(
+ peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)],
+ receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]
+ )
+ try await sdk.setProposalResult(paymentRequestRecord(role: .payee))
+ let manager = paymentRequestManager(sdk: sdk, clock: clock)
+ await manager.refreshEligibleTargets(savedPublicKeys: [publicKey])
+ let target = try XCTUnwrap(manager.eligibleTargets.first)
+ await sdk.pauseNextUpload()
+ let proposal = Task {
+ try await manager.proposeSubscription(
+ PaykitSubscriptionDraft(
+ amountSats: 1000, name: "Support", description: "", frequency: .month,
+ expiresAt: now.addingTimeInterval(60), iconData: iconData
+ ),
+ to: target
+ )
+ }
+ try await waitUntil { await sdk.uploadIsPaused() }
+ switch change {
+ case "expired": clock.advance(by: 60)
+ case "removed": await manager.refreshEligibleTargets(savedPublicKeys: [])
+ case "unlinked": await sdk.configureRecipients(peers: [], receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]])
+ case "unsupported":
+ await sdk.configureRecipients(
+ peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)],
+ receiverPathsByPublicKey: [:]
+ )
+ case "endpoint": UserDefaults.standard.set(false, forKey: optionKey)
+ case "cleared": manager.clear()
+ default: break
+ }
+ await sdk.resumeUpload()
+ do {
+ _ = try await proposal.value
+ XCTAssertEqual(change, "unchanged")
+ } catch {
+ XCTAssertNotEqual(change, "unchanged")
+ XCTAssertEqual(error as? PaykitPaymentRequestError, change == "expired" ? .requestExpired : .requestUnavailable, change)
+ }
+ let snapshot = await sdk.snapshot()
+ XCTAssertEqual(snapshot.uploadCount, 1, change)
+ XCTAssertEqual(snapshot.proposedRequests.count, change == "unchanged" ? 1 : 0, change)
+ XCTAssertEqual(manager.subscriptions.count, change == "unchanged" ? 1 : 0, change)
+ XCTAssertFalse(manager.isCreatingRequest, change)
+ }
+ }
+
+ func testOversizedCreatorProposalIsRejectedBeforeIconUploadOrEnqueue() async throws {
+ let now = Date(timeIntervalSince1970: 1_800_000_000)
+ let publicKey = "pubky\(String(repeating: "y", count: 52))"
+ let sdk = PaymentRequestSdkMock(records: [])
+ await sdk.configureRecipients(
+ peers: [linkedPeer(counterparty: publicKey, path: PaykitReceiverPath.wallet, state: .linked)],
+ receiverPathsByPublicKey: [publicKey: [PaykitReceiverPath.wallet]]
+ )
+ let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now))
+ await manager.refreshEligibleTargets(savedPublicKeys: [publicKey])
+ let target = try XCTUnwrap(manager.eligibleTargets.first)
+
+ for iconData in [nil, Data([0, 1, 2])] as [Data?] {
+ do {
+ _ = try await manager.proposeSubscription(
+ PaykitSubscriptionDraft(
+ amountSats: 1000,
+ name: "Support",
+ description: String(repeating: "💜", count: 256),
+ frequency: .month,
+ expiresAt: now.addingTimeInterval(60),
+ iconData: iconData
+ ),
+ to: target
+ )
+ XCTFail("Oversized proposals must be rejected before external writes")
+ } catch {
+ XCTAssertEqual(error as? PaykitPaymentRequestError, .subscriptionTooLong)
+ }
+ }
+
+ let snapshot = await sdk.snapshot()
+ XCTAssertEqual(snapshot.uploadCount, 0)
+ XCTAssertTrue(snapshot.proposedRequests.isEmpty)
+ XCTAssertTrue(manager.subscriptions.isEmpty)
+ XCTAssertFalse(manager.isCreatingRequest)
+ }
+
+ func testCreatorPendingProposalCanBeDeletedButFixedEndProposalCannot() throws {
+ let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
+ let openEnded = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord(
+ role: .payee,
+ expiresAt: timestamp(now.addingTimeInterval(60)),
+ recurrence: PaymentRequestRecurrence(
+ every: 1,
+ unit: "month",
+ startsAt: timestamp(now),
+ anchor: timestamp(now),
+ endsAt: nil
+ )
+ )))
+ let fixedEnd = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord(
+ role: .payee,
+ expiresAt: timestamp(now.addingTimeInterval(60)),
+ recurrence: PaymentRequestRecurrence(
+ every: 1,
+ unit: "month",
+ startsAt: timestamp(now),
+ anchor: timestamp(now),
+ endsAt: timestamp(now.addingTimeInterval(3600))
+ )
+ )))
+
+ XCTAssertTrue(openEnded.canCancel(at: now))
+ XCTAssertFalse(fixedEnd.canCancel(at: now))
+ }
+
func testEndedSubscriptionKeepsItsUnpaidPeriodAvailable() async throws {
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
let recurrence = PaymentRequestRecurrence(
@@ -2370,6 +2738,10 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling {
private var receiverPathsByPublicKey: [String: [String]] = [:]
private var liveSessionAvailable = true
private var proposalResult: PaymentRequestRecord?
+ private var uploadCount = 0
+ private var shouldPauseNextUpload = false
+ private var isUploadPaused = false
+ private var uploadContinuation: CheckedContinuation?
private var processCallCount = 0
private var receiveCallCount = 0
private var processFailuresRemaining = 0
@@ -2455,6 +2827,33 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling {
receiverPathsByPublicKey[publicKey] ?? []
}
+ func pauseNextUpload() {
+ shouldPauseNextUpload = true
+ }
+
+ func uploadIsPaused() -> Bool {
+ isUploadPaused
+ }
+
+ func resumeUpload() {
+ uploadContinuation?.resume()
+ uploadContinuation = nil
+ }
+
+ func uploadProfileAvatar(bytes _: Data, contentType _: String, expectedIdentity: String?) async throws -> String {
+ if shouldPauseNextUpload {
+ shouldPauseNextUpload = false
+ isUploadPaused = true
+ await withCheckedContinuation { uploadContinuation = $0 }
+ isUploadPaused = false
+ }
+ guard expectedIdentity == nil || PubkyPublicKeyFormat.matches(activeIdentity, expectedIdentity ?? "") else {
+ throw PaykitPaymentRequestError.requestUnavailable
+ }
+ uploadCount += 1
+ return "pubky://\(activeIdentity)/pub/paykit/blobs/subscription-icon.jpg"
+ }
+
func proposePaymentRequest(
counterparty: String,
counterpartyReceiverPath: String,
@@ -2681,6 +3080,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling {
func snapshot() -> PaymentRequestSdkSnapshot {
PaymentRequestSdkSnapshot(
+ uploadCount: uploadCount,
processCallCount: processCallCount,
receiveCallCount: receiveCallCount,
acceptedRequests: acceptedRequests,
@@ -2706,6 +3106,7 @@ private actor PaymentRequestSdkMock: PaykitPaymentRequestSdkHandling {
}
private struct PaymentRequestSdkSnapshot {
+ let uploadCount: Int
let processCallCount: Int
let receiveCallCount: Int
let acceptedRequests: [PaymentRequestInvocation]
diff --git a/BitkitTests/PaykitSubscriptionProposalTests.swift b/BitkitTests/PaykitSubscriptionProposalTests.swift
new file mode 100644
index 000000000..8b2f549b1
--- /dev/null
+++ b/BitkitTests/PaykitSubscriptionProposalTests.swift
@@ -0,0 +1,64 @@
+@testable import Bitkit
+import Paykit
+import UIKit
+import XCTest
+
+final class PaykitSubscriptionProposalTests: XCTestCase {
+ func testTransportLimitIncludesEnvelopeEndpointsAndPublicIcon() throws {
+ let empty = try terms(description: "", iconURI: PaykitSubscriptionProposal.reservedIconURI)
+ let available = try PaykitSubscriptionProposal.maximumMessageBytes - (PaykitSubscriptionProposal.encodedSize(empty))
+ XCTAssertGreaterThan(available, 0)
+ let full = try terms(description: String(repeating: "a", count: available), iconURI: PaykitSubscriptionProposal.reservedIconURI)
+ XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(full), 1000)
+ XCTAssertNoThrow(try PaykitSubscriptionProposal.validate(full))
+ XCTAssertThrowsError(try PaykitSubscriptionProposal.validate(terms(
+ description: String(repeating: "a", count: available + 1),
+ iconURI: PaykitSubscriptionProposal.reservedIconURI
+ ))) { error in
+ XCTAssertEqual(error as? PaykitPaymentRequestError, .subscriptionTooLong)
+ }
+ }
+
+ func testSizeCountsUTF8AndJSONEscapingRatherThanCharacters() throws {
+ let base = try PaykitSubscriptionProposal.encodedSize(terms(description: ""))
+ XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "💜")), base + 4)
+ XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "\"\n\\")), base + 6)
+ XCTAssertEqual(try PaykitSubscriptionProposal.encodedSize(terms(description: "https://example.com")), base + 19)
+ }
+
+ @MainActor
+ func testSubscriptionIconIsDownsampledAndInvalidDataIsRejected() throws {
+ let format = UIGraphicsImageRendererFormat()
+ format.scale = 1
+ let image = UIGraphicsImageRenderer(size: CGSize(width: 2400, height: 1200), format: format).image { context in
+ UIColor.purple.setFill()
+ context.fill(CGRect(x: 0, y: 0, width: 2400, height: 1200))
+ }
+ let input = try XCTUnwrap(image.pngData())
+ let compressed = try PaykitPaymentRequestService.compressedSubscriptionIcon(input)
+ let output = try XCTUnwrap(UIImage(data: compressed)?.cgImage)
+ XCTAssertEqual(output.width, 400)
+ XCTAssertEqual(output.height, 200)
+ XCTAssertThrowsError(try PaykitPaymentRequestService.compressedSubscriptionIcon(Data([0, 1, 2])))
+ }
+
+ private func terms(description: String, iconURI: String? = nil) throws -> PaymentRequestTerms {
+ var subscription: [String: Any] = ["version": 1, "description": description, "benefits": []]
+ subscription["icon_uri"] = iconURI
+ let data = try JSONSerialization.data(withJSONObject: ["note": "Support", "subscription": subscription])
+ return try PaymentRequestTerms(
+ amount: PaymentRequestAmount(value: "0.001", asset: "btc"),
+ paymentReference: PaymentReference(text: "bitkit-00000000-0000-0000-0000-000000000000"),
+ proposalExpiresAt: "2027-01-22T08:00:00.000Z",
+ recurrence: PaymentRequestRecurrence(
+ every: 1,
+ unit: "month",
+ startsAt: "2027-01-15T08:00:00.000Z",
+ anchor: "2027-01-15T08:00:00.000Z",
+ endsAt: nil
+ ),
+ acceptedPaymentEndpointIdentifiers: ["bitcoin:regtest", "lightning:bolt11", "lightning:lnurl"],
+ metadata: PrivateJsonObject(text: String(decoding: data, as: UTF8.self))
+ )
+ }
+}
diff --git a/changelog.d/next/736.added.md b/changelog.d/next/736.added.md
new file mode 100644
index 000000000..6da1146f7
--- /dev/null
+++ b/changelog.d/next/736.added.md
@@ -0,0 +1 @@
+Bitkit can now propose recurring subscriptions to Paykit contacts.