From 53f73ce7df148d1dc475099b261f25e2484bdfe6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 23 Oct 2026 16:11:35 -0400 Subject: [PATCH 1/3] feat(chat): map CashContent recipients, payment request ids and viewer moderation state CashContent.amount/receiver became totalAmount and a recipient oneof. Map the single receiver as before, carry multi-receiver payments and payment_request_id on ConversationMessage, and surface ViewerState silence plus moderation permissions. PaymentRequestWidget stays unrecognized until it has UI. Bumps the database schema version for the new persisted viewer-state fields. --- .../Conversation/ConversationMessage.swift | 53 +++++++++- .../Conversation/ConversationRules.swift | 96 ++++++++++++++++++- .../Conversation/ShareProfileWidget.swift | 5 +- .../Sources/FlipcashStore/Database.swift | 2 +- .../ConversationModelMappingTests.swift | 52 +++++++++- 5 files changed, 198 insertions(+), 10 deletions(-) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift index 96400cc0e..38ae69a57 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationMessage.swift @@ -24,6 +24,20 @@ public enum CashAction: Sendable, Hashable { case tipped } +/// One receiver's part of a multi-receiver cash payment, in the currency and mint of the message's +/// total. See `messaging.v1.CashContent.Payment`. +public struct CashPayment: Sendable, Hashable { + public let receiverID: UserID? + public let nativeAmount: Double + public let quarks: UInt64 + + public init(receiverID: UserID?, nativeAmount: Double, quarks: UInt64) { + self.receiverID = receiverID + self.nativeAmount = nativeAmount + self.quarks = quarks + } +} + /// A single message within a conversation. public struct ConversationMessage: Identifiable, Hashable, Sendable { @@ -125,6 +139,13 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { /// implicit) and for non-cash content. Set only on messages built from the wire proto; it is /// not persisted, so a message read back from the local store has `nil`. public let cashReceiverID: UserID? + /// The per-receiver parts of a group-chat `.cash` payment sent to several members at once (the + /// `.cash` amount is their total); empty for a single-receiver payment and for non-cash content. + /// Not persisted, like ``cashReceiverID``. + public let cashPayments: [CashPayment] + /// The payment request whose share this `.cash` message paid; `nil` for any other cash message and + /// for non-cash content. Not persisted, like ``cashReceiverID``. + public let paymentRequestID: PaymentRequestID? public let date: Date public let unreadSeq: UInt64 /// The event-log version at which this message reached its current state, @@ -172,6 +193,8 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { content: Content, cashAction: CashAction? = nil, cashReceiverID: UserID? = nil, + cashPayments: [CashPayment] = [], + paymentRequestID: PaymentRequestID? = nil, date: Date, unreadSeq: UInt64, eventSequence: UInt64 = 0, @@ -190,6 +213,8 @@ public struct ConversationMessage: Identifiable, Hashable, Sendable { self.content = content self.cashAction = cashAction self.cashReceiverID = cashReceiverID + self.cashPayments = cashPayments + self.paymentRequestID = paymentRequestID self.date = date self.unreadSeq = unreadSeq self.eventSequence = eventSequence @@ -233,6 +258,8 @@ extension ConversationMessage { content: content, cashAction: cashAction, cashReceiverID: cashReceiverID, + cashPayments: cashPayments, + paymentRequestID: paymentRequestID, date: date, unreadSeq: unreadSeq, eventSequence: eventSequence, @@ -299,6 +326,8 @@ extension ConversationMessage { content: content, cashAction: cashAction, cashReceiverID: cashReceiverID, + cashPayments: cashPayments, + paymentRequestID: paymentRequestID, date: date, unreadSeq: unreadSeq, eventSequence: eventSequence, @@ -362,6 +391,8 @@ extension ConversationMessage { let repliedTo: MessageID? var linkPreview: MessageLinkPreview? var cashReceiverID: UserID? + var cashPayments: [CashPayment] = [] + var paymentRequestID: PaymentRequestID? switch proto.content.first?.type { case .text(let textContent): self.content = .text(textContent.text) @@ -369,13 +400,27 @@ extension ConversationMessage { self.cashAction = nil repliedTo = nil case .cash(let cashContent): - guard let amount = try? ExchangedFiat(cashContent.amount) else { + guard let amount = try? ExchangedFiat(cashContent.totalAmount) else { return nil } self.content = .cash(amount) // Unrecognized verbs fall back to `.sent`, per the proto contract. self.cashAction = cashContent.verb == .tipped ? .tipped : .sent - cashReceiverID = cashContent.hasReceiver ? try? UUID(data: cashContent.receiver.value) : nil + switch cashContent.recipient { + case .singleReceiver(let receiver): + cashReceiverID = try? UUID(data: receiver.value) + case .multiReceiver(let payments): + cashPayments = payments.items.map { + CashPayment( + receiverID: $0.hasReceiver ? try? UUID(data: $0.receiver.value) : nil, + nativeAmount: $0.nativeAmount, + quarks: $0.quarks + ) + } + case nil: + break + } + paymentRequestID = cashContent.hasPaymentRequestID ? PaymentRequestID(cashContent.paymentRequestID) : nil repliedTo = nil case .deleted(let deletedContent): self.content = .deleted( @@ -422,6 +467,8 @@ extension ConversationMessage { // Kept even when the variant is unknown: a widget has no fallback text, so the contract // is that a client which can't draw one shows the message as unsupported. A poll widget // is such a variant until the app can draw one. + // A payment-request widget carries only the request id; drawing it needs a fetch this model + // doesn't do, so it stays unsupported until the UI lands. self.content = .widget(widgetContent.shareProfile.map(Widget.shareProfile) ?? .unrecognized) self.cashAction = nil repliedTo = nil @@ -441,6 +488,8 @@ extension ConversationMessage { self.id = MessageID(proto.messageID) self.cashReceiverID = cashReceiverID + self.cashPayments = cashPayments + self.paymentRequestID = paymentRequestID self.senderID = try? UUID(data: proto.senderID.value) self.date = proto.hasTs ? proto.ts.date : .now self.unreadSeq = proto.unreadSeq diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift index 4ce76ce37..1a2f1b9fb 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ConversationRules.swift @@ -61,10 +61,24 @@ public struct ConversationViewerState: Hashable, Codable, Sendable { /// order does not matter, and there is no delta to fetch, only a refetch. public let version: UInt64 - public init(mute: ConversationMuteState? = nil, canEdit: Bool = false, version: UInt64 = 0) { + /// Present iff the creator has silenced the viewer in this chat: they may read, react and vote + /// but not speak, so the composer is hidden while ``isSilenced(at:)`` holds. A ban is not + /// carried here — a banned user is no longer a member and is shown no viewer state. + public let silence: ConversationSilenceState? + + /// Whether the viewer may silence other members. Server-computed; never derive it client-side. + public let canSilence: Bool + + /// Whether the viewer may ban other members. Server-computed; never derive it client-side. + public let canBan: Bool + + public init(mute: ConversationMuteState? = nil, canEdit: Bool = false, version: UInt64 = 0, silence: ConversationSilenceState? = nil, canSilence: Bool = false, canBan: Bool = false) { self.mute = mute self.canEdit = canEdit self.version = version + self.silence = silence + self.canSilence = canSilence + self.canBan = canBan } } @@ -73,7 +87,10 @@ extension ConversationViewerState { self.init( mute: (proto.hasSettings && proto.settings.hasMute) ? ConversationMuteState(proto.settings.mute) : nil, canEdit: proto.hasPermissions ? proto.permissions.canEdit : false, - version: proto.version + version: proto.version, + silence: (proto.hasRestrictions && proto.restrictions.hasSilence) ? ConversationSilenceState(proto.restrictions.silence) : nil, + canSilence: (proto.hasPermissions && proto.permissions.hasModeration) ? proto.permissions.moderation.canSilence : false, + canBan: (proto.hasPermissions && proto.permissions.hasModeration) ? proto.permissions.moderation.canBan : false ) } } @@ -86,6 +103,12 @@ extension ConversationViewerState { public func isMuted(at date: Date = .now) -> Bool { mute?.isActive(at: date) ?? false } + + /// Whether the viewer is silenced in this chat as of `date`. Computed on every read for the same + /// reason as ``isMuted(at:)``: a timed silence lapses with no server signal. + public func isSilenced(at date: Date = .now) -> Bool { + silence?.isActive(at: date) ?? false + } } extension Conversation { @@ -147,6 +170,75 @@ extension ConversationMuteState { } } +/// Whether, and until when, a member is silenced in a chat. See `chat.v1.SilenceState`. +public enum ConversationSilenceState: Hashable, Codable, Sendable { + /// Silenced until this date, after which the silence lapses client-side. Nothing is sent when it + /// does, so the client owns the countdown. + case until(Date) + /// Silenced until explicitly unsilenced. + case forever +} + +extension ConversationSilenceState { + /// Falls back to `.forever` when `duration` is unset: a `SilenceState` is only sent while one is + /// in force, so an unreadable duration is safer treated as silenced. + init(_ proto: Flipcash_Chat_V1_SilenceState) { + switch proto.duration { + case .until(let timestamp): + self = .until(timestamp.date) + case .forever, nil: + self = .forever + } + } + + /// Whether the silence is still in force as of `date`. + public func isActive(at date: Date = .now) -> Bool { + switch self { + case .until(let expiry): expiry > date + case .forever: true + } + } + + /// Builds the wire form for `SilenceUserRequest.silence`. + var proto: Flipcash_Chat_V1_SilenceState { + .with { + switch self { + case .until(let date): + $0.until = .init(date: date) + case .forever: + $0.forever = .init() + } + } + } +} + +/// A member the chat's creator has silenced or banned. See `chat.v1.RestrictedUser`. +public struct ConversationRestrictedUser: Hashable, Sendable, Identifiable { + public let userID: UserID? + public let displayName: String + public let username: Username? + public let profilePicture: ProfilePicture? + /// Present iff the user is silenced. + public let silence: ConversationSilenceState? + /// Present iff the user is banned. + public let bannedAt: Date? + + public var id: String { userID?.uuidString ?? displayName } +} + +extension ConversationRestrictedUser { + init(_ proto: Flipcash_Chat_V1_RestrictedUser) { + self.init( + userID: proto.hasUserID ? try? UUID(data: proto.userID.value) : nil, + displayName: proto.userProfile.displayName, + username: proto.userProfile.hasUsername ? Username(proto.userProfile.username) : nil, + profilePicture: proto.userProfile.hasProfilePicture ? ProfilePicture(proto.userProfile.profilePicture) : nil, + silence: proto.hasSilence ? ConversationSilenceState(proto.silence) : nil, + bannedAt: proto.hasBan && proto.ban.hasBannedAt ? proto.ban.bannedAt.date : nil + ) + } +} + /// Requirements a user must satisfy to participate in a group chat. Only /// ever set for group chats; unset means the chat has no participation /// requirements. See `chat.v1.Rules`. diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift index e6f86c0ab..2db73c6b6 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Conversation/ShareProfileWidget.swift @@ -37,8 +37,9 @@ extension Flipcash_Messaging_V1_WidgetContent { var shareProfile: ShareProfileWidget? { switch type { case .shareProfile(let widget): ShareProfileWidget(widget) - // A poll widget has no local representation yet; it degrades to `.unrecognized` upstream. - case .poll, nil: nil + // Poll and payment-request widgets have no local representation yet; they degrade to + // `.unrecognized` upstream. + case .poll, .paymentRequest, nil: nil } } } diff --git a/FlipcashCore/Sources/FlipcashStore/Database.swift b/FlipcashCore/Sources/FlipcashStore/Database.swift index 3dc4c6ac4..488cd4064 100644 --- a/FlipcashCore/Sources/FlipcashStore/Database.swift +++ b/FlipcashCore/Sources/FlipcashStore/Database.swift @@ -221,7 +221,7 @@ nonisolated open class Database: @unchecked Sendable { /// the notification service extension needs the same number to decide whether the store on disk /// is one it understands, and an extension cannot read the app's `Info.plist` — separate bundles. /// Both targets link this module, so they cannot disagree. - public static let schemaVersion = 54 + public static let schemaVersion = 55 /// Removes the store and the write-ahead log files beside it. /// diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift index 228d5b514..8c63eaf75 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/ConversationModelMappingTests.swift @@ -187,7 +187,7 @@ struct ConversationModelMappingTests { $0.content = [.with { $0.cash = .with { $0.intentID = .with { $0.value = Data(repeating: 0x03, count: 32) } - $0.amount = .with { + $0.totalAmount = .with { $0.currency = "usd" $0.nativeAmount = 5.0 $0.quarks = 5_000_000 @@ -210,6 +210,52 @@ struct ConversationModelMappingTests { #expect(message.cashAction == .sent) } + @Test("Cash message maps a multi-receiver split and its payment request") + func cashMessageMapsMultiReceiverAndPaymentRequest() throws { + let proto = Flipcash_Messaging_V1_Message.with { + $0.messageID = .with { $0.value = 14 } + $0.content = [.with { + $0.cash = .with { + $0.totalAmount = .with { + $0.currency = "usd" + $0.nativeAmount = 9.0 + $0.quarks = 9_000_000 + $0.mint = .with { $0.value = Data(repeating: 0x02, count: 32) } + } + $0.multiReceiver = .with { + $0.items = (1...3).map { i in + .with { + $0.receiver = .with { $0.value = Data(repeating: UInt8(i), count: 16) } + $0.nativeAmount = 3.0 + $0.quarks = 3_000_000 + } + } + } + $0.paymentRequestID = .with { $0.value = Data(repeating: 0x07, count: 32) } + } + }] + } + + let message = try #require(ConversationMessage(proto)) + #expect(message.cashPayments.count == 3) + #expect(message.cashPayments.map(\.quarks) == [3_000_000, 3_000_000, 3_000_000]) + #expect(message.cashReceiverID == nil) + #expect(message.paymentRequestID?.data == Data(repeating: 0x07, count: 32)) + } + + @Test("Viewer state maps silence and moderation permissions") + func viewerStateMapsModeration() { + let proto = Flipcash_Chat_V1_ViewerState.with { + $0.restrictions = .with { $0.silence = .with { $0.forever = .init() } } + $0.permissions = .with { $0.moderation = .with { $0.canSilence = true; $0.canBan = true } } + } + let rules = ConversationViewerState(proto) + #expect(rules.isSilenced()) + #expect(rules.canSilence) + #expect(rules.canBan) + #expect(!ConversationViewerState(.init()).isSilenced()) + } + @Test("Cash message maps the tipped action") func cashMessageMapsTippedAction() throws { let proto = Flipcash_Messaging_V1_Message.with { @@ -217,7 +263,7 @@ struct ConversationModelMappingTests { $0.content = [.with { $0.cash = .with { $0.verb = .tipped - $0.amount = .with { + $0.totalAmount = .with { $0.currency = "usd" $0.nativeAmount = 2.0 $0.quarks = 2_000_000 @@ -238,7 +284,7 @@ struct ConversationModelMappingTests { // Missing mint bytes — ExchangedFiat(proto:) must reject it. $0.content = [.with { $0.cash = .with { - $0.amount = .with { + $0.totalAmount = .with { $0.currency = "usd" $0.nativeAmount = 5.0 $0.quarks = 5_000_000 From ce74f468af9c3ef9d6cf9ea719c9bf00abe7ff24 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 23 Oct 2026 16:11:35 -0400 Subject: [PATCH 2/3] feat(chat): add payment request and chat moderation client wrappers Add PaymentRequestService and FlipClient wrappers for the five paymentrequest.v1 RPCs, wrappers for silence/unsilence/ban/unban/get-restricted-users, and the appended BANNED result cases for JoinChat and EnterLobby. --- Flipcash/Utilities/Events.swift | 1 + .../Clients/Flip API/FlipClient+Chat.swift | 46 +++ .../Flip API/FlipClient+PaymentRequest.swift | 58 ++++ .../Clients/Flip API/FlipClient.swift | 2 + .../Flip API/Services/ChatService.swift | 270 ++++++++++++++- .../Services/PaymentRequestService.swift | 321 ++++++++++++++++++ .../PaymentRequest/GroupPaymentRequest.swift | 227 +++++++++++++ .../TransportClassificationTests.swift | 10 + 8 files changed, 933 insertions(+), 2 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+PaymentRequest.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PaymentRequestService.swift create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift diff --git a/Flipcash/Utilities/Events.swift b/Flipcash/Utilities/Events.swift index 714226086..80e344875 100644 --- a/Flipcash/Utilities/Events.swift +++ b/Flipcash/Utilities/Events.swift @@ -568,6 +568,7 @@ extension Analytics { case .denied: "Denied" case .notFound: "NotFound" case .rulesNotSatisfied: "RulesNotSatisfied" + case .banned: "Banned" case .ok, .unknown, .transportFailure, .cancelled, .rejected: "Network" } case let error as ErrorLeaveChat: diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift index c39229828..6bfb674fc 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+Chat.swift @@ -50,6 +50,52 @@ extension FlipClient { } } + // MARK: - Moderation - + + /// Silences `userID` in `conversationID` until `silence` lapses (or forever). Creator only. + public func silenceUser(owner: KeyPair, conversationID: ConversationID, userID: UserID, silence: ConversationSilenceState) async throws { + try await withCheckedThrowingContinuation { c in + chatService.silenceUser(owner: owner, conversationID: conversationID, userID: userID, silence: silence) { c.resume(with: $0) } + } + } + + /// Lifts a silence on `userID` in `conversationID`. Creator only. + public func unsilenceUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws { + try await withCheckedThrowingContinuation { c in + chatService.unsilenceUser(owner: owner, conversationID: conversationID, userID: userID) { c.resume(with: $0) } + } + } + + /// Bans `userID` from `conversationID`. Creator only. + public func banUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws { + try await withCheckedThrowingContinuation { c in + chatService.banUser(owner: owner, conversationID: conversationID, userID: userID) { c.resume(with: $0) } + } + } + + /// Lifts a ban on `userID` in `conversationID`. Creator only. + public func unbanUser(owner: KeyPair, conversationID: ConversationID, userID: UserID) async throws { + try await withCheckedThrowingContinuation { c in + chatService.unbanUser(owner: owner, conversationID: conversationID, userID: userID) { c.resume(with: $0) } + } + } + + /// Every user silenced or banned in `conversationID`, paged to exhaustion. Creator only. + public func getRestrictedUsers(owner: KeyPair, conversationID: ConversationID) async throws -> [ConversationRestrictedUser] { + var all: [ConversationRestrictedUser] = [] + var pagingToken: Data? + + while true { + let page: ChatService.RestrictedUsersPage = try await withCheckedThrowingContinuation { c in + chatService.getRestrictedUsers(owner: owner, conversationID: conversationID, pagingToken: pagingToken) { c.resume(with: $0) } + } + all.append(contentsOf: page.users) + guard page.hasMore else { break } + pagingToken = page.pagingToken + } + return all + } + /// Page the group chat feed to exhaustion against a single pinned snapshot. Unlike a DM feed, the /// caller's own membership can change mid-read (a join or a leave lands as a `rosterChanged` /// stream event, not a feed mutation) — the same live-stream caveat as `getDmChatFeed` applies. diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+PaymentRequest.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+PaymentRequest.swift new file mode 100644 index 000000000..59cebbdda --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient+PaymentRequest.swift @@ -0,0 +1,58 @@ +// +// FlipClient+GroupPaymentRequest.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +extension FlipClient { + + /// Creates a split in the group chat `chatID`. Reuse `idempotencyKey` across retries. + public func createPaymentRequest(owner: KeyPair, idempotencyKey: Data, chatID: ConversationID, split: GroupPaymentRequest.Split) async throws -> GroupPaymentRequest { + try await withCheckedThrowingContinuation { c in + paymentRequestService.createPaymentRequest(owner: owner, idempotencyKey: idempotencyKey, chatID: chatID, split: split) { c.resume(with: $0) } + } + } + + /// Fetches one request. `paymentIntentID` is the intent id a caller holding an unpaid share must + /// use for the payment, and `nil` for anyone else. + public func getPaymentRequest(owner: KeyPair, id: PaymentRequestID) async throws -> (request: GroupPaymentRequest, paymentIntentID: Data?) { + let fetched: PaymentRequestService.Fetched = try await withCheckedThrowingContinuation { c in + paymentRequestService.getPaymentRequest(owner: owner, id: id) { c.resume(with: $0) } + } + return (fetched.request, fetched.paymentIntentID) + } + + /// Every request in `conversationID` for `section`, paged to exhaustion. + public func getChatPaymentRequests(owner: KeyPair, conversationID: ConversationID, section: GroupPaymentRequest.Section) async throws -> [GroupPaymentRequest] { + var all: [GroupPaymentRequest] = [] + var pagingToken: Data? + + while true { + let page: PaymentRequestService.ChatPage = try await withCheckedThrowingContinuation { c in + paymentRequestService.getChatPaymentRequests(owner: owner, conversationID: conversationID, section: section, pagingToken: pagingToken) { c.resume(with: $0) } + } + all.append(contentsOf: page.requests) + guard page.hasMore else { break } + pagingToken = page.pagingToken + } + return all + } + + /// Closes an open request. Creator only. + public func closePaymentRequest(owner: KeyPair, id: PaymentRequestID) async throws -> GroupPaymentRequest { + try await withCheckedThrowingContinuation { c in + paymentRequestService.closePaymentRequest(owner: owner, id: id) { c.resume(with: $0) } + } + } + + /// Marks `userID`'s share paid out of band. Creator only. + public func markPaid(owner: KeyPair, id: PaymentRequestID, userID: UserID) async throws -> GroupPaymentRequest { + try await withCheckedThrowingContinuation { c in + paymentRequestService.markPaid(owner: owner, id: id, userID: userID) { c.resume(with: $0) } + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift index 81c14a418..58dc1c759 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/FlipClient.swift @@ -41,6 +41,7 @@ public class FlipClient: ObservableObject { internal let reportingService: ReportingService internal let pollService: PollService internal let communityService: CommunityService + internal let paymentRequestService: PaymentRequestService /// The single per-user event stream. Started on login, stopped on logout. public let eventStreamer: EventStreamer @@ -91,6 +92,7 @@ public class FlipClient: ObservableObject { self.reportingService = ReportingService(client: client) self.pollService = PollService(client: client) self.communityService = CommunityService(client: client) + self.paymentRequestService = PaymentRequestService(client: client) self.eventStreamer = EventStreamer(service: EventStreamingService(client: client)) } diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift index b1ea545be..47e699195 100644 --- a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/ChatService.swift @@ -250,6 +250,159 @@ final class ChatService: Sendable { } } + // MARK: - Moderation - + + /// Silences `userID` in `conversationID`: they may keep reading but not speak. Creator only. + + func silenceUser(owner: KeyPair, conversationID: ConversationID, userID: UserID, silence: ConversationSilenceState, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_SilenceUserRequest.with { + $0.chatID = conversationID.proto + $0.userID = userID.proto + $0.silence = silence.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.silenceUser(request, options: .unaryDefault) + let error = ErrorSilenceUser(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to silenceUser", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + await MainActor.run { completion(.success(())) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Lifts a silence on `userID`. Creator only. + + func unsilenceUser(owner: KeyPair, conversationID: ConversationID, userID: UserID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_UnsilenceUserRequest.with { + $0.chatID = conversationID.proto + $0.userID = userID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.unsilenceUser(request, options: .unaryDefault) + let error = ErrorUnsilenceUser(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to unsilenceUser", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + await MainActor.run { completion(.success(())) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Bans `userID` from `conversationID`, removing them. Creator only. + + func banUser(owner: KeyPair, conversationID: ConversationID, userID: UserID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_BanUserRequest.with { + $0.chatID = conversationID.proto + $0.userID = userID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.banUser(request, options: .unaryDefault) + let error = ErrorBanUser(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to banUser", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + await MainActor.run { completion(.success(())) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Lifts a ban on `userID`, letting them rejoin. Creator only. + + func unbanUser(owner: KeyPair, conversationID: ConversationID, userID: UserID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_UnbanUserRequest.with { + $0.chatID = conversationID.proto + $0.userID = userID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.unbanUser(request, options: .unaryDefault) + let error = ErrorUnbanUser(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to unbanUser", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + await MainActor.run { completion(.success(())) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + struct RestrictedUsersPage: Sendable { + let users: [ConversationRestrictedUser] + let pagingToken: Data + let hasMore: Bool + } + + /// One page of the users silenced or banned in `conversationID`. Creator only. + func getRestrictedUsers(owner: KeyPair, conversationID: ConversationID, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Chat_V1_GetRestrictedUsersRequest.with { + $0.chatID = conversationID.proto + $0.queryOptions = .with { + $0.pageSize = Int32(pageSize) + if let pagingToken { + $0.pagingToken = .with { $0.value = pagingToken } + } + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getRestrictedUsers(request, options: .unaryDefault) + let error = ErrorGetRestrictedUsers(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to fetch restricted users", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + let page = RestrictedUsersPage( + users: response.users.map(ConversationRestrictedUser.init), + pagingToken: response.pagingToken.value, + hasMore: response.hasMore_p + ) + await MainActor.run { completion(.success(page)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + func muteChat(owner: KeyPair, conversationID: ConversationID, mute: ConversationMuteState, completion: @Sendable @escaping (Result) -> Void) { let request = Flipcash_Chat_V1_MuteChatRequest.with { $0.chatID = conversationID.proto @@ -815,6 +968,7 @@ public enum ErrorJoinChat: Int, Error { case denied case notFound case rulesNotSatisfied + case banned case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -929,6 +1083,7 @@ public enum ErrorEnterLobby: Int, Error { case alreadyMember case lobbyFull case tooManyLobbies + case banned case unknown = -1 case transportFailure = -2 case cancelled = -3 @@ -1077,7 +1232,7 @@ extension ErrorJoinChat: ServerError, TransportClassifiableError { switch self { case .ok, .transportFailure: .suppressed case .cancelled: .info - case .denied, .notFound, .rulesNotSatisfied: .info + case .denied, .notFound, .rulesNotSatisfied, .banned: .info case .unknown, .rejected: .error } } @@ -1121,7 +1276,7 @@ extension ErrorEnterLobby: ServerError, TransportClassifiableError { switch self { case .ok, .transportFailure: .suppressed case .cancelled: .info - case .denied, .notFound, .alreadyMember, .lobbyFull, .tooManyLobbies: .info + case .denied, .notFound, .alreadyMember, .lobbyFull, .tooManyLobbies, .banned: .info case .unknown, .rejected: .error } } @@ -1359,3 +1514,114 @@ extension ErrorGetFeaturedGroups { } } } + +// MARK: - Moderation errors - + +/// Positional over `chat.v1` moderation `Result`s, which share the OK/DENIED/NOT_FOUND prefix. + +public enum ErrorSilenceUser: Int, Error { + case ok + case denied + case notFound + case cannotRestrictCreator + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorUnsilenceUser: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorBanUser: Int, Error { + case ok + case denied + case notFound + case cannotRestrictCreator + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorUnbanUser: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorGetRestrictedUsers: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +extension ErrorSilenceUser: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .cannotRestrictCreator: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorUnsilenceUser: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorBanUser: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .cannotRestrictCreator: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorUnbanUser: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetRestrictedUsers: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PaymentRequestService.swift b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PaymentRequestService.swift new file mode 100644 index 000000000..23100b434 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Clients/Flip API/Services/PaymentRequestService.swift @@ -0,0 +1,321 @@ +// +// PaymentRequestService.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI +import GRPCCore + +private let logger = Logger(label: "flipcash.payment-request-service") + +final class PaymentRequestService: Sendable { + + private let service: Flipcash_Paymentrequest_V1_PaymentRequest.Client + + init(client: GRPCClient) { + self.service = Flipcash_Paymentrequest_V1_PaymentRequest.Client(wrapping: client) + } + + struct Fetched: Sendable { + let request: GroupPaymentRequest + /// The intent id the caller's share payment must use; set only for a caller holding an unpaid share. + let paymentIntentID: Data? + } + + struct ChatPage: Sendable { + let requests: [GroupPaymentRequest] + let pagingToken: Data + let hasMore: Bool + } + + /// Creates a split in a group chat. `idempotencyKey` is reused across retries; a retry returns + /// the original request. `.textModerated` carries the category the server flagged. + func createPaymentRequest(owner: KeyPair, idempotencyKey: Data, chatID: ConversationID, split: GroupPaymentRequest.Split, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Paymentrequest_V1_CreatePaymentRequestRequest.with { + $0.idempotencyKey = .with { $0.value = idempotencyKey } + $0.groupChat = .with { + $0.chatID = chatID.proto + $0.split = split.proto + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.createPaymentRequest(request, options: .unaryDefault) + guard response.result == .ok else { + logger.error("Failed to create payment request", metadata: ["result": "\(response.result.rawValue)"]) + await MainActor.run { completion(.failure(ErrorCreatePaymentRequest(response.result, flaggedCategory: response.flaggedCategory))) } + return + } + guard let created = GroupPaymentRequest(response.paymentRequest) else { + logger.error("Created payment request was malformed") + await MainActor.run { completion(.failure(.unknown)) } + return + } + await MainActor.run { completion(.success(created)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + func getPaymentRequest(owner: KeyPair, id: PaymentRequestID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Paymentrequest_V1_GetPaymentRequestRequest.with { + $0.paymentRequestID = id.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getPaymentRequest(request, options: .unaryDefault) + let error = ErrorGetPaymentRequest(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to fetch payment request", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + guard let fetched = GroupPaymentRequest(response.paymentRequest) else { + logger.error("Fetched payment request was malformed") + await MainActor.run { completion(.failure(.unknown)) } + return + } + let result = Fetched( + request: fetched, + paymentIntentID: response.hasPaymentIntentID ? response.paymentIntentID.value : nil + ) + await MainActor.run { completion(.success(result)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + func getChatPaymentRequests(owner: KeyPair, conversationID: ConversationID, section: GroupPaymentRequest.Section, pageSize: Int = 50, pagingToken: Data?, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Paymentrequest_V1_GetChatPaymentRequestsRequest.with { + $0.chatID = conversationID.proto + $0.section = section.proto + $0.queryOptions = .with { + $0.pageSize = Int32(pageSize) + if let pagingToken { + $0.pagingToken = .with { $0.value = pagingToken } + } + } + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.getChatPaymentRequests(request, options: .unaryDefault) + let error = ErrorGetChatPaymentRequests(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok else { + logger.error("Failed to fetch chat payment requests", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error)) } + return + } + let page = ChatPage( + requests: response.paymentRequests.compactMap(GroupPaymentRequest.init), + pagingToken: response.pagingToken.value, + hasMore: response.hasMore_p + ) + await MainActor.run { completion(.success(page)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Closes an open request. Creator only. + func closePaymentRequest(owner: KeyPair, id: PaymentRequestID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Paymentrequest_V1_ClosePaymentRequestRequest.with { + $0.paymentRequestID = id.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.closePaymentRequest(request, options: .unaryDefault) + let error = ErrorClosePaymentRequest(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, let closed = GroupPaymentRequest(response.paymentRequest) else { + logger.error("Failed to close payment request", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(closed)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } + + /// Marks `userID`'s share paid out of band. Creator only. + func markPaid(owner: KeyPair, id: PaymentRequestID, userID: UserID, completion: @Sendable @escaping (Result) -> Void) { + let request = Flipcash_Paymentrequest_V1_MarkPaidRequest.with { + $0.paymentRequestID = id.proto + $0.userID = userID.proto + $0.auth = owner.authFor(message: $0) + } + + Task { + do { + let response = try await service.markPaid(request, options: .unaryDefault) + let error = ErrorMarkPaid(rawValue: response.result.rawValue) ?? .unknown + guard error == .ok, let updated = GroupPaymentRequest(response.paymentRequest) else { + logger.error("Failed to mark payment request share paid", metadata: ["error": "\(error)"]) + await MainActor.run { completion(.failure(error == .ok ? .unknown : error)) } + return + } + await MainActor.run { completion(.success(updated)) } + } catch let error as RPCError { + await MainActor.run { completion(.failure(.from(transportError: error))) } + } catch { + await MainActor.run { completion(.failure(.unknown)) } + } + } + } +} + +// MARK: - Errors - + +/// Associated-value error, modelled on ``ErrorEditChat``, so `.textModerated` can carry the +/// `flaggedCategory` the server reports. Mapped explicitly from `CreatePaymentRequestResponse.Result`; +/// there is no `.ok` case. +public enum ErrorCreatePaymentRequest: Error, Sendable, Equatable { + case denied + case chatNotSupported + case encryptionNotAllowed + case encryptionRequired + case invalidEndsAt + case invalidShares + case invalidCurrency + case textModerated(Flipcash_Moderation_V1_FlaggedCategory) + case unknown + case transportFailure + case cancelled + case rejected +} + +extension ErrorCreatePaymentRequest { + init(_ result: Flipcash_Paymentrequest_V1_CreatePaymentRequestResponse.Result, flaggedCategory: Flipcash_Moderation_V1_FlaggedCategory) { + switch result { + case .ok: self = .unknown + case .denied: self = .denied + case .chatNotSupported: self = .chatNotSupported + case .encryptionNotAllowed: self = .encryptionNotAllowed + case .encryptionRequired: self = .encryptionRequired + case .invalidEndsAt: self = .invalidEndsAt + case .invalidShares: self = .invalidShares + case .invalidCurrency: self = .invalidCurrency + case .textModerated: self = .textModerated(flaggedCategory) + case .UNRECOGNIZED: self = .unknown + } + } +} + +extension ErrorCreatePaymentRequest: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .transportFailure: .suppressed + case .cancelled: .info + case .denied, .chatNotSupported, .encryptionNotAllowed, .encryptionRequired, .invalidEndsAt, .invalidShares, .invalidCurrency, .textModerated: .info + case .unknown, .rejected: .error + } + } +} + +public enum ErrorGetPaymentRequest: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorGetChatPaymentRequests: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorClosePaymentRequest: Int, Error { + case ok + case denied + case notFound + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +public enum ErrorMarkPaid: Int, Error { + case ok + case denied + case notFound + case notOpen + case notAShare + case unknown = -1 + case transportFailure = -2 + case cancelled = -3 + case rejected = -4 +} + +extension ErrorGetPaymentRequest: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorGetChatPaymentRequests: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorClosePaymentRequest: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound: .info + case .unknown, .rejected: .error + } + } +} + +extension ErrorMarkPaid: ServerError, TransportClassifiableError { + public var reportingLevel: ErrorReportingLevel { + switch self { + case .ok, .transportFailure: .suppressed + case .cancelled: .info + case .denied, .notFound, .notOpen, .notAShare: .info + case .unknown, .rejected: .error + } + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift new file mode 100644 index 000000000..2f6c95d4e --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/PaymentRequest/GroupPaymentRequest.swift @@ -0,0 +1,227 @@ +// +// GroupPaymentRequest.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation +import FlipcashAPI + +/// Server-assigned id of a payment request. See `paymentrequest.v1.PaymentRequestId`. +public struct PaymentRequestID: Hashable, Sendable { + + public let data: Data + + public init(data: Data) { + self.data = data + } + + public init(_ proto: Flipcash_Paymentrequest_V1_PaymentRequestId) { + self.data = proto.value + } + + public var proto: Flipcash_Paymentrequest_V1_PaymentRequestId { + .with { $0.value = data } + } +} + +/// A request for members of a group chat to pay shares of a bill. See +/// `paymentrequest.v1.PaymentRequestMetadata`. +public struct GroupPaymentRequest: Hashable, Sendable { + + public enum Status: Hashable, Sendable { + case open + case completed + case closed + case expired + /// A status this build doesn't know, or one the server left unset. + case unknown + } + + /// The request's title and description. A private group sends them encrypted with the chat key. + public enum Text: Hashable, Sendable { + case plaintext(title: String, description: String) + case encrypted(scheme: Int, nonce: Data, ciphertext: Data) + } + + /// How a share was settled. + public enum Settlement: Hashable, Sendable { + /// Paid through a payment intent. + case intent(Data) + /// The creator marked it paid. + case manual + /// The creator's own share, created paid. + case creator + /// A settlement this build doesn't know. + case unknown + } + + public struct Share: Hashable, Sendable { + public let userID: UserID? + /// The share's amount in the split's ``Split/currency``. + public let nativeAmount: Double + /// Set once the share is paid; `nil` while it is owed. + public let paidAt: Date? + public let settlement: Settlement? + + public var isPaid: Bool { settlement != nil } + } + + /// An even or custom division of a bill across group members. + public struct Split: Hashable, Sendable { + public let text: Text? + /// The currency code the shares are denominated in. + public let currency: String + public let endsAt: Date? + public let shares: [Share] + } + + public let id: PaymentRequestID + public let creator: UserID? + public let createdAt: Date? + public let status: Status + public let closedAt: Date? + /// Advances on every change; apply the greater value, since there are no events to drive a refetch. + public let version: UInt64 + public let chatID: ConversationID? + /// `nil` for a request kind this build doesn't know. + public let split: Split? +} + +extension GroupPaymentRequest.Status { + init(_ proto: Flipcash_Paymentrequest_V1_PaymentRequestStatus) { + switch proto { + case .open: self = .open + case .completed: self = .completed + case .closed: self = .closed + case .expired: self = .expired + case .unknown, .UNRECOGNIZED: self = .unknown + } + } +} + +extension GroupPaymentRequest { + init?(_ proto: Flipcash_Paymentrequest_V1_PaymentRequestMetadata) { + guard proto.hasPaymentRequestID else { return nil } + + var chatID: ConversationID? + var split: Split? + switch proto.kind { + case .groupChat(let group): + chatID = group.hasChatID ? ConversationID(group.chatID) : nil + switch group.type { + case .split(let value): split = Split(value) + case nil: break + } + case nil: + break + } + + self.init( + id: PaymentRequestID(proto.paymentRequestID), + creator: proto.hasCreator ? try? UUID(data: proto.creator.value) : nil, + createdAt: proto.hasCreatedAt ? proto.createdAt.date : nil, + status: Status(proto.status), + closedAt: proto.hasClosedAt ? proto.closedAt.date : nil, + version: proto.version, + chatID: chatID, + split: split + ) + } +} + +extension GroupPaymentRequest.Split { + init(_ proto: Flipcash_Paymentrequest_V1_Split) { + let text: GroupPaymentRequest.Text? + switch proto.text { + case .plaintext(let value): + text = .plaintext(title: value.title, description: value.description_p) + case .encrypted(let value): + text = .encrypted(scheme: value.scheme.rawValue, nonce: value.nonce, ciphertext: value.ciphertext) + case nil: + text = nil + } + self.init( + text: text, + currency: proto.currency, + endsAt: proto.hasEndsAt ? proto.endsAt.date : nil, + shares: proto.shares.map(GroupPaymentRequest.Share.init) + ) + } +} + +extension GroupPaymentRequest.Share { + init(_ proto: Flipcash_Paymentrequest_V1_Share) { + let settlement: GroupPaymentRequest.Settlement? + if proto.hasPaid { + switch proto.paid.how { + case .intent(let intent): settlement = .intent(intent.value) + case .manual: settlement = .manual + case .creator: settlement = .creator + case nil: settlement = .unknown + } + } else { + settlement = nil + } + self.init( + userID: proto.hasUserID ? try? UUID(data: proto.userID.value) : nil, + nativeAmount: proto.nativeAmount, + paidAt: proto.hasPaid && proto.paid.hasPaidAt ? proto.paid.paidAt.date : nil, + settlement: settlement + ) + } +} + +// MARK: - Wire form - + +extension GroupPaymentRequest.Text { + var proto: Flipcash_Paymentrequest_V1_Split.OneOf_Text { + switch self { + case .plaintext(let title, let description): + .plaintext(.with { $0.title = title; $0.description_p = description }) + case .encrypted(let scheme, let nonce, let ciphertext): + .encrypted(.with { + $0.scheme = .init(rawValue: scheme) ?? .unknown + $0.nonce = nonce + $0.ciphertext = ciphertext + }) + } + } +} + +extension GroupPaymentRequest.Split { + /// The wire form for `CreatePaymentRequest`. Shares go out unpaid; the server settles the + /// creator's own share itself. + var proto: Flipcash_Paymentrequest_V1_Split { + .with { + $0.text = text?.proto + $0.currency = currency + if let endsAt { $0.endsAt = .init(date: endsAt) } + $0.shares = shares.compactMap { share in + guard let userID = share.userID else { return nil } + return .with { + $0.userID = userID.proto + $0.nativeAmount = share.nativeAmount + } + } + } + } +} + +extension GroupPaymentRequest { + /// Which slice of a chat's requests to list. + public enum Section: Hashable, Sendable { + /// Requests still collecting payments. + case open + /// Completed, closed and expired requests. + case resolved + + var proto: Flipcash_Paymentrequest_V1_GetChatPaymentRequestsRequest.Section { + switch self { + case .open: .open + case .resolved: .resolved + } + } + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift index 14c2b4cda..ee089183f 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/TransportClassificationTests.swift @@ -101,6 +101,16 @@ struct TransportClassificationTests { @Test func errorLeaveChat() { assertClassifies(ErrorLeaveChat.self) } @Test func errorMuteChat() { assertClassifies(ErrorMuteChat.self) } @Test func errorUnmuteChat() { assertClassifies(ErrorUnmuteChat.self) } + @Test func errorSilenceUser() { assertClassifies(ErrorSilenceUser.self) } + @Test func errorUnsilenceUser() { assertClassifies(ErrorUnsilenceUser.self) } + @Test func errorBanUser() { assertClassifies(ErrorBanUser.self) } + @Test func errorUnbanUser() { assertClassifies(ErrorUnbanUser.self) } + @Test func errorGetRestrictedUsers() { assertClassifies(ErrorGetRestrictedUsers.self) } + @Test func errorCreatePaymentRequest() { assertClassifies(ErrorCreatePaymentRequest.self) } + @Test func errorGetPaymentRequest() { assertClassifies(ErrorGetPaymentRequest.self) } + @Test func errorGetChatPaymentRequests() { assertClassifies(ErrorGetChatPaymentRequests.self) } + @Test func errorClosePaymentRequest() { assertClassifies(ErrorClosePaymentRequest.self) } + @Test func errorMarkPaid() { assertClassifies(ErrorMarkPaid.self) } @Test func errorGetRoster() { assertClassifies(ErrorGetRoster.self) } @Test func errorGetMentionSuggestions() { assertClassifies(ErrorGetMentionSuggestions.self) } @Test func errorEditChat() { assertClassifies(ErrorEditChat.self) } From 791709021f018047dbcce60638419046e7a7201b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 23 Oct 2026 16:38:24 -0400 Subject: [PATCH 3/3] chore(deps): bump flipcash2-client-protocol to 0.21.0 --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- FlipcashAPI/Package.swift | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index f19253844..3fdd78afb 100644 --- a/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Code.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c1a366f97e033b7ee60810d9f4229a153bddc573d336d834f2446e421324d519", + "originHash" : "1b3757f361c274f2cfab22a7a970c9c0a0dd394ec3d62435329c4458cf779ca3", "pins" : [ { "identity" : "abseil-cpp-binary", @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/code-payments/flipcash2-client-protocol", "state" : { - "revision" : "f0bc47eefd9c8d2074f229c91d3a950f4bed1b97", - "version" : "0.20.0" + "revision" : "a73ec1dff18a77a698af68add72f1856b6005b2a", + "version" : "0.21.0" } }, { diff --git a/FlipcashAPI/Package.swift b/FlipcashAPI/Package.swift index 866a969a8..6378a0f70 100644 --- a/FlipcashAPI/Package.swift +++ b/FlipcashAPI/Package.swift @@ -56,7 +56,7 @@ enum ContractPackage: String, CaseIterable { var version: Version { switch self { case .ocp: return "0.9.0" - case .flipcash2: return "0.20.0" + case .flipcash2: return "0.21.0" } }