Skip to content

Commit 57e675c

Browse files
authored
feat(payments): send an explicit action on every tip DM payment (#1442)
`flipcash2-client-protocol` 0.5.0 adds `action` to `intent.v1.ChatMetadata.TipDmPayment`, and the server prefers it over `location` when it resolves the verb the recipient sees ("Tipped" vs "Sent"). Left unset it reads as `DEFAULT`, which falls back to the location — and since `TIPCARD` is also the zero value, an unset action on an unset location resolves to a tip. So the field is not optional on the path that opens a tip DM: the server denies that intent unless it resolves to a tip. Set it everywhere a `TipDmPayment` is built. `TippingCoordinator` always sends `TIP`, since a tip card has no send-cash path. `ChatViewModel`'s send handler derives one `tipAction` and uses it for both the wire field and the `SentTip`/`SentCash` analytics event, instead of the event re-deriving tip-ness on its own. `TipAction` carries only `SEND` and `TIP`, so `DEFAULT` is unrepresentable rather than merely avoided. `location` keeps its current values on every path. A server predating the field reads `location` alone, and because `action` agrees with what `location` already implied, both server versions resolve the same verb through the rollout. `buildDmPaymentMetadata` (contact DMs) is unchanged; `ContactDmPayment` has no action field. Bumps `flipcash2-client-protocol` 0.4.1 to 0.5.0 for the new field.
1 parent b09cc38 commit 57e675c

7 files changed

Lines changed: 257 additions & 17 deletions

File tree

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import com.flipcash.app.funding.PurchaseMethodController
3232
import com.flipcash.app.tokens.TokenCoordinator
3333
import com.flipcash.app.userflags.UserFlagsCoordinator
3434
import com.flipcash.features.messenger.R
35+
import com.flipcash.services.models.TipAction
3536
import com.flipcash.services.models.TipOrigin
3637
import com.flipcash.services.models.UserProfile
3738
import com.flipcash.services.models.chat.ChatId
@@ -1075,12 +1076,22 @@ internal class ChatViewModel @Inject constructor(
10751076
// sends one. Every later send comes from the money button beside a composer
10761077
// that only exists once the thread is unlocked, and stays a plain send.
10771078
//
1078-
// `TIPCARD` is how a tip is asked for: `TipDmPayment.Location` has two values,
1079-
// and the server reads them as the verb ("Tipped" vs "Sent") rather than as a
1080-
// place. Sending `CHAT` here would title the payment "Sent" in the recipient's
1081-
// activity feed, under a button that promised a tip.
1082-
val isTip = stateFlow.value.chatType == ChatType.TIP_DM &&
1079+
// This is the one place that decides it, and both `TipDmPayment.action` (the
1080+
// verb the server renders — "Tipped" vs "Sent") and the analytics event below
1081+
// are read off this single value rather than each re-deriving "is this a tip."
1082+
// `origin`/`location` keeps being computed off the same condition as before —
1083+
// this change doesn't touch what byte it sends. The server reads it in exactly
1084+
// one place, as the fallback when `action` is `DEFAULT`, so leaving it alone is
1085+
// what keeps a server that predates `action` resolving the same verb as one
1086+
// that reads it.
1087+
val tipAction = if (
1088+
stateFlow.value.chatType == ChatType.TIP_DM &&
10831089
!stateFlow.value.typingConstraints.enabled
1090+
) {
1091+
TipAction.TIP
1092+
} else {
1093+
TipAction.SEND
1094+
}
10841095

10851096
val result = when (val participant = stateFlow.value.participant) {
10861097
is ChatParticipant.Contact -> contactPaymentDelegate.send(
@@ -1095,19 +1106,21 @@ internal class ChatViewModel @Inject constructor(
10951106
verifiedFiat = verifiedFiat,
10961107
token = token,
10971108
source = source,
1098-
origin = if (isTip) TipOrigin.TIPCARD else TipOrigin.CHAT,
1109+
origin = if (tipAction == TipAction.TIP) TipOrigin.TIPCARD else TipOrigin.CHAT,
1110+
action = tipAction,
10991111
)
11001112
null -> {
11011113
dispatchEvent(Event.SendStateUpdated())
11021114
return@launch
11031115
}
11041116
}
11051117

1106-
// Report what was sent, on the same line `TipDmPayment.Location` draws: the
1107-
// tip call to action above is a tip, and every other send from this screen —
1108-
// contact DM or unlocked tip DM — is a plain cash send.
1118+
// Report what was sent — the tip call to action above is a tip, and every
1119+
// other send from this screen (contact DM or unlocked tip DM) is a plain cash
1120+
// send. Same `tipAction` the wire `action` above was set from, not a second
1121+
// "is this a tip" check that could drift from it.
11091122
val transferEvent =
1110-
if (isTip) Analytics.Transfer.SentTip else Analytics.Transfer.SentCash
1123+
if (tipAction == TipAction.TIP) Analytics.Transfer.SentTip else Analytics.Transfer.SentCash
11111124

11121125
result.onSuccess {
11131126
dispatchEvent(Event.SendStateUpdated(success = true))

‎apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package com.flipcash.shared.payments
33
import com.flipcash.app.tokens.TokenCoordinator
44
import com.flipcash.app.userflags.UserFlagsCoordinator
55
import com.flipcash.services.controllers.ResolverController
6+
import com.flipcash.services.models.TipAction
67
import com.flipcash.services.models.TipOrigin
78
import com.flipcash.services.models.UserProfile
89
import com.flipcash.services.models.buildTipDmPaymentMetadata
@@ -167,18 +168,25 @@ class TipPaymentDelegate @Inject constructor(
167168
* Sends [verifiedFiat] of [token] from [source] to the user identified by [userId] as a tip DM:
168169
* derives the canonical tip chat, resolves the recipient's on-chain owner, attaches tip-DM app
169170
* metadata, transfers, debits the local balance, and syncs the chat feed. [origin] records
170-
* where the tip was initiated (a tip card vs. an in-chat send). Returns the canonical tip
171-
* [ChatId] (for message reload / navigation), or null if it couldn't be derived.
171+
* where the tip was initiated (a tip card vs. an in-chat send); [action] is the verb the
172+
* server renders for it ("Tipped" vs "Sent") and is always explicit — see [TipAction]. Returns
173+
* the canonical tip [ChatId] (for message reload / navigation), or null if it couldn't be
174+
* derived.
172175
*/
173176
suspend fun send(
174177
userId: ID,
175178
verifiedFiat: VerifiedFiat,
176179
token: Token,
177180
source: AccountCluster,
178181
origin: TipOrigin,
182+
action: TipAction,
179183
): Result<ChatId?> {
180184
val canonicalChatId = chatCoordinator.generateChatId(userId = userId).getOrNull()
181-
val appMetadataBytes = buildTipDmPaymentMetadata(chatId = canonicalChatId, origin = origin)
185+
val appMetadataBytes = buildTipDmPaymentMetadata(
186+
chatId = canonicalChatId,
187+
origin = origin,
188+
action = action,
189+
)
182190

183191
return resolverController.resolve(userId = userId)
184192
.mapCatching { destination ->

‎apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import com.flipcash.app.currency.PreferredCurrencyController
1313
import com.flipcash.app.funding.PurchaseMethodController
1414
import com.flipcash.app.tokens.TokenCoordinator
1515
import com.flipcash.services.controllers.ProfileController
16+
import com.flipcash.services.models.TipAction
1617
import com.flipcash.services.models.TipOrigin
1718
import com.flipcash.services.models.UserProfile
1819
import com.flipcash.services.user.UserManager
@@ -239,6 +240,8 @@ class TippingCoordinator @Inject constructor(
239240
token = token,
240241
source = source,
241242
origin = TipOrigin.TIPCARD,
243+
// A tip card is always a genuine tip — there is no "send cash" path through it.
244+
action = TipAction.TIP,
242245
).onSuccess { canonicalChatId ->
243246
setSendState(LoadingSuccessState(success = true))
244247
delay(400.milliseconds)

‎apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,35 @@
11
package com.flipcash.shared.tipping
22

33
import com.flipcash.app.analytics.FlipcashAnalyticsService
4+
import com.flipcash.app.core.tipping.TipAmount
45
import com.flipcash.app.funding.PurchaseMethodController
56
import com.flipcash.app.tokens.TokenCoordinator
67
import com.flipcash.services.controllers.ProfileController
8+
import com.flipcash.services.models.TipAction
9+
import com.flipcash.services.models.TipOrigin
710
import com.flipcash.services.models.UserProfile
11+
import com.flipcash.services.models.chat.ChatId
812
import com.flipcash.services.user.UserManager
913
import com.flipcash.shared.payments.TipPaymentDelegate
1014
import com.getcode.opencode.exchange.Exchange
15+
import com.getcode.opencode.exchange.VerifiedFiat
1116
import com.getcode.opencode.exchange.VerifiedFiatCalculator
17+
import com.getcode.opencode.model.accounts.AccountCluster
18+
import com.getcode.opencode.model.financial.CurrencyCode
19+
import com.getcode.opencode.model.financial.Fiat
20+
import com.getcode.opencode.model.financial.LocalFiat
21+
import com.getcode.opencode.model.financial.Rate
22+
import com.getcode.opencode.model.core.ID
23+
import com.getcode.opencode.model.financial.Token
24+
import com.getcode.solana.keys.Mint
1225
import com.getcode.util.resources.ResourceHelper
1326
import com.getcode.util.vibration.Vibrator
1427
import io.mockk.coEvery
28+
import io.mockk.coVerify
1529
import io.mockk.every
1630
import io.mockk.mockk
31+
import kotlinx.coroutines.flow.MutableStateFlow
32+
import kotlinx.coroutines.flow.flowOf
1733
import kotlinx.coroutines.test.runTest
1834
import kotlin.test.Test
1935
import kotlin.test.assertEquals
@@ -93,4 +109,77 @@ class TippingCoordinatorTest {
93109

94110
assertEquals(id, coordinator.currentUserId)
95111
}
112+
113+
/**
114+
* Reaches into [TippingCoordinator]'s private `_userId`/`_recipient`/`_canTip` state to select
115+
* a tip-card recipient without going through [TippingCoordinator.resolveTipCard] — that method
116+
* builds a real scannable [com.getcode.opencode.model.core.OpenCodePayload], which loads the
117+
* native `kikCodes`/`codeScanner` libraries a plain JVM unit test has no access to
118+
* (`UnsatisfiedLinkError`). Only [TippingCoordinator.confirmTip]'s wiring is under test here.
119+
*/
120+
@Suppress("UNCHECKED_CAST")
121+
private fun <T> TippingCoordinator.privateStateFlow(name: String): MutableStateFlow<T> {
122+
val field = TippingCoordinator::class.java.getDeclaredField(name)
123+
field.isAccessible = true
124+
return field.get(this) as MutableStateFlow<T>
125+
}
126+
127+
@Test
128+
fun `confirmTip sends the tip card as a TIP action`() = runTest {
129+
// A tip card has no "send cash" path — every payment through it is a genuine tip, so
130+
// confirmTip must always report TipAction.TIP on the wire, never SEND.
131+
val userId = listOf<Byte>(4, 5, 6)
132+
133+
val mint = Mint.usdf
134+
val token = mockk<Token>(relaxed = true) {
135+
every { address } returns mint
136+
}
137+
val rate = Rate.oneToOne
138+
val amount = Fiat(5.0, CurrencyCode.USD)
139+
val owner = mockk<AccountCluster>(relaxed = true) {
140+
every { withTimelockForToken(token) } returns this
141+
}
142+
val verifiedFiat = VerifiedFiat(LocalFiat.Zero, null)
143+
144+
every { exchange.preferredRate } returns rate
145+
// selectedToken is a plain `val` built once at construction from these two calls, not a
146+
// lazily-recomputed property — stubbing them has to happen before TippingCoordinator is
147+
// built, or the coordinator captures whatever the mock's default (unstubbed) answer was.
148+
every { tokenCoordinator.observeSelectedTokenMint() } returns flowOf(mint)
149+
every { tokenCoordinator.tokens } returns flowOf(listOf(token))
150+
every { tokenCoordinator.balanceForToken(token) } returns amount
151+
every { userManager.accountCluster } returns owner
152+
every { tipPaymentDelegate.minimumTipFor(userId, any()) } returns flowOf(null)
153+
coEvery {
154+
verifiedFiatCalculator.compute(any(), any(), any(), any(), any())
155+
} returns Result.success(verifiedFiat)
156+
coEvery {
157+
tipPaymentDelegate.send(any(), any(), any(), any(), any(), any())
158+
} returns Result.success<ChatId?>(null)
159+
160+
val coordinator = buildCoordinator()
161+
162+
// Selects the tip card the same way onCardResolved would, minus the native-dependent
163+
// OpenCodePayload construction resolveTipCard performs.
164+
coordinator.privateStateFlow<ID?>("_userId").value = userId
165+
coordinator.privateStateFlow<UserProfile?>("_recipient").value = profile("Bob")
166+
coordinator.privateStateFlow<Boolean>("_canTip").value = true
167+
168+
coordinator.selectAmount(TipAmount.Custom(amount))
169+
coordinator.confirmTip()
170+
171+
// confirmTip fires into the coordinator's own background scope rather than this test's,
172+
// so the send lands asynchronously — coVerify's timeout polls for it instead of asserting
173+
// immediately after the (non-suspend) call above returns.
174+
coVerify(timeout = 5_000) {
175+
tipPaymentDelegate.send(
176+
userId = userId,
177+
verifiedFiat = verifiedFiat,
178+
token = token,
179+
source = owner,
180+
origin = TipOrigin.TIPCARD,
181+
action = TipAction.TIP,
182+
)
183+
}
184+
}
96185
}

‎gradle/libs.versions.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ protovalidate-kt = "0.1.2"
6969
# 0.3.0 is the first release of either package to ship R8 keep rules for its generated
7070
# messages, which is what lets proguard-rules.pro drop its own.
7171
ocp-client-protocol = "0.3.0"
72-
flipcash2-client-protocol = "0.4.1"
72+
flipcash2-client-protocol = "0.5.0"
7373

7474
# The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's
7575
# `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata,

‎services/flipcash/src/main/kotlin/com/flipcash/services/models/DmPaymentMetadata.kt‎

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,20 +29,36 @@ fun buildDmPaymentMetadata(
2929
).build().toByteArray()
3030
}
3131

32-
/** Where in the app a tip DM payment was initiated, reported to the backend. */
32+
/** Where in the app a tip DM payment was initiated, reported to the backend as `location`. */
3333
enum class TipOrigin { TIPCARD, CHAT }
3434

35+
/**
36+
* The verb the client intends for a tip DM payment, reported to the backend as `action`.
37+
*
38+
* There is no `DEFAULT` case here on purpose. `action` is proto3, so an unset field reads back
39+
* as its zero value — which is `DEFAULT`, the same number as `location`'s zero value (`TIPCARD`).
40+
* That makes an unset `action` alongside a `TIPCARD` location indistinguishable from a client
41+
* deliberately declaring a tip. This client always sets one of the two real actions
42+
* explicitly, so `DEFAULT` never leaves it — a type that cannot express `DEFAULT` is how that
43+
* stays true.
44+
*/
45+
enum class TipAction { SEND, TIP }
46+
3547
/**
3648
* Builds the serialized Flipcash `AppMetadata` proto bytes for a tip DM payment.
3749
*
3850
* Unlike a contact DM payment there is no phone source/destination — a tip DM is
39-
* between two user IDs, which map directly to/from public keys. [origin] records
40-
* where the tip was sent from (a tip card vs. an in-chat send). Returns `null` when
51+
* between two user IDs, which map directly to/from public keys. [origin] records where the tip
52+
* was sent from (a tip card vs. an in-chat send) and drives `location`, which the server reads
53+
* only as the fallback for an unset [action]. [action] is the always-explicit verb, and it is
54+
* what the server resolves the payment to — the title on the sender's activity feed, the message
55+
* injected into the DM, and which validation rules the intent is held to. Returns `null` when
4156
* [chatId] is missing so the caller can pass the result through unconditionally.
4257
*/
4358
fun buildTipDmPaymentMetadata(
4459
chatId: ChatId?,
4560
origin: TipOrigin,
61+
action: TipAction,
4662
): ByteArray? {
4763
if (chatId == null) return null
4864
return FlipcashIntentModel.AppMetadata.newBuilder()
@@ -59,6 +75,14 @@ fun buildTipDmPaymentMetadata(
5975
FlipcashIntentModel.ChatMetadata.TipDmPayment.Location.CHAT
6076
}
6177
)
78+
.setAction(
79+
when (action) {
80+
TipAction.SEND ->
81+
FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.SEND
82+
TipAction.TIP ->
83+
FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.TIP
84+
}
85+
)
6286
)
6387
).build().toByteArray()
6488
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package com.flipcash.services.models
2+
3+
import com.codeinc.flipcash.gen.intent.v1.Model as FlipcashIntentModel
4+
import com.flipcash.services.models.chat.ChatId
5+
import com.getcode.utils.toByteString
6+
import org.junit.Test
7+
import kotlin.test.assertEquals
8+
import kotlin.test.assertNotEquals
9+
import kotlin.test.assertNull
10+
11+
/**
12+
* [buildTipDmPaymentMetadata] and [buildDmPaymentMetadata] are the only places that build a
13+
* `ChatMetadata.TipDmPayment` / `ChatMetadata.ContactDmPayment`. Nothing round-tripped either
14+
* through the wire before this, so a mistake in which field gets which value — or in which enum
15+
* constant a [TipOrigin]/[TipAction] maps to — would ship silently. These parse the built bytes
16+
* back into `AppMetadata` and assert on the decoded fields, not just on the builder calls.
17+
*/
18+
class DmPaymentMetadataTest {
19+
20+
private val chatId = ChatId(bytes(32) { it })
21+
22+
@Test
23+
fun `tip DM payment sets location TIPCARD and action TIP for a tip-card send`() {
24+
val bytes = buildTipDmPaymentMetadata(
25+
chatId = chatId,
26+
origin = TipOrigin.TIPCARD,
27+
action = TipAction.TIP,
28+
)
29+
30+
val decoded = FlipcashIntentModel.AppMetadata.parseFrom(bytes!!)
31+
32+
assertEquals(
33+
FlipcashIntentModel.ChatMetadata.TipDmPayment.Location.TIPCARD,
34+
decoded.chat.tipDmPayment.location,
35+
)
36+
assertEquals(
37+
FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.TIP,
38+
decoded.chat.tipDmPayment.action,
39+
)
40+
assertNotEquals(
41+
FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.DEFAULT,
42+
decoded.chat.tipDmPayment.action,
43+
)
44+
assertEquals(chatId.bytes.toByteString(), decoded.chat.chatId.value)
45+
}
46+
47+
@Test
48+
fun `tip DM payment sets location CHAT and action SEND for an in-chat send`() {
49+
val bytes = buildTipDmPaymentMetadata(
50+
chatId = chatId,
51+
origin = TipOrigin.CHAT,
52+
action = TipAction.SEND,
53+
)
54+
55+
val decoded = FlipcashIntentModel.AppMetadata.parseFrom(bytes!!)
56+
57+
assertEquals(
58+
FlipcashIntentModel.ChatMetadata.TipDmPayment.Location.CHAT,
59+
decoded.chat.tipDmPayment.location,
60+
)
61+
assertEquals(
62+
FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.SEND,
63+
decoded.chat.tipDmPayment.action,
64+
)
65+
assertNotEquals(
66+
FlipcashIntentModel.ChatMetadata.TipDmPayment.Action.DEFAULT,
67+
decoded.chat.tipDmPayment.action,
68+
)
69+
}
70+
71+
@Test
72+
fun `tip DM payment metadata is null when chatId is missing`() {
73+
assertNull(buildTipDmPaymentMetadata(chatId = null, origin = TipOrigin.TIPCARD, action = TipAction.TIP))
74+
}
75+
76+
@Test
77+
fun `contact DM payment round-trips the chat id and both phone numbers`() {
78+
val bytes = buildDmPaymentMetadata(
79+
chatId = chatId,
80+
sourcePhone = "+15550001111",
81+
destinationPhone = "+15550002222",
82+
)
83+
84+
val decoded = FlipcashIntentModel.AppMetadata.parseFrom(bytes!!)
85+
86+
assertEquals(chatId.bytes.toByteString(), decoded.chat.chatId.value)
87+
assertEquals("+15550001111", decoded.chat.contactDmPayment.source.value)
88+
assertEquals("+15550002222", decoded.chat.contactDmPayment.destination.value)
89+
assertEquals(
90+
FlipcashIntentModel.ChatMetadata.TypeCase.CONTACT_DM_PAYMENT,
91+
decoded.chat.typeCase,
92+
)
93+
}
94+
95+
@Test
96+
fun `contact DM payment metadata is null when any required field is missing`() {
97+
assertNull(buildDmPaymentMetadata(chatId = null, sourcePhone = "+1", destinationPhone = "+2"))
98+
assertNull(buildDmPaymentMetadata(chatId = chatId, sourcePhone = null, destinationPhone = "+2"))
99+
assertNull(buildDmPaymentMetadata(chatId = chatId, sourcePhone = "+1", destinationPhone = null))
100+
}
101+
102+
private fun bytes(size: Int, value: (Int) -> Int) = ByteArray(size) { value(it).toByte() }
103+
}

0 commit comments

Comments
 (0)