diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index fa357381f..f31e4d560 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -67,7 +67,7 @@ protovalidate-kt = "0.1.1" # protos are independent (flipcash2 does not import ocp), so the packages move on their own # cadence and there is nothing to keep aligned. ocp-client-protocol = "0.2.0" -flipcash2-client-protocol = "0.1.0" +flipcash2-client-protocol = "0.2.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/ProfileController.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt index 6e06f9796..5cc47231c 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/controllers/ProfileController.kt @@ -12,6 +12,7 @@ import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.repository.ProfileRepository import com.flipcash.services.user.UserManager import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat import com.getcode.utils.TraceType import com.getcode.utils.trace import javax.inject.Inject @@ -124,6 +125,17 @@ class ProfileController @Inject constructor( return repository.updateTipCard(owner, hexColor) } + /** + * Sets the minimum fee another user must pay to initialize a DM chat with + * the caller, replacing any fee already set. + */ + suspend fun setMinDmChatInitFee(fee: Fiat): Result { + val owner = userManager.accountCluster?.authority?.keyPair + ?: return Result.failure(Throwable("No account cluster in UserManager")) + + return repository.setMinDmChatInitFee(owner, fee) + } + /** * Sets the caller's profile picture to a blob already uploaded via BlobStorage. * Returns the full set of renditions the server derived from it. diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt index bea0845c6..68c2196f3 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/domain/UserProfileMapper.kt @@ -4,6 +4,7 @@ import com.codeinc.flipcash.gen.profile.v1.Model import com.codeinc.flipcash.gen.profile.v1.emailAddressOrNull import com.codeinc.flipcash.gen.profile.v1.phoneNumberOrNull import com.codeinc.flipcash.gen.profile.v1.usernameOrNull +import com.flipcash.services.internal.network.extensions.toFiat import com.flipcash.services.internal.network.extensions.toId import com.flipcash.services.internal.network.extensions.toMediaItem import com.flipcash.services.models.UserProfile @@ -30,6 +31,7 @@ class UserProfileMapper @Inject constructor( userId = if (from.hasUserId()) from.userId.toId() else null, // Public, so it is returned for any user — absent only when unclaimed. username = from.usernameOrNull?.value, + minDmChatInitFee = if (from.hasMinDmChatInitFee()) from.minDmChatInitFee.toFiat() else null, ) } } \ No newline at end of file diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt index 2e54c04ab..f546aadfc 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/api/ProfileApi.kt @@ -4,6 +4,7 @@ import com.codeinc.flipcash.gen.common.v1.Common import com.codeinc.flipcash.gen.profile.v1.ProfileGrpcKt import com.codeinc.flipcash.gen.profile.v1.ProfileService import com.flipcash.services.internal.annotations.FlipcashManagedChannel +import com.flipcash.services.internal.network.extensions.asFiatPaymentAmount import com.flipcash.services.internal.network.extensions.asUserId import com.flipcash.services.internal.network.extensions.asUsername import com.flipcash.services.internal.network.extensions.authenticate @@ -14,6 +15,7 @@ import com.flipcash.services.models.SocialAccountUnlinkRequest import com.flipcash.services.models.chat.BlobId import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.core.GrpcApi +import com.getcode.opencode.model.financial.Fiat import com.getcode.utils.toByteString import com.codeinc.flipcash.gen.profile.v1.validate import dev.bmcreations.protovalidate.orThrow @@ -158,6 +160,26 @@ internal class ProfileApi @Inject constructor( } } + /** + * Sets the minimum fee another user must pay to initialize a DM chat with + * the caller, replacing any fee already set. + */ + suspend fun setMinDmChatInitFee( + owner: Ed25519.KeyPair, + fee: Fiat, + ): ProfileService.SetMinDmChatInitFeeResponse { + val request = ProfileService.SetMinDmChatInitFeeRequest.newBuilder() + .setMinDmChatInitFee(fee.asFiatPaymentAmount()) + .apply { setAuth(authenticate(owner)) } + .build() + + request.validate().orThrow() + + return withContext(Dispatchers.IO) { + api.setMinDmChatInitFee(request) + } + } + /** * removes a social account link from a user */ 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 3551aac72..ff36aadb5 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 @@ -16,6 +16,7 @@ import com.flipcash.services.models.chat.TypingState import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.network.jwt.ApiProvider import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Checksum import com.getcode.solana.keys.PublicKey import com.getcode.utils.toByteString @@ -93,6 +94,13 @@ internal fun String.asCountryCode(): Common.CountryCode { return Common.CountryCode.newBuilder().setValue(this).build() } +internal fun Fiat.asFiatPaymentAmount(): Common.FiatPaymentAmount { + return Common.FiatPaymentAmount.newBuilder() + .setCurrency(currencyCode.name.lowercase()) + .setNativeAmount(decimalValue) + .build() +} + internal fun SocialAccountLinkRequest.linkingToken(): ProfileService.LinkSocialAccountRequest.LinkingToken { val builder = ProfileService.LinkSocialAccountRequest.LinkingToken.newBuilder() 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 47f51d314..70bc3912f 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 @@ -70,6 +70,10 @@ internal fun Common.UserId.toId(): ID = value.toByteArray().toList() internal fun Common.Hash.toChecksum(): Checksum = value.toByteArray().toChecksum() internal fun Common.PublicKey.toPublicKey(): PublicKey = value.toByteArray().toPublicKey() internal fun Common.PublicKey.toMint(): Mint = value.toByteArray().toMint() +internal fun Common.FiatPaymentAmount.toFiat(): Fiat = Fiat( + fiat = nativeAmount, + currencyCode = CurrencyCode.tryValueOf(currency) ?: CurrencyCode.USD, +) internal fun PushModels.Payload.asPayload(): NotificationPayload { val navigationTrigger = navigationOrNull?.typeCase?.let { type -> diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt index c07a98080..8bf3c5f75 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ProfileService.kt @@ -9,6 +9,7 @@ import com.flipcash.services.models.GetUserProfileError import com.flipcash.services.models.LinkSocialAccountError import com.flipcash.services.models.ProfileIdentifier import com.flipcash.services.models.SetDisplayNameError +import com.flipcash.services.models.SetMinDmChatInitFeeError import com.flipcash.services.models.SetProfilePictureError import com.flipcash.services.models.SetUsernameError import com.flipcash.services.models.SocialAccountLinkRequest @@ -20,6 +21,7 @@ import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.internal.network.extensions.toMediaItem import com.getcode.ed25519.Ed25519 import com.getcode.opencode.internal.network.extensions.foldWithSuppression +import com.getcode.opencode.model.financial.Fiat import javax.inject.Inject internal class ProfileService @Inject constructor( @@ -131,6 +133,26 @@ internal class ProfileService @Inject constructor( ) } + suspend fun setMinDmChatInitFee( + owner: Ed25519.KeyPair, + fee: Fiat, + ): Result { + return runCatching { + api.setMinDmChatInitFee(owner, fee) + }.foldWithSuppression( + onSuccess = { response -> + when (response.result) { + ProfileService.SetMinDmChatInitFeeResponse.Result.OK -> Result.success(Unit) + ProfileService.SetMinDmChatInitFeeResponse.Result.DENIED -> Result.failure(SetMinDmChatInitFeeError.Denied()) + ProfileService.SetMinDmChatInitFeeResponse.Result.INVALID_AMOUNT -> Result.failure(SetMinDmChatInitFeeError.InvalidAmount()) + ProfileService.SetMinDmChatInitFeeResponse.Result.UNRECOGNIZED -> Result.failure(SetMinDmChatInitFeeError.Unrecognized()) + null -> Result.failure(SetMinDmChatInitFeeError.Unrecognized()) + } + }, + onFailure = { Result.failure(it.toValidationOrElse { cause -> SetMinDmChatInitFeeError.Other(cause) }) } + ) + } + suspend fun linkSocialAccount( request: SocialAccountLinkRequest, owner: Ed25519.KeyPair, diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt index ad34e50a2..0330218e6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/repositories/InternalProfileRepository.kt @@ -15,6 +15,7 @@ import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.MediaItem import com.flipcash.services.repository.ProfileRepository import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.model.financial.Fiat import com.getcode.utils.ErrorUtils internal class InternalProfileRepository( @@ -86,6 +87,14 @@ internal class InternalProfileRepository( .onFailure { ErrorUtils.handleError(it) } } + override suspend fun setMinDmChatInitFee( + owner: Ed25519.KeyPair, + fee: Fiat, + ): Result { + return service.setMinDmChatInitFee(owner, fee) + .onFailure { ErrorUtils.handleError(it) } + } + override suspend fun linkSocialAccount( request: SocialAccountLinkRequest, owner: Ed25519.KeyPair 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 c956ae44d..e26f04103 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 @@ -601,6 +601,16 @@ sealed class UpdateTipCardError( data class Other(override val cause: Throwable? = null) : UpdateTipCardError(message = cause?.message, cause = cause), NotifiableError } +sealed class SetMinDmChatInitFeeError( + override val message: String? = null, + override val cause: Throwable? = null +): CodeServerError(message, cause) { + class Denied: SetMinDmChatInitFeeError("Denied") + class InvalidAmount: SetMinDmChatInitFeeError("Invalid amount") + class Unrecognized : SetMinDmChatInitFeeError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : SetMinDmChatInitFeeError(message = cause?.message, cause = cause), NotifiableError +} + // Thrown when a reserved blob failed server-side finalization (moderation / decode / size). // Terminal: the client must reserve a fresh upload to retry. class BlobRejectedException(val rejection: BlobRejection) : diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt index 7d2b90c76..1d5f8cc82 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/UserProfile.kt @@ -3,6 +3,7 @@ package com.flipcash.services.models import android.os.Parcelable import com.flipcash.services.models.chat.MediaItem import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable import kotlin.time.Instant @@ -27,6 +28,11 @@ data class UserProfile( // The user's public Flipcash handle. Public, so it is present for any user — // null when they haven't claimed one yet. val username: String? = null, + // The minimum fee another user must pay to initialize a DM chat with this + // user. Public, so it is present for any user, not just the caller. Null + // when the user hasn't set one, in which case the server default applies. + // Update it with ProfileController.setMinDmChatInitFee. + val minDmChatInitFee: Fiat? = null, ): Parcelable { /** The phone number only when it has been verified — backwards-compatible accessor. */ val verifiedPhoneNumber: String? get() = phoneNumber?.takeIf { it.verified }?.value @@ -43,6 +49,7 @@ data class UserProfile( tipCardColor = null, userId = null, username = null, + minDmChatInitFee = null, ) } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt index 257ac6a08..b212fd9b2 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/repository/ProfileRepository.kt @@ -8,6 +8,7 @@ import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.BlobId import com.flipcash.services.models.chat.MediaItem import com.getcode.ed25519.Ed25519 +import com.getcode.opencode.model.financial.Fiat interface ProfileRepository { suspend fun getProfile(identifier: ProfileIdentifier, owner: Ed25519.KeyPair): Result @@ -15,6 +16,7 @@ interface ProfileRepository { suspend fun setUsername(username: String, owner: Ed25519.KeyPair): Result suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair): Result suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String): Result + suspend fun setMinDmChatInitFee(owner: Ed25519.KeyPair, fee: Fiat): Result suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair): Result suspend fun unlinkSocialAccount(request: SocialAccountUnlinkRequest, owner: Ed25519.KeyPair): Result } \ No newline at end of file diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt index d141e3779..013c52dc5 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/controllers/ProfileControllerTest.kt @@ -14,6 +14,7 @@ import com.flipcash.services.user.UserManager import com.getcode.ed25519.Ed25519 import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat import io.mockk.every import io.mockk.mockk import io.mockk.verify @@ -305,6 +306,7 @@ private class FakeProfileRepository : ProfileRepository { var setUsernameResult: Result = Result.success(Unit) var setProfilePictureResult: Result = Result.failure(RuntimeException("not configured")) var updateTipCardResult: Result = Result.success(Unit) + var setMinDmChatInitFeeResult: Result = Result.success(Unit) var linkSocialAccountResult: Result = Result.failure(RuntimeException("not configured")) var unlinkSocialAccountResult: Result = Result.success(Unit) @@ -313,6 +315,7 @@ private class FakeProfileRepository : ProfileRepository { override suspend fun setUsername(username: String, owner: Ed25519.KeyPair) = setUsernameResult override suspend fun setProfilePicture(blobId: BlobId, owner: Ed25519.KeyPair) = setProfilePictureResult override suspend fun updateTipCard(owner: Ed25519.KeyPair, hexColor: String) = updateTipCardResult + override suspend fun setMinDmChatInitFee(owner: Ed25519.KeyPair, fee: Fiat) = setMinDmChatInitFeeResult override suspend fun linkSocialAccount(request: SocialAccountLinkRequest, owner: Ed25519.KeyPair) = linkSocialAccountResult override suspend fun unlinkSocialAccount(request: SocialAccountUnlinkRequest, owner: Ed25519.KeyPair) =