diff --git a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt index 5a7bc0e832..e392763563 100644 --- a/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt +++ b/apps/flipcash/core/src/commonMain/kotlin/com/flipcash/app/core/feed/ActivityFeedMessage.kt @@ -2,6 +2,7 @@ package com.flipcash.app.core.feed import com.flipcash.services.models.BoostedReaction import com.flipcash.services.models.RequestedPayment +import com.flipcash.services.models.ReturnedPayment import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat @@ -115,6 +116,8 @@ sealed interface MessageMetadata { val boostedReaction: BoostedReaction? = null, // Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set. val requestedPayment: RequestedPayment? = null, + // Defaulted like [boostedReaction]; the same oneof on the wire. + val returnedPayment: ReturnedPayment? = null, ) : MessageMetadata @Serializable @@ -131,6 +134,8 @@ sealed interface MessageMetadata { val boostedReaction: BoostedReaction? = null, // Defaulted like [boostedReaction]. A oneof with it on the wire, so at most one is set. val requestedPayment: RequestedPayment? = null, + // Defaulted like [boostedReaction]; the same oneof on the wire. + val returnedPayment: ReturnedPayment? = null, ) : MessageMetadata /** diff --git a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt index b9274a8741..04b87ea4ec 100644 --- a/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt +++ b/apps/flipcash/shared/persistence/db/src/commonMain/kotlin/com/flipcash/app/persistence/converters/ChatTypeConverters.kt @@ -218,8 +218,17 @@ sealed interface MessageContentSerialized { val multiReceiver: List = emptyList(), // Base58, like [intentId]. Null unless the payment paid a payment-request share. val paymentRequestId: String? = null, + // Null for rows stored before this existed, and for payments with no return involvement. + val paymentReturn: CashPaymentReturn? = null, ) : MessageContentSerialized + @Serializable + data class CashPaymentReturn( + val offered: Boolean = false, + // The message id of the payment this one returns; null if it is not a return. + val returnOfMessageId: Long? = null, + ) + @Serializable data class CashPayment( // Base58, like [Cash.receiver]. diff --git a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 36f6e829df..7fd21cd0cf 100644 --- a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -450,6 +450,12 @@ private fun MessageContent.toSerialized(): MessageContentSerialized = when (this ) }, paymentRequestId = paymentRequestId?.base58, + paymentReturn = paymentReturn?.let { + MessageContentSerialized.CashPaymentReturn( + offered = it.offer != null, + returnOfMessageId = it.returnOf?.messageId, + ) + }, ) is MessageContent.Reply -> MessageContentSerialized.Reply( repliedMessageId = repliedMessageId, @@ -516,6 +522,12 @@ private fun MessageContentSerialized.toDomain(): MessageContent = when (this) { ) }, paymentRequestId = paymentRequestId?.decodeBase58()?.toList(), + paymentReturn = paymentReturn?.let { + MessageContent.Cash.PaymentReturn( + offer = if (it.offered) MessageContent.Cash.PaymentReturn.Offer else null, + returnOf = it.returnOfMessageId?.let(MessageContent.Cash.PaymentReturn::ReturnOf), + ) + }, ) is MessageContentSerialized.Reply -> MessageContent.Reply( repliedMessageId = repliedMessageId, diff --git a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt index 655120a51f..5f147a2043 100644 --- a/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/commonMain/kotlin/com/flipcash/app/persistence/sources/mapper/notifications/NotificationToEntityMapper.kt @@ -71,8 +71,8 @@ class MetadataMapper @Inject constructor(): Mapper MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment) - is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment) + is NotificationMetadata.DirectlySentCrypto -> MessageMetadata.DirectlySentCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment, from.returnedPayment) + is NotificationMetadata.ReceivedCrypto -> MessageMetadata.ReceivedCrypto(from.phoneNumber, from.userId, from.boostedReaction, from.requestedPayment, from.returnedPayment) is NotificationMetadata.IndirectlySentCrypto -> MessageMetadata.IndirectlySentCrypto(from.creator, from.canCancel) NotificationMetadata.Unknown -> MessageMetadata.Unknown is NotificationMetadata.WithdrewCrypto -> MessageMetadata.WithdrewCrypto(from.swapMetadata?.toMessage()) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b423c7813..5d89649c56 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,7 +74,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.24.0" +flipcash2-client-protocol = "0.25.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/androidHostTest/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt b/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt index 1da44fb5b1..413890dc1b 100644 --- a/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt +++ b/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/controllers/ChatMessagingControllerTest.kt @@ -1,6 +1,7 @@ package com.flipcash.services.controllers import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.CashMessageReturnStatus import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId @@ -584,6 +585,13 @@ private class FakeChatMessagingRepository : ChatMessagingRepository { return getMessageResult } + var getCashMessageReturnStatusResult: Result = Result.failure(RuntimeException("not configured")) + + override suspend fun getCashMessageReturnStatus(owner: Ed25519.KeyPair, chatId: ChatId, messageId: Long): Result { + lastChatId = chatId; lastMessageId = messageId + return getCashMessageReturnStatusResult + } + override suspend fun getMessages(owner: Ed25519.KeyPair, chatId: ChatId, queryOptions: QueryOptions, viewMode: ViewMode): Result> { lastChatId = chatId; lastQueryOptions = queryOptions return getMessagesResult diff --git a/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt b/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt index f70019ce3a..9f6a04593a 100644 --- a/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt +++ b/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapperTest.kt @@ -393,4 +393,39 @@ class ActivityFeedMessageMapperTest { assertNotNull(amount) assertEquals(12_000_000L, amount.underlyingTokenAmount.quarks) } + + // --- Returned payment --- + + private fun returnedPayment() = Model.ReturnedPayment.newBuilder() + .setChatId(com.codeinc.flipcash.gen.common.v1.Common.ChatId.newBuilder().setValue(ByteString.copyFrom(ByteArray(32) { 7 }))) + .setMessageId(com.codeinc.flipcash.gen.messaging.v1.Model.MessageId.newBuilder().setValue(99L)) + .build() + + @Test + fun `returned payment on a sent notification is mapped`() { + val proto = baseNotification { + directlySentCrypto = Model.DirectlySentCryptoNotificationMetadata.newBuilder() + .setReturnedPayment(returnedPayment()) + .build() + } + + val meta = assertIs(mapper.map(proto).metadata) + + assertEquals(99L, meta.returnedPayment?.messageId) + assertNull(meta.boostedReaction) + assertNull(meta.requestedPayment) + } + + @Test + fun `returned payment on a received notification is mapped`() { + val proto = baseNotification { + receivedCrypto = Model.ReceivedCryptoNotificationMetadata.newBuilder() + .setReturnedPayment(returnedPayment()) + .build() + } + + val meta = assertIs(mapper.map(proto).metadata) + + assertEquals(99L, meta.returnedPayment?.messageId) + } } diff --git a/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/models/DmPaymentMetadataTest.kt b/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/models/DmPaymentMetadataTest.kt index 757bc1d1c5..b882f9af05 100644 --- a/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/models/DmPaymentMetadataTest.kt +++ b/services/flipcash/src/androidHostTest/kotlin/com/flipcash/services/models/DmPaymentMetadataTest.kt @@ -100,4 +100,23 @@ class DmPaymentMetadataTest { } private fun bytes(size: Int, value: (Int) -> Int) = ByteArray(size) { value(it).toByte() } + + @Test + fun `return payment metadata carries the chat and the returned message id`() { + val bytes = buildReturnPaymentMetadata(chatId = chatId, messageId = 42L) + + val decoded = FlipcashIntentModel.AppMetadata.parseFrom(bytes!!) + + assertEquals(chatId.bytes.toByteString(), decoded.chat.chatId.value) + assertEquals(42L, decoded.chat.returnPayment.messageId.value) + assertEquals( + FlipcashIntentModel.ChatMetadata.TypeCase.RETURN_PAYMENT, + decoded.chat.typeCase, + ) + } + + @Test + fun `return payment metadata is null without a chat id`() { + assertNull(buildReturnPaymentMetadata(chatId = null, messageId = 42L)) + } } diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt index 8813c0d939..dda0e2371e 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/controllers/ChatMessagingController.kt @@ -1,6 +1,7 @@ package com.flipcash.services.controllers import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.CashMessageReturnStatus import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId @@ -36,6 +37,18 @@ class ChatMessagingController @Inject constructor( return repository.getMessage(owner, chatId, messageId, viewMode) } + /** + * Whether [messageId]'s payment, in the DM [chatId], can still be returned. A + * [CashMessageReturnStatus.Status.RETURNED] answer is terminal, so callers can stop asking. + */ + suspend fun getCashMessageReturnStatus( + chatId: ChatId, + messageId: Long, + ): Result { + val owner = runCatching { requireOwner() }.getOrElse { return Result.failure(it) } + return repository.getCashMessageReturnStatus(owner, chatId, messageId) + } + suspend fun getMessages( chatId: ChatId, queryOptions: QueryOptions = QueryOptions(), diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt index 812e12d947..4971b7b6be 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/domain/ActivityFeedMessageMapper.kt @@ -15,6 +15,7 @@ import com.flipcash.services.internal.network.extensions.toPublicKey import com.flipcash.services.models.ActivityFeedNotification import com.flipcash.services.models.BoostedReaction import com.flipcash.services.models.RequestedPayment +import com.flipcash.services.models.ReturnedPayment import com.flipcash.services.models.NotificationMetadata import com.flipcash.services.models.chat.Emoji import com.flipcash.services.internal.network.extensions.toChatId @@ -57,6 +58,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( ) meta.userId.value.toByteArray().toList() else null, boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null, requestedPayment = if (meta.hasRequestedPayment()) meta.requestedPayment.toDomain() else null, + returnedPayment = if (meta.hasReturnedPayment()) meta.returnedPayment.toDomain() else null, ) } Model.Notification.AdditionalMetadataCase.RECEIVED_CRYPTO -> { @@ -70,6 +72,7 @@ internal class ActivityFeedMessageMapper @Inject constructor( ) meta.userId.value.toByteArray().toList() else null, boostedReaction = if (meta.hasBoostedReaction()) meta.boostedReaction.toDomain() else null, requestedPayment = if (meta.hasRequestedPayment()) meta.requestedPayment.toDomain() else null, + returnedPayment = if (meta.hasReturnedPayment()) meta.returnedPayment.toDomain() else null, ) } Model.Notification.AdditionalMetadataCase.WITHDREW_CRYPTO -> NotificationMetadata.WithdrewCrypto( @@ -180,3 +183,8 @@ private fun Model.BoostedReaction.toDomain(): BoostedReaction = BoostedReaction( messageId = messageId.value, emoji = Emoji(emoji.value), ) + +private fun Model.ReturnedPayment.toDomain(): ReturnedPayment = ReturnedPayment( + chatId = chatId.toChatId(), + messageId = messageId.value, +) diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt index 90837231bc..330872aadd 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/api/ChatMessagingApi.kt @@ -64,6 +64,24 @@ internal class ChatMessagingApi @Inject constructor( } } + suspend fun getCashMessageReturnStatus( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): RpcMessagingService.GetCashMessageReturnStatusResponse { + val request = RpcMessagingService.GetCashMessageReturnStatusRequest.newBuilder() + .setChatId(chatId.asChatId()) + .setMessageId(MessagingModel.MessageId.newBuilder().setValue(messageId)) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.getCashMessageReturnStatus(request) + } + } + suspend fun getMessages( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 1dc827ec6e..b3d14af4ba 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -227,6 +227,18 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { emptyList() }, paymentRequestId = if (cash.hasPaymentRequestId()) cash.paymentRequestId.value.toByteArray().toList() else null, + paymentReturn = if (cash.hasPaymentReturn()) { + MessageContent.Cash.PaymentReturn( + offer = if (cash.paymentReturn.hasOffer()) MessageContent.Cash.PaymentReturn.Offer else null, + returnOf = if (cash.paymentReturn.hasReturnOf()) { + MessageContent.Cash.PaymentReturn.ReturnOf(cash.paymentReturn.returnOf.messageId.value) + } else { + null + }, + ) + } else { + null + }, ) MessagingModel.Content.TypeCase.REPLY -> MessageContent.Reply( repliedMessageId = reply.repliedMessageId.value, diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt index b275c397fc..747c70aabe 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/network/services/ChatMessagingService.kt @@ -15,7 +15,10 @@ import com.flipcash.services.models.GetReactionSummariesError import com.flipcash.services.models.GetReactionSummaryError import com.flipcash.services.models.GetReactorsError import com.flipcash.services.models.SendMessageError +import com.flipcash.services.models.GetCashMessageReturnStatusError import com.flipcash.services.models.GetMessageError +import com.flipcash.services.models.chat.CashMessageReturnStatus +import com.flipcash.services.models.paymentrequest.PaymentIntentId import com.flipcash.services.models.GetMessagesError import com.flipcash.services.models.NotifyIsTypingError import com.flipcash.services.models.PagingToken @@ -66,6 +69,44 @@ internal class ChatMessagingService @Inject constructor( ) } + suspend fun getCashMessageReturnStatus( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result { + return runCatching { + api.getCashMessageReturnStatus(owner, chatId, messageId) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + RpcMessagingService.GetCashMessageReturnStatusResponse.Result.OK -> Result.success( + CashMessageReturnStatus( + status = when (response.status) { + RpcMessagingService.GetCashMessageReturnStatusResponse.Status.AVAILABLE -> + CashMessageReturnStatus.Status.AVAILABLE + RpcMessagingService.GetCashMessageReturnStatusResponse.Status.RETURNED -> + CashMessageReturnStatus.Status.RETURNED + else -> CashMessageReturnStatus.Status.UNKNOWN + }, + paymentIntentId = if (response.hasPaymentIntentId()) { + PaymentIntentId(response.paymentIntentId.value.toByteArray()) + } else { + null + }, + ) + ) + RpcMessagingService.GetCashMessageReturnStatusResponse.Result.DENIED -> Result.failure(GetCashMessageReturnStatusError.Denied()) + RpcMessagingService.GetCashMessageReturnStatusResponse.Result.NOT_FOUND -> Result.failure(GetCashMessageReturnStatusError.NotFound()) + RpcMessagingService.GetCashMessageReturnStatusResponse.Result.UNRECOGNIZED -> Result.failure(GetCashMessageReturnStatusError.Unrecognized()) + else -> Result.failure(GetCashMessageReturnStatusError.Other()) + } + }, + onFailure = { cause -> + Result.failure(cause.toValidationOrElse { GetCashMessageReturnStatusError.Other(cause = it) }) + } + ) + } + suspend fun getMessages( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt index 5d78af8f7f..ce286cfbe9 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/internal/repositories/InternalChatMessagingRepository.kt @@ -3,6 +3,8 @@ package com.flipcash.services.internal.repositories import com.flipcash.services.internal.network.extensions.toChatMessage import com.flipcash.services.internal.network.services.ChatMessagingService import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.GetCashMessageReturnStatusError +import com.flipcash.services.models.chat.CashMessageReturnStatus import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId @@ -34,6 +36,13 @@ internal class InternalChatMessagingRepository( .onFailure { ErrorUtils.handleError(it) } .map { it.toChatMessage() } + override suspend fun getCashMessageReturnStatus( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result = service.getCashMessageReturnStatus(owner, chatId, messageId) + .onFailure { if (it !is GetCashMessageReturnStatusError.NotFound) ErrorUtils.handleError(it) } + override suspend fun getMessages( owner: KeyPair, chatId: ChatId, diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt index a4378322fa..a5e8a10975 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/ActivityFeedNotification.kt @@ -63,6 +63,8 @@ sealed interface NotificationMetadata { val boostedReaction: BoostedReaction? = null, // Mutually exclusive with [boostedReaction] (a oneof on the wire). val requestedPayment: RequestedPayment? = null, + // Same `context` oneof as [boostedReaction] and [requestedPayment], so at most one is set. + val returnedPayment: ReturnedPayment? = null, ) : NotificationMetadata /** @@ -85,6 +87,8 @@ sealed interface NotificationMetadata { val boostedReaction: BoostedReaction? = null, // Mutually exclusive with [boostedReaction] (a oneof on the wire). val requestedPayment: RequestedPayment? = null, + // Same `context` oneof as [boostedReaction] and [requestedPayment], so at most one is set. + val returnedPayment: ReturnedPayment? = null, ) : NotificationMetadata /** @@ -140,6 +144,18 @@ data class RequestedPayment( val paymentRequestId: ID, ) +/** + * Set on a sent/received payment notification when the payment returned a payment made in a DM. + * On a directly-sent payment the sender is giving back what the recipient paid them; on a received + * one the payer returned the recipient's payment. Not kept up to date: [messageId] may since have + * been deleted. + */ +@Serializable +data class ReturnedPayment( + val chatId: ChatId, + val messageId: Long, +) + enum class SwapState { UNKNOWN, PENDING, diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt index b5fbe3e1d8..f56ae4b95b 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt @@ -2,6 +2,7 @@ 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.messaging.v1.Model as MessagingModel import com.flipcash.services.models.chat.ChatId import com.getcode.utils.toByteString @@ -96,3 +97,28 @@ fun buildTipDmPaymentMetadata( ) ).build().toByteArray() } + +/** + * Builds the serialized Flipcash `AppMetadata` proto bytes for a return payment: the receiver of a + * DM cash message paying it back to its sender (`ChatMetadata.return_payment`). + * + * The intent this metadata rides on MUST be submitted under the server-issued `payment_intent_id` + * from `GetCashMessageReturnStatus`, not a generated one; the server rejects any other id. Pass it + * as `intentId` to `TransactionController.directTransfer`. Returns `null` when [chatId] is missing + * so the caller can pass the result through unconditionally. + */ +fun buildReturnPaymentMetadata( + chatId: ChatId?, + messageId: Long, +): ByteArray? { + if (chatId == null) return null + return FlipcashIntentModel.AppMetadata.newBuilder() + .setChat( + FlipcashIntentModel.ChatMetadata.newBuilder() + .setChatId(Common.ChatId.newBuilder().setValue(chatId.bytes.toByteString())) + .setReturnPayment( + FlipcashIntentModel.ChatMetadata.ReturnPayment.newBuilder() + .setMessageId(MessagingModel.MessageId.newBuilder().setValue(messageId)) + ) + ).build().toByteArray() +} diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/Errors.kt index ba70bbbdf0..e09577f7db 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/Errors.kt @@ -577,6 +577,16 @@ sealed class GetMessageError( data class Other(override val cause: Throwable? = null) : GetMessageError(message = cause?.message, cause = cause), NotifiableError } +sealed class GetCashMessageReturnStatusError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied : GetCashMessageReturnStatusError("Denied") + class NotFound : GetCashMessageReturnStatusError("Not found") + class Unrecognized : GetCashMessageReturnStatusError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : GetCashMessageReturnStatusError(message = cause?.message, cause = cause), NotifiableError +} + sealed class GetMessagesError( override val message: String? = null, override val cause: Throwable? = null diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/CashMessageReturnStatus.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/CashMessageReturnStatus.kt new file mode 100644 index 0000000000..277b86630f --- /dev/null +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/CashMessageReturnStatus.kt @@ -0,0 +1,26 @@ +package com.flipcash.services.models.chat + +import com.flipcash.services.models.paymentrequest.PaymentIntentId + +/** + * Whether the payment of a DM cash message can still be returned to its sender + * (`GetCashMessageReturnStatus`). + * + * @param paymentIntentId The intent id the return payment must be submitted under. Set only for + * the message's single receiver, and kept private to that one payment (see [PaymentIntentId]). + */ +data class CashMessageReturnStatus( + val status: Status, + val paymentIntentId: PaymentIntentId?, +) { + enum class Status { + /** The server did not say, or said something this build does not know. */ + UNKNOWN, + + /** The payment has not been returned yet. */ + AVAILABLE, + + /** Terminal: the payment was returned, so there is no reason to ask again. */ + RETURNED, + } +} diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/MessageContent.kt index 7fe5f67f99..e5dfddc3ab 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -43,7 +43,29 @@ sealed interface MessageContent { * null for any other cash message. */ val paymentRequestId: ID? = null, + /** + * What the server attached to this payment about returning it (`CashContent.payment_return`), + * set when the message was sent and never changed. Null for a payment with no return + * involvement, and for rows stored before this was kept. + */ + val paymentReturn: PaymentReturn? = null, ) : MessageContent { + /** + * `CashContent.Return`. [offer] and [returnOf] are independent: the first says the payer + * offers the receiver a return of this payment, the second says this payment is itself a + * return of an earlier message's payment. + */ + data class PaymentReturn( + val offer: Offer? = null, + val returnOf: ReturnOf? = null, + ) { + /** `Return.Offer`; carries nothing, its presence is the offer. */ + data object Offer + + /** `Return.ReturnOf`: this payment returns the payment in [messageId]'s cash message. */ + data class ReturnOf(val messageId: Long) + } + /** One receiver's part of a multi-receiver payment (`CashContent.Payment`). */ data class Payment( val receiver: ID, diff --git a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt index a2890da48a..d2c7626300 100644 --- a/services/flipcash/src/commonMain/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt +++ b/services/flipcash/src/commonMain/kotlin/com/flipcash/services/repository/ChatMessagingRepository.kt @@ -1,6 +1,7 @@ package com.flipcash.services.repository import com.flipcash.services.models.QueryOptions +import com.flipcash.services.models.chat.CashMessageReturnStatus import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ClientMessageId @@ -23,6 +24,12 @@ interface ChatMessagingRepository { viewMode: ViewMode = ViewMode.FULL, ): Result + suspend fun getCashMessageReturnStatus( + owner: KeyPair, + chatId: ChatId, + messageId: Long, + ): Result + suspend fun getMessages( owner: KeyPair, chatId: ChatId,