diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt index 85ed7b4066..4cf56bf7e6 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -58,6 +58,7 @@ val Throwable.chatResult: String is StartChatError.PictureBlobNotAccepted, is EditChatError.PictureBlobNotAccepted -> "PictureBlobNotAccepted" is StartChatError.InvalidRules -> "InvalidRules" is StartChatError.RulesNotSatisfied, is JoinChatError.RulesNotSatisfied -> "RulesNotSatisfied" + is JoinChatError.Banned -> "Banned" is StartChatError.Unrecognized, is EditChatError.Unrecognized, is JoinChatError.Unrecognized, is LeaveChatError.Unrecognized, is MuteChatError.Unrecognized, is UnmuteChatError.Unrecognized -> "Unrecognized" is ValidationException -> "Validation" diff --git a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt index ffb5893ed8..0d7fb7f906 100644 --- a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt +++ b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt @@ -103,6 +103,7 @@ class ConversionsTest { fun `a chat error reports the proto result name, not its message`() { assertEquals("RulesNotSatisfied", JoinChatError.RulesNotSatisfied().chatResult) assertEquals("NotFound", JoinChatError.NotFound().chatResult) + assertEquals("Banned", JoinChatError.Banned().chatResult) // MuteChatError.Denied's message is "Caller is not a member". assertEquals("Denied", MuteChatError.Denied().chatResult) assertEquals("TitleModerated", StartChatError.TitleModerated(mockk(relaxed = true)).chatResult) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index 0c10b60ce8..03e38458b0 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -213,8 +213,21 @@ sealed interface MessageContentSerialized { // Base58, like [intentId]. Null for rows stored before this existed, and for messages // that carried no receiver. val receiver: String? = null, + // Each receiver's part of a multi-receiver payment. Empty for rows stored before this + // existed, and for single-receiver payments. + val multiReceiver: List = emptyList(), + // Base58, like [intentId]. Null unless the payment paid a payment-request share. + val paymentRequestId: String? = null, ) : MessageContentSerialized + @Serializable + data class CashPayment( + // Base58, like [Cash.receiver]. + val receiver: String, + val nativeAmount: Double, + val tokenQuarks: Long, + ) + @Serializable @SerialName("deleted") data class Deleted( diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 86e465513e..e1cf744305 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -401,6 +401,14 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this action = action.name, tokenQuarks = tokenQuarks, receiver = receiver?.base58, + multiReceiver = multiReceiver.map { + MessageContentSerialized.CashPayment( + receiver = it.receiver.base58, + nativeAmount = it.nativeAmount, + tokenQuarks = it.tokenQuarks, + ) + }, + paymentRequestId = paymentRequestId?.base58, ) is MessageContent.Reply -> MessageContentSerialized.Reply( repliedMessageId = repliedMessageId, @@ -451,6 +459,14 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { ?: MessageContent.Cash.Action.SENT, tokenQuarks = tokenQuarks, receiver = receiver?.decodeBase58()?.toList(), + multiReceiver = multiReceiver.map { + MessageContent.Cash.Payment( + receiver = it.receiver.decodeBase58().toList(), + nativeAmount = it.nativeAmount, + tokenQuarks = it.tokenQuarks, + ) + }, + paymentRequestId = paymentRequestId?.decodeBase58()?.toList(), ) is MessageContentSerialized.Reply -> MessageContent.Reply( repliedMessageId = repliedMessageId, diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index df68e2404f..b839e8b4e4 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -210,6 +210,27 @@ class ChatEntityMapperTest { assertNull(restored.tokenQuarks) } + @Test + fun `cash multi-receiver parts and payment request id survive the round trip`() { + val parts = listOf( + MessageContent.Cash.Payment(listOf(1), 1.25, 4L), + MessageContent.Cash.Payment(listOf(2), 1.25, 6L), + ) + val requestId = List(16) { (it + 1).toByte() } + val message = cashMessage(tokenQuarks = 10L).let { m -> + m.copy(content = m.content.map { + (it as MessageContent.Cash).copy(multiReceiver = parts, paymentRequestId = requestId) + }) + } + + val restored = mapper.toMessage(mapper.toEntity(CHAT_HEX, message)) + .content.single() as MessageContent.Cash + + assertEquals(parts, restored.multiReceiver) + assertEquals(requestId, restored.paymentRequestId) + assertNull(restored.receiver) + } + @Test fun `cash receiver survives the round trip, and its absence stays null`() { val receiver = listOf(9, 8, 7) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1242e61efa..69d19df733 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -69,7 +69,7 @@ protovalidate-kt = "0.1.3" # 0.3.0 is the first release of either package to ship R8 keep rules for its generated # messages, which is what lets proguard-rules.pro drop its own. ocp-client-protocol = "0.9.0" -flipcash2-client-protocol = "0.20.0" +flipcash2-client-protocol = "0.21.0" # The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's # `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt index f2750b9d01..d5a5924cbc 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ChatController.kt @@ -12,6 +12,8 @@ import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.RestrictedUsersPage +import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters @@ -210,6 +212,46 @@ class ChatController @Inject constructor( return repository.denyLobbyMember(owner, chatId, userId) } + /** Silences [userId] in [chatId] for the duration in [silence]. */ + suspend fun silenceUser(chatId: ChatId, userId: ID, silence: SilenceState): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.silenceUser(owner, chatId, userId, silence) + } + + /** Lifts [userId]'s silence in [chatId]. */ + suspend fun unsilenceUser(chatId: ChatId, userId: ID): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.unsilenceUser(owner, chatId, userId) + } + + /** Bans [userId] from [chatId]. */ + suspend fun banUser(chatId: ChatId, userId: ID): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.banUser(owner, chatId, userId) + } + + /** Lifts [userId]'s ban from [chatId]. */ + suspend fun unbanUser(chatId: ChatId, userId: ID): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.unbanUser(owner, chatId, userId) + } + + /** One page of the users silenced or banned in [chatId]. */ + suspend fun getRestrictedUsers(chatId: ChatId, queryOptions: QueryOptions = QueryOptions()): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.getRestrictedUsers(owner, chatId, queryOptions) + } + /** Stores the caller's own key envelope for [chatId]. */ suspend fun setKeyEnvelope(chatId: ChatId, keyEnvelope: KeyEnvelope): Result { val owner = userManager.accountCluster?.authority?.keyPair diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PaymentRequestController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PaymentRequestController.kt new file mode 100644 index 0000000000..d0a5937d3a --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/PaymentRequestController.kt @@ -0,0 +1,47 @@ +package com.flipcash.services.controllers + +import com.codeinc.flipcash.gen.paymentrequest.v1.Model +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequestDetails +import com.flipcash.services.models.paymentrequest.PaymentRequestPage +import com.flipcash.services.models.paymentrequest.PaymentRequestSection +import com.flipcash.services.repository.PaymentRequestRepository +import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.core.ID +import javax.inject.Inject + +class PaymentRequestController @Inject constructor( + private val repository: PaymentRequestRepository, + private val userManager: UserManager, +) { + suspend fun createPaymentRequest(groupChat: Model.GroupChatPaymentRequest, idempotencyKey: Model.IdempotencyKey): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.createPaymentRequest(owner, groupChat, idempotencyKey) + } + + suspend fun getPaymentRequest(paymentRequestId: Model.PaymentRequestId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.getPaymentRequest(owner, paymentRequestId) + } + + suspend fun getChatPaymentRequests(chatId: ChatId, section: PaymentRequestSection, queryOptions: QueryOptions = QueryOptions()): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.getChatPaymentRequests(owner, chatId, section, queryOptions) + } + + suspend fun closePaymentRequest(paymentRequestId: Model.PaymentRequestId): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.closePaymentRequest(owner, paymentRequestId) + } + + suspend fun markPaid(paymentRequestId: Model.PaymentRequestId, userId: ID): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + return repository.markPaid(owner, paymentRequestId, userId) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt index 1daeefddf9..934c174576 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt @@ -27,8 +27,11 @@ import com.flipcash.services.internal.network.services.ChatMessagingService import com.flipcash.services.internal.network.services.EmailVerificationService import com.flipcash.services.internal.network.services.ContactListService import com.flipcash.services.internal.network.services.ModerationService +import com.flipcash.services.internal.network.services.PaymentRequestService import com.flipcash.services.internal.network.services.PollService +import com.flipcash.services.internal.repositories.InternalPaymentRequestRepository import com.flipcash.services.internal.repositories.InternalPollRepository +import com.flipcash.services.repository.PaymentRequestRepository import com.flipcash.services.repository.PollRepository import com.flipcash.services.internal.network.services.PhoneVerificationService import com.flipcash.services.internal.network.services.ProfileService @@ -273,6 +276,11 @@ internal object FlipcashModule { service: PollService, ): PollRepository = InternalPollRepository(service) + @Provides + internal fun providesPaymentRequestRepository( + service: PaymentRequestService, + ): PaymentRequestRepository = InternalPaymentRequestRepository(service) + @Provides internal fun providesModerationRepository( service: ModerationService, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt index 1dbae26170..d74b15759b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ChatApi.kt @@ -8,6 +8,7 @@ import com.flipcash.services.internal.network.extensions.asChatId import com.flipcash.services.internal.network.extensions.asProtoChatType import com.flipcash.services.internal.network.extensions.asProtoIdempotencyKey import com.flipcash.services.internal.network.extensions.asProtoMuteState +import com.flipcash.services.internal.network.extensions.asProtoSilenceState import com.flipcash.services.internal.network.extensions.asProtoRules import com.flipcash.services.internal.network.extensions.asQueryOptions import com.flipcash.services.internal.network.extensions.asProtoKeyEnvelope @@ -23,6 +24,7 @@ import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ViewMode import com.getcode.utils.toByteString @@ -461,6 +463,98 @@ internal class ChatApi @Inject constructor( } } + suspend fun silenceUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + silence: SilenceState, + ): RpcChatService.SilenceUserResponse { + val request = RpcChatService.SilenceUserRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setUserId(userId.asUserId()) + .setSilence(silence.asProtoSilenceState()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.silenceUser(request) + } + } + + suspend fun unsilenceUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + ): RpcChatService.UnsilenceUserResponse { + val request = RpcChatService.UnsilenceUserRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setUserId(userId.asUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.unsilenceUser(request) + } + } + + suspend fun banUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + ): RpcChatService.BanUserResponse { + val request = RpcChatService.BanUserRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setUserId(userId.asUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.banUser(request) + } + } + + suspend fun unbanUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + ): RpcChatService.UnbanUserResponse { + val request = RpcChatService.UnbanUserRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setUserId(userId.asUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.unbanUser(request) + } + } + + suspend fun getRestrictedUsers( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): RpcChatService.GetRestrictedUsersResponse { + val request = RpcChatService.GetRestrictedUsersRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setQueryOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getRestrictedUsers(request) + } + } + suspend fun setKeyEnvelope( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PaymentRequestApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PaymentRequestApi.kt new file mode 100644 index 0000000000..7ee74ca143 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/PaymentRequestApi.kt @@ -0,0 +1,127 @@ +package com.flipcash.services.internal.network.api + +import com.codeinc.flipcash.gen.paymentrequest.v1.Model +import com.codeinc.flipcash.gen.paymentrequest.v1.PaymentRequestGrpcKt +import com.codeinc.flipcash.gen.paymentrequest.v1.PaymentrequestService +import com.codeinc.flipcash.gen.paymentrequest.v1.validate +import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.asChatId +import com.flipcash.services.internal.network.extensions.asQueryOptions +import com.flipcash.services.internal.network.extensions.asUserId +import com.flipcash.services.internal.network.extensions.authenticate +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequestSection +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.internal.network.core.GrpcApi +import com.getcode.opencode.model.core.ID +import dev.bmcreations.protovalidate.orThrow +import io.grpc.ManagedChannel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +internal class PaymentRequestApi @Inject constructor( + @FlipcashManagedChannel + managedChannel: ManagedChannel, +) : GrpcApi(managedChannel) { + private val api = PaymentRequestGrpcKt.PaymentRequestCoroutineStub(managedChannel) + .withWaitForReady() + + suspend fun createPaymentRequest( + owner: Ed25519.KeyPair, + groupChat: Model.GroupChatPaymentRequest, + idempotencyKey: Model.IdempotencyKey, + ): PaymentrequestService.CreatePaymentRequestResponse { + val request = PaymentrequestService.CreatePaymentRequestRequest.newBuilder() + .setIdempotencyKey(idempotencyKey) + .setGroupChat(groupChat) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.createPaymentRequest(request) + } + } + + suspend fun getPaymentRequest( + owner: Ed25519.KeyPair, + paymentRequestId: Model.PaymentRequestId, + ): PaymentrequestService.GetPaymentRequestResponse { + val request = PaymentrequestService.GetPaymentRequestRequest.newBuilder() + .setPaymentRequestId(paymentRequestId) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getPaymentRequest(request) + } + } + + suspend fun getChatPaymentRequests( + owner: Ed25519.KeyPair, + chatId: ChatId, + section: PaymentRequestSection, + queryOptions: QueryOptions, + ): PaymentrequestService.GetChatPaymentRequestsResponse { + val request = PaymentrequestService.GetChatPaymentRequestsRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setSection( + when (section) { + PaymentRequestSection.OPEN -> + PaymentrequestService.GetChatPaymentRequestsRequest.Section.OPEN + PaymentRequestSection.RESOLVED -> + PaymentrequestService.GetChatPaymentRequestsRequest.Section.RESOLVED + } + ) + .setQueryOptions(queryOptions.asQueryOptions()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getChatPaymentRequests(request) + } + } + + suspend fun closePaymentRequest( + owner: Ed25519.KeyPair, + paymentRequestId: Model.PaymentRequestId, + ): PaymentrequestService.ClosePaymentRequestResponse { + val request = PaymentrequestService.ClosePaymentRequestRequest.newBuilder() + .setPaymentRequestId(paymentRequestId) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.closePaymentRequest(request) + } + } + + suspend fun markPaid( + owner: Ed25519.KeyPair, + paymentRequestId: Model.PaymentRequestId, + userId: ID, + ): PaymentrequestService.MarkPaidResponse { + val request = PaymentrequestService.MarkPaidRequest.newBuilder() + .setPaymentRequestId(paymentRequestId) + .setUserId(userId.asUserId()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.markPaid(request) + } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index f7a4fd6dc8..b735b93535 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -15,6 +15,7 @@ import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.TypingState import com.flipcash.services.models.chat.ViewMode @@ -29,6 +30,7 @@ import com.google.protobuf.Timestamp import kotlin.time.Instant import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.codeinc.flipcash.gen.paymentrequest.v1.Model as PaymentRequestModel internal fun Checksum.asHash(): Common.Hash { return Common.Hash.newBuilder().setValue(byteArray.toByteString()).build() @@ -136,7 +138,7 @@ internal fun MessageContent.asContent(): MessagingModel.Content { .setCash( MessagingModel.CashContent.newBuilder() .setIntentId(Common.IntentId.newBuilder().setValue(intentId.toByteString())) - .setAmount( + .setTotalAmount( Common.CryptoPaymentAmount.newBuilder() .setQuarks(tokenQuarks ?: amount.quarks) .setMint(Common.PublicKey.newBuilder().setValue(mint.bytes.toByteString())) @@ -148,8 +150,27 @@ internal fun MessageContent.asContent(): MessagingModel.Content { } ) .apply { - this@asContent.receiver?.let { - setReceiver(Common.UserId.newBuilder().setValue(it.toByteString())) + // single_receiver and multi_receiver share a oneof; setting one clears the other. + val multi = this@asContent.multiReceiver + if (multi.isNotEmpty()) { + setMultiReceiver( + MessagingModel.CashContent.Payments.newBuilder().addAllItems( + multi.map { + MessagingModel.CashContent.Payment.newBuilder() + .setReceiver(it.receiver.asUserId()) + .setNativeAmount(it.nativeAmount) + .setQuarks(it.tokenQuarks) + .build() + } + ) + ) + } else { + this@asContent.receiver?.let { setSingleReceiver(it.asUserId()) } + } + this@asContent.paymentRequestId?.let { + setPaymentRequestId( + PaymentRequestModel.PaymentRequestId.newBuilder().setValue(it.toByteString()) + ) } } ) @@ -370,6 +391,14 @@ internal fun ViewMode.asViewMode(): MessagingModel.ViewMode { } } +internal fun SilenceState.asProtoSilenceState(): ChatModel.SilenceState { + val builder = ChatModel.SilenceState.newBuilder() + return when (this) { + is SilenceState.Until -> builder.setUntil(until.asTimestamp()).build() + SilenceState.Forever -> builder.setForever(ChatModel.SilenceState.Forever.getDefaultInstance()).build() + } +} + internal fun MuteState.asProtoMuteState(): ChatModel.MuteState { val builder = ChatModel.MuteState.newBuilder() return when (this) { diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index dfbfb5612f..1c73d9a278 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -53,6 +53,9 @@ import com.flipcash.services.models.chat.WidgetContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MetadataUpdate import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.BanState +import com.flipcash.services.models.chat.RestrictedUser +import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.PointerType import com.flipcash.services.models.chat.ChatRules import com.flipcash.services.models.chat.ChatRuleRequirement @@ -190,16 +193,28 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { MessagingModel.Content.TypeCase.CASH -> MessageContent.Cash( intentId = cash.intentId.value.toByteArray().toList(), amount = Fiat( - fiat = cash.amount.nativeAmount, - currencyCode = CurrencyCode.tryValueOf(cash.amount.currency) ?: CurrencyCode.USD, + fiat = cash.totalAmount.nativeAmount, + currencyCode = CurrencyCode.tryValueOf(cash.totalAmount.currency) ?: CurrencyCode.USD, ), - mint = cash.amount.mint.value.toByteArray().toMint(), + mint = cash.totalAmount.mint.value.toByteArray().toMint(), action = when (cash.verb) { MessagingModel.CashContent.Verb.TIPPED -> MessageContent.Cash.Action.TIPPED else -> MessageContent.Cash.Action.SENT }, - receiver = if (cash.hasReceiver()) cash.receiver.value.toByteArray().toList() else null, - tokenQuarks = cash.amount.quarks, + receiver = if (cash.hasSingleReceiver()) cash.singleReceiver.value.toByteArray().toList() else null, + tokenQuarks = cash.totalAmount.quarks, + multiReceiver = if (cash.hasMultiReceiver()) { + cash.multiReceiver.itemsList.map { + MessageContent.Cash.Payment( + receiver = it.receiver.toId(), + nativeAmount = it.nativeAmount, + tokenQuarks = it.quarks, + ) + } + } else { + emptyList() + }, + paymentRequestId = if (cash.hasPaymentRequestId()) cash.paymentRequestId.value.toByteArray().toList() else null, ) MessagingModel.Content.TypeCase.REPLY -> MessageContent.Reply( repliedMessageId = reply.repliedMessageId.value, @@ -229,6 +244,8 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { when (widget.typeCase) { MessagingModel.WidgetContent.TypeCase.SHARE_PROFILE -> WidgetContent.ShareProfile(widget.shareProfile.username.value) + // PAYMENT_REQUEST carries only the request id. It renders as unsupported until a + // UI consumes payment requests, which also owns the chat/creator mismatch check. else -> WidgetContent.Unsupported } ) @@ -488,13 +505,46 @@ internal fun ChatModel.ViewerState.toViewerState(): ViewerState { mute = if (hasSettings() && settings.hasMute()) settings.mute.toMuteState() else null, version = version, permissions = if (hasPermissions()) { - ViewerState.Permissions(canEdit = permissions.canEdit) + ViewerState.Permissions( + canEdit = permissions.canEdit, + moderation = if (permissions.hasModeration()) { + ViewerState.ModerationPermissions( + canSilence = permissions.moderation.canSilence, + canBan = permissions.moderation.canBan, + ) + } else { + ViewerState.ModerationPermissions() + }, + ) } else { ViewerState.Permissions() }, + restrictions = if (hasRestrictions()) { + ViewerState.Restrictions( + silence = if (restrictions.hasSilence()) restrictions.silence.toSilenceState() else null, + ) + } else { + null + }, ) } +internal fun ChatModel.SilenceState.toSilenceState(): SilenceState { + return when (durationCase) { + ChatModel.SilenceState.DurationCase.UNTIL -> + SilenceState.Until(Instant.fromEpochSeconds(until.seconds, until.nanos)) + ChatModel.SilenceState.DurationCase.FOREVER -> SilenceState.Forever + // A duration this client does not know is treated as the strictest one the user can see. + else -> SilenceState.Forever + } +} + +internal fun ChatModel.RestrictedUser.toRestrictedUser(): RestrictedUser = RestrictedUser( + userProfile = userProfile.toMemberUserProfile(fallbackUserId = userId.toId()), + silence = if (hasSilence()) silence.toSilenceState() else null, + ban = if (hasBan()) BanState(Instant.fromEpochSeconds(ban.bannedAt.seconds, ban.bannedAt.nanos)) else null, +) + internal fun ChatModel.MuteState.toMuteState(): MuteState { return when (durationCase) { ChatModel.MuteState.DurationCase.UNTIL -> diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt index 77b514b9e1..c3d11b9bb6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ChatService.kt @@ -5,6 +5,11 @@ import com.codeinc.flipcash.gen.chat.v1.Model as ChatModel import com.flipcash.services.internal.network.api.ChatApi import com.flipcash.services.internal.network.extensions.toFlaggedCategory import com.flipcash.services.models.AdmitLobbyMemberError +import com.flipcash.services.models.BanUserError +import com.flipcash.services.models.GetRestrictedUsersError +import com.flipcash.services.models.SilenceUserError +import com.flipcash.services.models.UnbanUserError +import com.flipcash.services.models.UnsilenceUserError import com.flipcash.services.models.DenyLobbyMemberError import com.flipcash.services.models.EditChatError import com.flipcash.services.models.EnterLobbyError @@ -31,6 +36,7 @@ import com.flipcash.services.models.chat.EditChatParameters import com.flipcash.services.models.chat.IdempotencyKey import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.StartChatParameters import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.ViewMode @@ -234,6 +240,7 @@ internal class ChatService @Inject constructor( RpcChatService.JoinChatResponse.Result.DENIED -> Result.failure(JoinChatError.Denied()) RpcChatService.JoinChatResponse.Result.NOT_FOUND -> Result.failure(JoinChatError.NotFound()) RpcChatService.JoinChatResponse.Result.RULES_NOT_SATISFIED -> Result.failure(JoinChatError.RulesNotSatisfied()) + RpcChatService.JoinChatResponse.Result.BANNED -> Result.failure(JoinChatError.Banned()) RpcChatService.JoinChatResponse.Result.UNRECOGNIZED -> Result.failure(JoinChatError.Unrecognized()) else -> Result.failure(JoinChatError.Other()) } @@ -385,6 +392,7 @@ internal class ChatService @Inject constructor( RpcChatService.EnterLobbyResponse.Result.ALREADY_MEMBER -> Result.failure(EnterLobbyError.AlreadyMember()) RpcChatService.EnterLobbyResponse.Result.LOBBY_FULL -> Result.failure(EnterLobbyError.LobbyFull()) RpcChatService.EnterLobbyResponse.Result.TOO_MANY_LOBBIES -> Result.failure(EnterLobbyError.TooManyLobbies()) + RpcChatService.EnterLobbyResponse.Result.BANNED -> Result.failure(EnterLobbyError.Banned()) RpcChatService.EnterLobbyResponse.Result.UNRECOGNIZED -> Result.failure(EnterLobbyError.Unrecognized()) else -> Result.failure(EnterLobbyError.Other()) } @@ -465,6 +473,124 @@ internal class ChatService @Inject constructor( ) } + suspend fun silenceUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + silence: SilenceState, + ): Result { + return runCatching { + api.silenceUser(owner, chatId, userId, silence) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.SilenceUserResponse.Result.OK -> Result.success(Unit) + RpcChatService.SilenceUserResponse.Result.DENIED -> Result.failure(SilenceUserError.Denied()) + RpcChatService.SilenceUserResponse.Result.NOT_FOUND -> Result.failure(SilenceUserError.NotFound()) + RpcChatService.SilenceUserResponse.Result.CANNOT_RESTRICT_CREATOR -> Result.failure(SilenceUserError.CannotRestrictCreator()) + RpcChatService.SilenceUserResponse.Result.UNRECOGNIZED -> Result.failure(SilenceUserError.Unrecognized()) + else -> Result.failure(SilenceUserError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { SilenceUserError.Other(cause = it) }) + } + ) + } + + suspend fun unsilenceUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + ): Result { + return runCatching { + api.unsilenceUser(owner, chatId, userId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.UnsilenceUserResponse.Result.OK -> Result.success(Unit) + RpcChatService.UnsilenceUserResponse.Result.DENIED -> Result.failure(UnsilenceUserError.Denied()) + RpcChatService.UnsilenceUserResponse.Result.NOT_FOUND -> Result.failure(UnsilenceUserError.NotFound()) + RpcChatService.UnsilenceUserResponse.Result.UNRECOGNIZED -> Result.failure(UnsilenceUserError.Unrecognized()) + else -> Result.failure(UnsilenceUserError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { UnsilenceUserError.Other(cause = it) }) + } + ) + } + + suspend fun banUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + ): Result { + return runCatching { + api.banUser(owner, chatId, userId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.BanUserResponse.Result.OK -> Result.success(Unit) + RpcChatService.BanUserResponse.Result.DENIED -> Result.failure(BanUserError.Denied()) + RpcChatService.BanUserResponse.Result.NOT_FOUND -> Result.failure(BanUserError.NotFound()) + RpcChatService.BanUserResponse.Result.CANNOT_RESTRICT_CREATOR -> Result.failure(BanUserError.CannotRestrictCreator()) + RpcChatService.BanUserResponse.Result.UNRECOGNIZED -> Result.failure(BanUserError.Unrecognized()) + else -> Result.failure(BanUserError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { BanUserError.Other(cause = it) }) + } + ) + } + + suspend fun unbanUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + ): Result { + return runCatching { + api.unbanUser(owner, chatId, userId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.UnbanUserResponse.Result.OK -> Result.success(Unit) + RpcChatService.UnbanUserResponse.Result.DENIED -> Result.failure(UnbanUserError.Denied()) + RpcChatService.UnbanUserResponse.Result.NOT_FOUND -> Result.failure(UnbanUserError.NotFound()) + RpcChatService.UnbanUserResponse.Result.UNRECOGNIZED -> Result.failure(UnbanUserError.Unrecognized()) + else -> Result.failure(UnbanUserError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { UnbanUserError.Other(cause = it) }) + } + ) + } + + suspend fun getRestrictedUsers( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getRestrictedUsers(owner, chatId, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcChatService.GetRestrictedUsersResponse.Result.OK -> Result.success(response) + RpcChatService.GetRestrictedUsersResponse.Result.DENIED -> Result.failure(GetRestrictedUsersError.Denied()) + RpcChatService.GetRestrictedUsersResponse.Result.NOT_FOUND -> Result.failure(GetRestrictedUsersError.NotFound()) + RpcChatService.GetRestrictedUsersResponse.Result.UNRECOGNIZED -> Result.failure(GetRestrictedUsersError.Unrecognized()) + else -> Result.failure(GetRestrictedUsersError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetRestrictedUsersError.Other(cause = it) }) + } + ) + } + suspend fun denyLobbyMember( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PaymentRequestService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PaymentRequestService.kt new file mode 100644 index 0000000000..e9c9e3cab1 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/PaymentRequestService.kt @@ -0,0 +1,155 @@ +package com.flipcash.services.internal.network.services + +import com.codeinc.flipcash.gen.paymentrequest.v1.Model +import com.codeinc.flipcash.gen.paymentrequest.v1.PaymentrequestService +import com.flipcash.services.internal.network.api.PaymentRequestApi +import com.flipcash.services.internal.network.extensions.toFlaggedCategory +import com.flipcash.services.internal.network.extensions.toPagingToken +import com.flipcash.services.models.ClosePaymentRequestError +import com.flipcash.services.models.CreatePaymentRequestError +import com.flipcash.services.models.GetChatPaymentRequestsError +import com.flipcash.services.models.GetPaymentRequestError +import com.flipcash.services.models.MarkPaymentRequestPaidError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequestDetails +import com.flipcash.services.models.paymentrequest.PaymentRequestPage +import com.flipcash.services.models.paymentrequest.PaymentRequestSection +import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.utils.toValidationOrElse +import javax.inject.Inject + +internal class PaymentRequestService @Inject constructor( + private val api: PaymentRequestApi, +) { + suspend fun createPaymentRequest( + owner: Ed25519.KeyPair, + groupChat: Model.GroupChatPaymentRequest, + idempotencyKey: Model.IdempotencyKey, + ): Result { + return runCatching { + api.createPaymentRequest(owner, groupChat, idempotencyKey) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + PaymentrequestService.CreatePaymentRequestResponse.Result.OK -> Result.success(response.paymentRequest) + PaymentrequestService.CreatePaymentRequestResponse.Result.DENIED -> Result.failure(CreatePaymentRequestError.Denied()) + PaymentrequestService.CreatePaymentRequestResponse.Result.CHAT_NOT_SUPPORTED -> Result.failure(CreatePaymentRequestError.ChatNotSupported()) + PaymentrequestService.CreatePaymentRequestResponse.Result.ENCRYPTION_NOT_ALLOWED -> Result.failure(CreatePaymentRequestError.EncryptionNotAllowed()) + PaymentrequestService.CreatePaymentRequestResponse.Result.ENCRYPTION_REQUIRED -> Result.failure(CreatePaymentRequestError.EncryptionRequired()) + PaymentrequestService.CreatePaymentRequestResponse.Result.INVALID_ENDS_AT -> Result.failure(CreatePaymentRequestError.InvalidEndsAt()) + PaymentrequestService.CreatePaymentRequestResponse.Result.INVALID_SHARES -> Result.failure(CreatePaymentRequestError.InvalidShares()) + PaymentrequestService.CreatePaymentRequestResponse.Result.INVALID_CURRENCY -> Result.failure(CreatePaymentRequestError.InvalidCurrency()) + PaymentrequestService.CreatePaymentRequestResponse.Result.TEXT_MODERATED -> Result.failure(CreatePaymentRequestError.TextModerated(response.flaggedCategory.toFlaggedCategory())) + PaymentrequestService.CreatePaymentRequestResponse.Result.UNRECOGNIZED -> Result.failure(CreatePaymentRequestError.Unrecognized()) + else -> Result.failure(CreatePaymentRequestError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { CreatePaymentRequestError.Other(cause = it) }) + } + ) + } + + suspend fun getPaymentRequest( + owner: Ed25519.KeyPair, + paymentRequestId: Model.PaymentRequestId, + ): Result { + return runCatching { + api.getPaymentRequest(owner, paymentRequestId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + PaymentrequestService.GetPaymentRequestResponse.Result.OK -> Result.success(PaymentRequestDetails( + paymentRequest = response.paymentRequest, + paymentIntentId = if (response.hasPaymentIntentId()) response.paymentIntentId else null, + )) + PaymentrequestService.GetPaymentRequestResponse.Result.DENIED -> Result.failure(GetPaymentRequestError.Denied()) + PaymentrequestService.GetPaymentRequestResponse.Result.NOT_FOUND -> Result.failure(GetPaymentRequestError.NotFound()) + PaymentrequestService.GetPaymentRequestResponse.Result.UNRECOGNIZED -> Result.failure(GetPaymentRequestError.Unrecognized()) + else -> Result.failure(GetPaymentRequestError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetPaymentRequestError.Other(cause = it) }) + } + ) + } + + suspend fun getChatPaymentRequests( + owner: Ed25519.KeyPair, + chatId: ChatId, + section: PaymentRequestSection, + queryOptions: QueryOptions, + ): Result { + return runCatching { + api.getChatPaymentRequests(owner, chatId, section, queryOptions) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + PaymentrequestService.GetChatPaymentRequestsResponse.Result.OK -> Result.success(PaymentRequestPage( + paymentRequests = response.paymentRequestsList, + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + )) + PaymentrequestService.GetChatPaymentRequestsResponse.Result.DENIED -> Result.failure(GetChatPaymentRequestsError.Denied()) + PaymentrequestService.GetChatPaymentRequestsResponse.Result.NOT_FOUND -> Result.failure(GetChatPaymentRequestsError.NotFound()) + PaymentrequestService.GetChatPaymentRequestsResponse.Result.UNRECOGNIZED -> Result.failure(GetChatPaymentRequestsError.Unrecognized()) + else -> Result.failure(GetChatPaymentRequestsError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetChatPaymentRequestsError.Other(cause = it) }) + } + ) + } + + suspend fun closePaymentRequest( + owner: Ed25519.KeyPair, + paymentRequestId: Model.PaymentRequestId, + ): Result { + return runCatching { + api.closePaymentRequest(owner, paymentRequestId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + PaymentrequestService.ClosePaymentRequestResponse.Result.OK -> Result.success(response.paymentRequest) + PaymentrequestService.ClosePaymentRequestResponse.Result.DENIED -> Result.failure(ClosePaymentRequestError.Denied()) + PaymentrequestService.ClosePaymentRequestResponse.Result.NOT_FOUND -> Result.failure(ClosePaymentRequestError.NotFound()) + PaymentrequestService.ClosePaymentRequestResponse.Result.UNRECOGNIZED -> Result.failure(ClosePaymentRequestError.Unrecognized()) + else -> Result.failure(ClosePaymentRequestError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { ClosePaymentRequestError.Other(cause = it) }) + } + ) + } + + suspend fun markPaid( + owner: Ed25519.KeyPair, + paymentRequestId: Model.PaymentRequestId, + userId: ID, + ): Result { + return runCatching { + api.markPaid(owner, paymentRequestId, userId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + PaymentrequestService.MarkPaidResponse.Result.OK -> Result.success(response.paymentRequest) + PaymentrequestService.MarkPaidResponse.Result.DENIED -> Result.failure(MarkPaymentRequestPaidError.Denied()) + PaymentrequestService.MarkPaidResponse.Result.NOT_FOUND -> Result.failure(MarkPaymentRequestPaidError.NotFound()) + PaymentrequestService.MarkPaidResponse.Result.NOT_OPEN -> Result.failure(MarkPaymentRequestPaidError.NotOpen()) + PaymentrequestService.MarkPaidResponse.Result.NOT_A_SHARE -> Result.failure(MarkPaymentRequestPaidError.NotAShare()) + PaymentrequestService.MarkPaidResponse.Result.UNRECOGNIZED -> Result.failure(MarkPaymentRequestPaidError.Unrecognized()) + else -> Result.failure(MarkPaymentRequestPaidError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { MarkPaymentRequestPaidError.Other(cause = it) }) + } + ) + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt index 4084edaec9..c63b896580 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalChatRepository.kt @@ -6,6 +6,7 @@ import com.flipcash.services.internal.network.extensions.toChatMember import com.flipcash.services.internal.network.extensions.toId import com.flipcash.services.internal.network.extensions.toKeyEnvelope import com.flipcash.services.internal.network.extensions.toLobbyMember +import com.flipcash.services.internal.network.extensions.toRestrictedUser import com.flipcash.services.internal.network.extensions.toPagingToken import com.flipcash.services.internal.network.extensions.toRosterSummary import com.flipcash.services.internal.network.extensions.toViewerState @@ -25,6 +26,8 @@ import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.RestrictedUsersPage +import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters @@ -228,6 +231,40 @@ internal class InternalChatRepository( service.denyLobbyMember(owner, chatId, userId) .onFailure { ErrorUtils.handleError(it) } + override suspend fun silenceUser( + owner: KeyPair, + chatId: ChatId, + userId: ID, + silence: SilenceState, + ): Result = service.silenceUser(owner, chatId, userId, silence) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun unsilenceUser(owner: KeyPair, chatId: ChatId, userId: ID): Result = + service.unsilenceUser(owner, chatId, userId) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun banUser(owner: KeyPair, chatId: ChatId, userId: ID): Result = + service.banUser(owner, chatId, userId) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun unbanUser(owner: KeyPair, chatId: ChatId, userId: ID): Result = + service.unbanUser(owner, chatId, userId) + .onFailure { ErrorUtils.handleError(it) } + + override suspend fun getRestrictedUsers( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result = service.getRestrictedUsers(owner, chatId, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + .map { response -> + RestrictedUsersPage( + users = response.usersList.map { it.toRestrictedUser() }, + pagingToken = if (response.hasPagingToken()) response.pagingToken.toPagingToken() else null, + hasMore = response.hasMore, + ) + } + override suspend fun setKeyEnvelope( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPaymentRequestRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPaymentRequestRepository.kt new file mode 100644 index 0000000000..528f8f99bf --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalPaymentRequestRepository.kt @@ -0,0 +1,68 @@ +package com.flipcash.services.internal.repositories + +import com.codeinc.flipcash.gen.paymentrequest.v1.Model +import com.flipcash.services.internal.network.services.PaymentRequestService +import com.flipcash.services.models.CreatePaymentRequestError +import com.flipcash.services.models.GetChatPaymentRequestsError +import com.flipcash.services.models.GetPaymentRequestError +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequestDetails +import com.flipcash.services.models.paymentrequest.PaymentRequestPage +import com.flipcash.services.models.paymentrequest.PaymentRequestSection +import com.flipcash.services.repository.PaymentRequestRepository +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.model.core.ID +import com.getcode.utils.ErrorUtils + +internal class InternalPaymentRequestRepository( + private val service: PaymentRequestService, +) : PaymentRequestRepository { + override suspend fun createPaymentRequest( + owner: KeyPair, + groupChat: Model.GroupChatPaymentRequest, + idempotencyKey: Model.IdempotencyKey, + ): Result { + return service.createPaymentRequest(owner, groupChat, idempotencyKey) + .onFailure { + if (it !is CreatePaymentRequestError.Denied && it !is CreatePaymentRequestError.TextModerated) { + ErrorUtils.handleError(it) + } + } + } + + override suspend fun getPaymentRequest( + owner: KeyPair, + paymentRequestId: Model.PaymentRequestId, + ): Result { + return service.getPaymentRequest(owner, paymentRequestId) + .onFailure { if (it !is GetPaymentRequestError.NotFound) ErrorUtils.handleError(it) } + } + + override suspend fun getChatPaymentRequests( + owner: KeyPair, + chatId: ChatId, + section: PaymentRequestSection, + queryOptions: QueryOptions, + ): Result { + return service.getChatPaymentRequests(owner, chatId, section, queryOptions) + .onFailure { ErrorUtils.handleError(it) } + } + + override suspend fun closePaymentRequest( + owner: KeyPair, + paymentRequestId: Model.PaymentRequestId, + ): Result { + return service.closePaymentRequest(owner, paymentRequestId) + .onFailure { ErrorUtils.handleError(it) } + } + + override suspend fun markPaid( + owner: KeyPair, + paymentRequestId: Model.PaymentRequestId, + userId: ID, + ): Result { + return service.markPaid(owner, paymentRequestId, userId) + .onFailure { ErrorUtils.handleError(it) } + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index c8a8512c64..b8feff730b 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -465,6 +465,9 @@ sealed class JoinChatError( // The caller does not meet the chat's listener rules — a minimum balance, or staff // membership. Recoverable by the user, so it is deliberately not a NotifiableError. class RulesNotSatisfied : JoinChatError("Rules not satisfied") + // The creator or a moderator banned the caller from this chat. Not recoverable by the user, + // so deliberately not a NotifiableError. + class Banned : JoinChatError("Banned") class Unrecognized : JoinChatError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : JoinChatError(message = cause?.message, cause = cause), NotifiableError } @@ -828,6 +831,8 @@ sealed class EnterLobbyError( class LobbyFull : EnterLobbyError("Lobby full") // The caller is already waiting in as many lobbies as the server allows. class TooManyLobbies : EnterLobbyError("Too many lobbies") + // The caller is banned from this chat, so they cannot ask to join it. + class Banned : EnterLobbyError("Banned") class Unrecognized : EnterLobbyError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : EnterLobbyError(message = cause?.message, cause = cause), NotifiableError } @@ -963,3 +968,117 @@ sealed class VoteError( class Unrecognized : VoteError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : VoteError(message = cause?.message, cause = cause), NotifiableError } + +sealed class SilenceUserError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : SilenceUserError("Denied") + class NotFound : SilenceUserError("Not found") + // The target user is the chat's creator. + class CannotRestrictCreator : SilenceUserError("Cannot restrict the chat creator") + class Unrecognized : SilenceUserError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SilenceUserError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class UnsilenceUserError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : UnsilenceUserError("Denied") + class NotFound : UnsilenceUserError("Not found") + class Unrecognized : UnsilenceUserError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : UnsilenceUserError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class BanUserError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : BanUserError("Denied") + class NotFound : BanUserError("Not found") + // The target user is the chat's creator. + class CannotRestrictCreator : BanUserError("Cannot restrict the chat creator") + class Unrecognized : BanUserError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : BanUserError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class UnbanUserError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : UnbanUserError("Denied") + class NotFound : UnbanUserError("Not found") + class Unrecognized : UnbanUserError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : UnbanUserError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetRestrictedUsersError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetRestrictedUsersError("Denied") + class NotFound : GetRestrictedUsersError("Not found") + class Unrecognized : GetRestrictedUsersError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetRestrictedUsersError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class CreatePaymentRequestError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : CreatePaymentRequestError("Denied") + class ChatNotSupported : CreatePaymentRequestError("Chat does not support payment requests") + class EncryptionNotAllowed : CreatePaymentRequestError("Encryption not allowed") + class EncryptionRequired : CreatePaymentRequestError("Encryption required") + class InvalidEndsAt : CreatePaymentRequestError("Invalid end time") + class InvalidShares : CreatePaymentRequestError("Invalid shares") + class InvalidCurrency : CreatePaymentRequestError("Invalid currency") + class TextModerated(val category: ModerationResult.FlaggedCategory) : CreatePaymentRequestError("Payment request text flagged: $category") + class Unrecognized : CreatePaymentRequestError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : CreatePaymentRequestError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetPaymentRequestError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetPaymentRequestError("Denied") + class NotFound : GetPaymentRequestError("Not found") + class Unrecognized : GetPaymentRequestError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetPaymentRequestError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class GetChatPaymentRequestsError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetChatPaymentRequestsError("Denied") + class NotFound : GetChatPaymentRequestsError("Not found") + class Unrecognized : GetChatPaymentRequestsError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetChatPaymentRequestsError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class ClosePaymentRequestError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : ClosePaymentRequestError("Denied") + class NotFound : ClosePaymentRequestError("Not found") + class Unrecognized : ClosePaymentRequestError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : ClosePaymentRequestError(message = cause?.message, cause = cause), NotifiableError +} + +sealed class MarkPaymentRequestPaidError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : MarkPaymentRequestPaidError("Denied") + class NotFound : MarkPaymentRequestPaidError("Not found") + // The request is no longer open. + class NotOpen : MarkPaymentRequestPaidError("Not open") + // The user does not owe a share of this request. + class NotAShare : MarkPaymentRequestPaidError("Not a share") + class Unrecognized : MarkPaymentRequestPaidError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : MarkPaymentRequestPaidError(message = cause?.message, cause = cause), NotifiableError +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/RequestedPaymentMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/RequestedPaymentMetadata.kt new file mode 100644 index 0000000000..163f993a11 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/RequestedPaymentMetadata.kt @@ -0,0 +1,34 @@ +package com.flipcash.services.models + +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.intent.v1.Model as FlipcashIntentModel +import com.codeinc.flipcash.gen.paymentrequest.v1.Model as PaymentRequestModel +import com.flipcash.services.models.chat.ChatId +import com.getcode.utils.toByteString + +/** + * Builds the serialized Flipcash `AppMetadata` proto bytes for a payment of the caller's share of + * payment request [paymentRequestId] in [chatId]. The intent that carries this is a single public + * payment to the request's creator, using the intent id `GetPaymentRequest` returned. + * + * Returns `null` when [chatId] or [paymentRequestId] is missing so the caller can pass the result + * through unconditionally. + */ +fun buildRequestedPaymentMetadata( + chatId: ChatId?, + paymentRequestId: ByteArray?, +): ByteArray? { + if (chatId == null || paymentRequestId == null) return null + return FlipcashIntentModel.AppMetadata.newBuilder() + .setChat( + FlipcashIntentModel.ChatMetadata.newBuilder() + .setChatId(Common.ChatId.newBuilder().setValue(chatId.bytes.toByteString())) + .setRequestedPayment( + FlipcashIntentModel.ChatMetadata.RequestedPayment.newBuilder() + .setPaymentRequestId( + PaymentRequestModel.PaymentRequestId.newBuilder() + .setValue(paymentRequestId.toByteString()) + ) + ) + ).build().toByteArray() +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 9e9beb8bae..02cdcdc524 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -20,9 +20,10 @@ sealed interface MessageContent { val tokenImageUrl: String = "", val action: Action = Action.SENT, /** - * Who was paid (`CashContent.receiver`). Always set in a group chat. In a DM it is set on - * new messages and unset on older ones, whose receiver is the DM member who is not the - * sender; null here means the message did not carry one. + * Who was paid by a single-receiver payment (`CashContent.single_receiver`). In a DM it is + * set on new messages and unset on older ones, whose receiver is the DM member who is not + * the sender. Null here means the message did not carry one, which includes every + * multi-receiver payment: read [multiReceiver] for those. */ val receiver: ID? = null, /** @@ -31,7 +32,27 @@ sealed interface MessageContent { * was kept have no value, so callers must not read null as zero. */ val tokenQuarks: Long? = null, + /** + * Each receiver's part of [amount] (`CashContent.multi_receiver`), in [amount]'s currency + * and [mint]. Empty unless this is a multi-receiver payment, which only occurs in group + * chats; then [receiver] is null and [amount] is the whole payment. + */ + val multiReceiver: List = emptyList(), + /** + * The payment request whose share this payment paid (`CashContent.payment_request_id`); + * null for any other cash message. + */ + val paymentRequestId: ID? = null, ) : MessageContent { + /** One receiver's part of a multi-receiver payment (`CashContent.Payment`). */ + data class Payment( + val receiver: ID, + /** Fiat value in the payment's currency. */ + val nativeAmount: Double, + /** On-chain token quarks of the payment's mint. */ + val tokenQuarks: Long, + ) + enum class Action { SENT, TIPPED, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Moderation.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Moderation.kt new file mode 100644 index 0000000000..903b6f0954 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/Moderation.kt @@ -0,0 +1,37 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.PagingToken +import com.flipcash.services.models.UserProfile +import kotlin.time.Instant + +/** + * How long a user is silenced in a chat. Mirrors `chat.v1.SilenceState`. A silenced user stays a + * member and can read, but cannot send. + */ +sealed class SilenceState { + /** Silenced until [until]; the client owns the countdown, nothing is sent when it lapses. */ + data class Until(val until: Instant) : SilenceState() + + /** Silenced until explicitly unsilenced. */ + data object Forever : SilenceState() +} + +/** A user's ban from a chat. Mirrors `chat.v1.BanState`. */ +data class BanState(val bannedAt: Instant) + +/** + * A user a chat's moderators have restricted. At least one of [silence] and [ban] is normally + * set. Mirrors `chat.v1.RestrictedUser`. + */ +data class RestrictedUser( + val userProfile: UserProfile, + val silence: SilenceState?, + val ban: BanState?, +) + +/** One page of a chat's restricted users. Page with [pagingToken] while [hasMore]. */ +data class RestrictedUsersPage( + val users: List, + val pagingToken: PagingToken?, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt index 4eca19c89e..f479c5ef61 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ViewerState.kt @@ -13,9 +13,25 @@ data class ViewerState( // Server-computed grants for the viewer. Never client-derivable; defaults false until // the server says otherwise. val permissions: Permissions = Permissions(), + // Present if and only if a moderator restricts the viewer in this chat. While a silence is + // set the composer is hidden; the client owns the countdown for a timed silence. + val restrictions: Restrictions? = null, ) { data class Permissions( // Whether the viewer may call EditChat on this chat. val canEdit: Boolean = false, + // Which moderation actions the viewer may take on other users. + val moderation: ModerationPermissions = ModerationPermissions(), + ) + + data class ModerationPermissions( + // Whether the viewer may call SilenceUser / UnsilenceUser. + val canSilence: Boolean = false, + // Whether the viewer may call BanUser / UnbanUser. + val canBan: Boolean = false, + ) + + data class Restrictions( + val silence: SilenceState? = null, ) } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestModels.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestModels.kt new file mode 100644 index 0000000000..7491bff578 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/paymentrequest/PaymentRequestModels.kt @@ -0,0 +1,31 @@ +package com.flipcash.services.models.paymentrequest + +import com.codeinc.flipcash.gen.common.v1.Common +import com.codeinc.flipcash.gen.paymentrequest.v1.Model +import com.flipcash.services.models.PagingToken + +/** Which half of a chat's payment requests to list. Mirrors `GetChatPaymentRequestsRequest.Section`. */ +enum class PaymentRequestSection { + /** Requests still awaiting payment. */ + OPEN, + + /** Requests that are completed, closed or expired. */ + RESOLVED, +} + +/** + * A payment request, with the intent id the caller's share payment must use. [paymentIntentId] is + * set only for a caller who holds an unpaid share. The request is the generated + * `paymentrequest.v1` type until a UI consumes it. + */ +data class PaymentRequestDetails( + val paymentRequest: Model.PaymentRequestMetadata, + val paymentIntentId: Common.IntentId?, +) + +/** One page of a chat's payment requests. Page with [pagingToken] while [hasMore]. */ +data class PaymentRequestPage( + val paymentRequests: List, + val pagingToken: PagingToken?, + val hasMore: Boolean, +) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt index 7adaaa9b88..4acba2852d 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ChatRepository.kt @@ -14,6 +14,8 @@ import com.flipcash.services.models.chat.KeyEnvelope import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.RestrictedUsersPage +import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.MentionSuggestion import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.StartChatParameters @@ -177,6 +179,32 @@ interface ChatRepository { /** Removes [userId] from [chatId]'s lobby without admitting them. Creator only. */ suspend fun denyLobbyMember(owner: KeyPair, chatId: ChatId, userId: ID): Result + /** + * Silences [userId] in [chatId] for the duration in [silence]. A silenced user stays a member + * and can read but not send. Fails with `SilenceUserError.CannotRestrictCreator` for the + * chat's creator. + */ + suspend fun silenceUser(owner: KeyPair, chatId: ChatId, userId: ID, silence: SilenceState): Result + + /** Lifts [userId]'s silence in [chatId]. */ + suspend fun unsilenceUser(owner: KeyPair, chatId: ChatId, userId: ID): Result + + /** + * Bans [userId] from [chatId]. Fails with `BanUserError.CannotRestrictCreator` for the chat's + * creator. + */ + suspend fun banUser(owner: KeyPair, chatId: ChatId, userId: ID): Result + + /** Lifts [userId]'s ban from [chatId]. */ + suspend fun unbanUser(owner: KeyPair, chatId: ChatId, userId: ID): Result + + /** One page of the users silenced or banned in [chatId]. Callers who can silence or ban only. */ + suspend fun getRestrictedUsers( + owner: KeyPair, + chatId: ChatId, + queryOptions: QueryOptions = QueryOptions(), + ): Result + /** * Stores the caller's own key envelope for [chatId]. [SetKeyEnvelopeError.AlreadySet] means a * different envelope already stands; it is surfaced rather than recovered because whether to diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PaymentRequestRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PaymentRequestRepository.kt new file mode 100644 index 0000000000..da08dbfd5a --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/PaymentRequestRepository.kt @@ -0,0 +1,48 @@ +package com.flipcash.services.repository + +import com.codeinc.flipcash.gen.paymentrequest.v1.Model +import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequestDetails +import com.flipcash.services.models.paymentrequest.PaymentRequestPage +import com.flipcash.services.models.paymentrequest.PaymentRequestSection +import com.getcode.ed25519.Ed25519.KeyPair +import com.getcode.opencode.model.core.ID + +/** + * Payment requests in group chats. Request values are the generated `paymentrequest.v1` types + * for now; a domain model is deferred until a UI consumes them. The server sends no events for + * payment requests, so callers refetch. + */ +interface PaymentRequestRepository { + suspend fun createPaymentRequest( + owner: KeyPair, + groupChat: Model.GroupChatPaymentRequest, + idempotencyKey: Model.IdempotencyKey, + ): Result + + suspend fun getPaymentRequest( + owner: KeyPair, + paymentRequestId: Model.PaymentRequestId, + ): Result + + suspend fun getChatPaymentRequests( + owner: KeyPair, + chatId: ChatId, + section: PaymentRequestSection, + queryOptions: QueryOptions = QueryOptions(), + ): Result + + /** Closes the request. Creator only. */ + suspend fun closePaymentRequest( + owner: KeyPair, + paymentRequestId: Model.PaymentRequestId, + ): Result + + /** Marks [userId]'s share paid outside the app. Creator only. */ + suspend fun markPaid( + owner: KeyPair, + paymentRequestId: Model.PaymentRequestId, + userId: ID, + ): Result +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt index d4c2fa2f53..8385188776 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ChatControllerTest.kt @@ -14,6 +14,8 @@ import com.flipcash.services.models.chat.Lobby import com.flipcash.services.models.chat.LobbyPage import com.flipcash.services.models.chat.StoredKeyEnvelope import com.flipcash.services.models.chat.MuteState +import com.flipcash.services.models.chat.RestrictedUsersPage +import com.flipcash.services.models.chat.SilenceState import com.flipcash.services.models.chat.RosterPage import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.StartChatParameters @@ -649,6 +651,28 @@ private class FakeChatRepository : ChatRepository { return admitLobbyMemberResult } + override suspend fun silenceUser( + owner: Ed25519.KeyPair, + chatId: ChatId, + userId: ID, + silence: SilenceState, + ): Result = Result.success(Unit) + + override suspend fun unsilenceUser(owner: Ed25519.KeyPair, chatId: ChatId, userId: ID): Result = + Result.success(Unit) + + override suspend fun banUser(owner: Ed25519.KeyPair, chatId: ChatId, userId: ID): Result = + Result.success(Unit) + + override suspend fun unbanUser(owner: Ed25519.KeyPair, chatId: ChatId, userId: ID): Result = + Result.success(Unit) + + override suspend fun getRestrictedUsers( + owner: Ed25519.KeyPair, + chatId: ChatId, + queryOptions: QueryOptions, + ): Result = Result.success(RestrictedUsersPage(emptyList(), null, false)) + override suspend fun denyLobbyMember(owner: Ed25519.KeyPair, chatId: ChatId, userId: ID): Result { lastChatId = chatId return denyLobbyMemberResult diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt index 734c64a7f5..550c0be866 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/internal/network/extensions/CashContentMappingTest.kt @@ -16,14 +16,14 @@ class CashContentMappingTest { .setCash( MessagingModel.CashContent.newBuilder() .setIntentId(Common.IntentId.newBuilder().setValue(ByteString.copyFrom(byteArrayOf(1, 2, 3)))) - .setAmount( + .setTotalAmount( Common.CryptoPaymentAmount.newBuilder() .setQuarks(quarks) .setCurrency("usd") .setNativeAmount(2.5) .setMint(Common.PublicKey.newBuilder().setValue(ByteString.copyFrom(Mint.usdc.bytes.toByteArray()))) ) - .apply { receiver?.let { setReceiver(Common.UserId.newBuilder().setValue(ByteString.copyFrom(it))) } } + .apply { receiver?.let { setSingleReceiver(Common.UserId.newBuilder().setValue(ByteString.copyFrom(it))) } } ) .build() @@ -39,7 +39,7 @@ class CashContentMappingTest { fun `token quarks are written back to the wire when known`() { val mapped = cash(7_654_321L).toMessageContent() - assertEquals(7_654_321L, mapped.asContent().cash.amount.quarks) + assertEquals(7_654_321L, mapped.asContent().cash.totalAmount.quarks) } @Test @@ -56,7 +56,47 @@ class CashContentMappingTest { val with = cash(1L, receiver = byteArrayOf(9, 8, 7)).toMessageContent().asContent().cash val without = cash(1L).toMessageContent().asContent().cash - assertEquals(listOf(9, 8, 7), with.receiver.value.toByteArray().toList()) - assertFalse(without.hasReceiver()) + assertEquals(listOf(9, 8, 7), with.singleReceiver.value.toByteArray().toList()) + assertFalse(without.hasSingleReceiver()) } + + @Test + fun `a multi-receiver payment and its payment request id survive the round trip`() { + val wire = MessagingModel.Content.newBuilder().setCash( + cash(10L).cash.toBuilder() + .setMultiReceiver( + MessagingModel.CashContent.Payments.newBuilder() + .addItems(payment(byteArrayOf(1), 1.25, 4L)) + .addItems(payment(byteArrayOf(2), 1.25, 6L)) + ) + .setPaymentRequestId( + com.codeinc.flipcash.gen.paymentrequest.v1.Model.PaymentRequestId.newBuilder() + .setValue(ByteString.copyFrom(ByteArray(16) { it.toByte() })) + ) + ).build() + + val mapped = wire.toMessageContent() as MessageContent.Cash + + assertNull(mapped.receiver) + assertEquals( + listOf( + MessageContent.Cash.Payment(listOf(1), 1.25, 4L), + MessageContent.Cash.Payment(listOf(2), 1.25, 6L), + ), + mapped.multiReceiver, + ) + assertEquals(ByteArray(16) { it.toByte() }.toList(), mapped.paymentRequestId) + + val back = mapped.asContent().cash + assertEquals(MessagingModel.CashContent.RecipientCase.MULTI_RECEIVER, back.recipientCase) + assertEquals(2, back.multiReceiver.itemsCount) + assertEquals(wire.cash.paymentRequestId, back.paymentRequestId) + } + + private fun payment(receiver: ByteArray, nativeAmount: Double, quarks: Long) = + MessagingModel.CashContent.Payment.newBuilder() + .setReceiver(Common.UserId.newBuilder().setValue(ByteString.copyFrom(receiver))) + .setNativeAmount(nativeAmount) + .setQuarks(quarks) + .build() }