diff --git a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt index 12a688b986..f26a8c55d6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt @@ -1,4 +1,5 @@ @file:OptIn(ExperimentalTime::class) +@file:Suppress("TooManyFunctions") package to.bitkit.ui.screens.subscriptions @@ -167,12 +168,10 @@ internal fun SubscriptionsContent( onCreateSubscription: () -> Unit, paymentsContent: @Composable (topPadding: Dp) -> Unit, ) { - val proposals = subscriptions.filter { it.isPayer && it.isProposalVisible(now) } - val active = subscriptions.filter { it.isPayer && it.isActive(now) } - val expired = subscriptions.filter { it.isPayer && it.isExpired(now) && acceptedAt(it.id) != null } - val created = subscriptions.filter { it.isCreatedVisible(now) } - val hasVisibleSubscriptions = + val sections = subscriptionSections(subscriptions, acceptedAt, now) + val hasVisibleSubscriptions = with(sections) { proposals.isNotEmpty() || active.isNotEmpty() || expired.isNotEmpty() || created.isNotEmpty() + } var selectedTabIndex by rememberSaveable { mutableIntStateOf(initialTab.ordinal) } val selectedTab = SubscriptionTab.entries[selectedTabIndex] @@ -214,34 +213,34 @@ internal fun SubscriptionsContent( item { SubscriptionMetrics( monthlyCostSats = subscriptionMonthlyCostSats(subscriptions, now), - activeCount = active.size, - createdCount = created.size, + activeCount = sections.active.size, + createdCount = sections.created.size, ) } subscriptionSection( titleRes = R.string.subscriptions__proposals, - subscriptions = proposals, + subscriptions = sections.proposals, contacts = contacts, now = now, onSubscription = onSubscription, ) subscriptionSection( titleRes = R.string.subscriptions__active, - subscriptions = active, + subscriptions = sections.active, contacts = contacts, now = now, onSubscription = onSubscription, ) subscriptionSection( titleRes = R.string.subscriptions__expired, - subscriptions = expired, + subscriptions = sections.expired, contacts = contacts, now = now, onSubscription = onSubscription, ) subscriptionSection( titleRes = R.string.subscriptions__created, - subscriptions = created, + subscriptions = sections.created, contacts = contacts, now = now, onSubscription = onSubscription, @@ -290,6 +289,24 @@ internal fun SubscriptionsContent( } } +internal data class SubscriptionSections( + val proposals: List, + val active: List, + val expired: List, + val created: List, +) + +internal fun subscriptionSections( + subscriptions: List, + acceptedAt: (PaykitSubscriptionId) -> Instant?, + now: Instant, +) = SubscriptionSections( + proposals = subscriptions.filter { it.isPayer && it.isProposalVisible(now) }, + active = subscriptions.filter { it.isPayer && it.runsUntilPaidThrough(now) }, + expired = subscriptions.filter { it.isPayer && it.hasEnded(now) && acceptedAt(it.id) != null }, + created = subscriptions.filter { it.isCreatedVisible(now) }, +) + private fun LazyListScope.subscriptionSection( @StringRes titleRes: Int, subscriptions: List, @@ -306,7 +323,7 @@ private fun LazyListScope.subscriptionSection( subscription = subscription, contact = contacts.contactFor(subscription), subtitle = subscription.rowSubtitle(now), - faded = subscription.isExpired(now), + faded = subscription.hasEnded(now), onClick = { onSubscription(subscription) }, ) } @@ -447,7 +464,7 @@ fun SubscriptionDetailScreen( verticalArrangement = Arrangement.spacedBy(32.dp), modifier = Modifier .weight(1f) - .alpha(if (subscription.isExpired(now)) 0.5f else 1f), + .alpha(if (subscription.hasEnded(now)) 0.5f else 1f), ) { item { Column(verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.fillMaxWidth()) { @@ -513,13 +530,13 @@ private fun SubscriptionDetailsGrid(subscription: PaykitSubscription, now: Insta Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { SubscriptionDetailCell( stringResource(R.string.subscriptions__status), - subscription.statusText(now), + stringResource(subscription.statusRes(now)), R.drawable.ic_check, Modifier.weight(1f), ) if (subscription.shouldShowTiming(now)) { SubscriptionDetailCell( - subscription.timingTitle(now), + stringResource(subscription.timingTitleRes(now)), subscription.renewalText(now), R.drawable.ic_calendar, Modifier.weight(1f), @@ -1039,27 +1056,19 @@ internal fun PaykitSubscription.subscriptionFrequencyText(): String { @Composable private fun PaykitSubscription.rowSubtitle(now: Instant): String { createdRowSubtitle(now)?.let { return it } - return when { - isProposalVisible(now) || !recurrence.unit.isSupported -> subscriptionFrequencyText() - isExpired(now) -> recurrence.endsAt?.let { - stringResource(R.string.subscriptions__expires_date, it.formatShortDate()) - } ?: stringResource(R.string.subscriptions__expired) - recurrence.endsAt != null -> stringResource( - R.string.subscriptions__expires_date, - recurrence.endsAt.formatShortDate(), - ) - else -> { - val renewal = recurrence.nextPeriodAfter(now)?.startsAt - if (renewal == null) { - subscriptionFrequencyText() - } else { - stringResource( - R.string.subscriptions__renews_date, - renewal.formatShortDate(), - ) - } - } + val (res, date) = rowSubtitleSpec(now) ?: return subscriptionFrequencyText() + return if (date == null) stringResource(res) else stringResource(res, date.formatShortDate()) +} + +/** The string and date behind the row subtitle, or null when it shows the frequency. */ +internal fun PaykitSubscription.rowSubtitleSpec(now: Instant): Pair? = when { + isProposalVisible(now) || !recurrence.unit.isSupported -> null + runsUntilPaidThrough(now) -> when { + recurrence.endsAt != null || !isActive(now) -> R.string.subscriptions__expires_date to expiryDate() + else -> recurrence.nextPeriodAfter(now)?.startsAt?.let { R.string.subscriptions__renews_date to it } } + else -> recurrence.endsAt?.let { R.string.subscriptions__expires_date to (canceledPaidThrough() ?: it) } + ?: (R.string.subscriptions__expired to null) } @Composable @@ -1089,20 +1098,35 @@ internal fun PaykitSubscription.shouldShowTiming(now: Instant): Boolean = isActive(now) || expiryDate() != null internal fun PaykitSubscription.expiryDate(): Instant? = - recurrence.endsAt ?: paidPeriods.maxOfOrNull { it.endsAt } + canceledPaidThrough() ?: recurrence.endsAt ?: paidPeriods.maxOfOrNull { it.endsAt } -@Composable -private fun PaykitSubscription.statusText(now: Instant): String = when { - isProposalVisible(now) -> stringResource(R.string.subscriptions__pending) - isActive(now) -> stringResource(R.string.subscriptions__active) - else -> stringResource(R.string.subscriptions__expired) +/** A canceled subscription we pay is paid for up to its last paid period, whatever its fixed end date. */ +private fun PaykitSubscription.canceledPaidThrough(): Instant? = + if (isPayer && lifecycleState == PaymentRequestLifecycleState.CANCELED) { + paidPeriods.maxOfOrNull { it.endsAt } + } else { + null + } + +/** Active, or canceled with its last paid period still ahead: it keeps running until it is paid through. */ +internal fun PaykitSubscription.runsUntilPaidThrough(now: Instant): Boolean = + isActive(now) || canceledPaidThrough()?.let { it > now } == true + +/** Shown as expired: it no longer runs, whether canceled, rejected or lapsed. */ +internal fun PaykitSubscription.hasEnded(now: Instant): Boolean = isExpired(now) && !runsUntilPaidThrough(now) + +@StringRes +internal fun PaykitSubscription.statusRes(now: Instant): Int = when { + isProposalVisible(now) -> R.string.subscriptions__pending + runsUntilPaidThrough(now) -> R.string.subscriptions__active + else -> R.string.subscriptions__expired } -@Composable -private fun PaykitSubscription.timingTitle(now: Instant): String = when { - !isActive(now) -> stringResource(R.string.subscriptions__expired) - recurrence.endsAt == null -> stringResource(R.string.subscriptions__renews) - else -> stringResource(R.string.subscriptions__expires) +@StringRes +internal fun PaykitSubscription.timingTitleRes(now: Instant): Int = when { + isActive(now) && recurrence.endsAt == null -> R.string.subscriptions__renews + runsUntilPaidThrough(now) -> R.string.subscriptions__expires + else -> R.string.subscriptions__expired } @Composable @@ -1136,6 +1160,7 @@ internal fun nextSubscriptionTransition( }.filterNotNull().toMutableList() dates += activeSubscriptions.mapNotNull { it.recurrence.nextPeriodAfter(now)?.startsAt } dates += subscriptions.mapNotNull { it.paymentDueOnAcceptance(now)?.billingPeriod?.endsAt } + dates += subscriptions.mapNotNull { it.canceledPaidThrough() } return dates.filter { it > now }.minOrNull() } @@ -1149,7 +1174,8 @@ internal fun subscriptionMonthlyCostSats( subscriptions: List, now: Instant, ): Long { - val total = subscriptions.filter { it.isPayer && it.isActive(now) }.fold(BigDecimal.ZERO) { total, subscription -> + val running = subscriptions.filter { it.isPayer && it.runsUntilPaidThrough(now) } + val total = running.fold(BigDecimal.ZERO) { total, subscription -> val annualPeriods = when (subscription.recurrence.unit) { PaykitRecurrenceUnit.Minute -> 525_600L PaykitRecurrenceUnit.Hour -> 8_760L diff --git a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt index e741447bf8..6eb2953758 100644 --- a/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt @@ -9,8 +9,10 @@ import to.bitkit.models.NewTransactionSheetType import to.bitkit.repositories.PaykitBillingPeriod import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription +import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.repositories.PaykitSubscriptionMetadata import to.bitkit.repositories.PaykitSubscriptionRecurrence +import to.bitkit.repositories.PaykitSubscriptionRole import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -19,6 +21,7 @@ import kotlin.time.Instant class SubscriptionsScreenTest { private val now = Instant.parse("2027-01-15T08:00:00Z") + private val paidThrough = Instant.parse("2027-02-01T08:00:00Z") @Test fun `next transition includes the next recurring period`() { @@ -156,6 +159,131 @@ class SubscriptionsScreenTest { assertFalse(fixedEnd.canCancel(now)) } + @Test + fun `canceled subscription before its paid period ends reads active and expires`() { + val canceled = canceledWithPaidThrough() + + assertTrue(canceled.runsUntilPaidThrough(now)) + assertEquals(R.string.subscriptions__active, canceled.statusRes(now)) + assertEquals(R.string.subscriptions__expires, canceled.timingTitleRes(now)) + assertEquals(R.string.subscriptions__expires_date to paidThrough, canceled.rowSubtitleSpec(now)) + assertTrue(canceled.shouldShowTiming(now)) + assertEquals(paidThrough, canceled.expiryDate()) + assertFalse(canceled.canCancel(now)) + } + + @Test + fun `canceled subscription expires when its paid period ends`() { + val canceled = canceledWithPaidThrough() + + assertFalse(canceled.runsUntilPaidThrough(paidThrough)) + assertEquals(R.string.subscriptions__expired, canceled.statusRes(paidThrough)) + assertEquals(R.string.subscriptions__expired, canceled.timingTitleRes(paidThrough)) + assertEquals(R.string.subscriptions__expired to null, canceled.rowSubtitleSpec(paidThrough)) + assertTrue(canceled.hasEnded(paidThrough)) + assertFalse(canceled.hasEnded(now)) + } + + @Test + fun `canceled subscription stops running at its last paid period even with a later end date`() { + val canceled = canceledWithPaidThrough().let { + it.copy(recurrence = it.recurrence.copy(endsAt = Instant.parse("2027-06-01T08:00:00Z"))) + } + + assertEquals(paidThrough, canceled.expiryDate()) + assertTrue(canceled.runsUntilPaidThrough(now)) + assertFalse(canceled.runsUntilPaidThrough(paidThrough)) + assertEquals(R.string.subscriptions__expired, canceled.statusRes(paidThrough)) + assertEquals(R.string.subscriptions__expires_date to paidThrough, canceled.rowSubtitleSpec(now)) + assertEquals(R.string.subscriptions__expires_date to paidThrough, canceled.rowSubtitleSpec(paidThrough)) + assertEquals(paidThrough, nextSubscriptionTransition(listOf(canceled), now)) + } + + @Test + fun `canceled subscription the user created keeps its expired treatment and stays under created`() { + val created = canceledWithPaidThrough().copy(role = PaykitSubscriptionRole.Payee) + val sections = subscriptionSections(listOf(created), { now }, now) + + assertFalse(created.runsUntilPaidThrough(now)) + assertTrue(created.hasEnded(now)) + assertEquals(R.string.subscriptions__expired, created.statusRes(now)) + assertEquals(R.string.subscriptions__expired, created.timingTitleRes(now)) + assertEquals(listOf(created), sections.created) + assertEquals(emptyList(), sections.active) + assertEquals(emptyList(), sections.expired) + } + + @Test + fun `canceled subscription without an end date shows no timing`() { + val canceled = subscription(PaykitRecurrenceUnit.Month) + .copy(lifecycleState = PaymentRequestLifecycleState.CANCELED) + + assertFalse(canceled.runsUntilPaidThrough(now)) + assertFalse(canceled.shouldShowTiming(now)) + assertEquals(R.string.subscriptions__expired, canceled.statusRes(now)) + } + + @Test + fun `active and ended subscriptions keep their status and timing`() { + val active = subscription(PaykitRecurrenceUnit.Month) + val fixedEnd = active.copy(recurrence = active.recurrence.copy(endsAt = Instant.parse("2027-06-01T08:00:00Z"))) + val ended = active.copy(recurrence = active.recurrence.copy(endsAt = Instant.parse("2027-01-10T08:00:00Z"))) + + assertEquals(R.string.subscriptions__active, active.statusRes(now)) + assertEquals(R.string.subscriptions__renews, active.timingTitleRes(now)) + assertEquals( + R.string.subscriptions__renews_date to Instant.parse("2027-02-01T08:00:00Z"), + active.rowSubtitleSpec(now), + ) + assertEquals(R.string.subscriptions__expires, fixedEnd.timingTitleRes(now)) + assertEquals(R.string.subscriptions__expired, ended.statusRes(now)) + assertEquals(R.string.subscriptions__expired, ended.timingTitleRes(now)) + } + + @Test + fun `expired proposal with a future end date does not run until paid through`() { + val expiredProposal = subscription(PaykitRecurrenceUnit.Month).let { + it.copy( + lifecycleState = PaymentRequestLifecycleState.PROPOSAL_EXPIRED, + recurrence = it.recurrence.copy(endsAt = Instant.parse("2027-06-01T08:00:00Z")), + ) + } + + assertFalse(expiredProposal.runsUntilPaidThrough(now)) + assertEquals(R.string.subscriptions__expired, expiredProposal.timingTitleRes(now)) + } + + @Test + fun `canceled subscription is listed as active until its paid period ends then as expired`() { + val canceled = canceledWithPaidThrough() + val active = subscription(PaykitRecurrenceUnit.Month).copy(paymentRequestId = "active") + val subscriptions = listOf(canceled, active) + val accepted = { _: PaykitSubscriptionId -> now } + + val before = subscriptionSections(subscriptions, accepted, now) + assertEquals(listOf(canceled, active), before.active) + assertEquals(emptyList(), before.expired) + + val after = subscriptionSections(subscriptions, accepted, paidThrough) + assertEquals(listOf(active), after.active) + assertEquals(listOf(canceled), after.expired) + } + + @Test + fun `monthly cost counts a canceled subscription until its paid period ends`() { + val canceled = canceledWithPaidThrough().copy(amountSats = 1_200u) + + assertEquals(1_200L, subscriptionMonthlyCostSats(listOf(canceled), now)) + assertEquals(0L, subscriptionMonthlyCostSats(listOf(canceled), paidThrough)) + } + + @Test + fun `next transition includes the end of a canceled subscription's paid period`() { + val canceled = canceledWithPaidThrough() + + assertEquals(paidThrough, nextSubscriptionTransition(listOf(canceled), now)) + } + @Test fun `subscription payment confetti follows the settled rail`() { assertEquals( @@ -169,6 +297,13 @@ class SubscriptionsScreenTest { assertEquals(R.raw.confetti_purple, subscriptionConfettiResource(null)) } + private fun canceledWithPaidThrough() = subscription(PaykitRecurrenceUnit.Month).copy( + lifecycleState = PaymentRequestLifecycleState.CANCELED, + paidPeriods = listOf( + PaykitBillingPeriod(startsAt = Instant.parse("2027-01-01T08:00:00Z"), endsAt = paidThrough), + ), + ) + private fun subscription( unit: PaykitRecurrenceUnit, every: Int = 1, diff --git a/changelog.d/next/1391.fixed.md b/changelog.d/next/1391.fixed.md new file mode 100644 index 0000000000..d76428af47 --- /dev/null +++ b/changelog.d/next/1391.fixed.md @@ -0,0 +1 @@ +A canceled subscription now stays active until its paid period ends, then expires. diff --git a/journeys/subscriptions/cancel-and-delete.xml b/journeys/subscriptions/cancel-and-delete.xml index 672571c58e..eecd74386d 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -17,7 +17,10 @@ Tap Cancel Verify the sheet title reads "Cancel Subscription", the cross illustration is shown, and the swipe control reads "Swipe To Cancel" Swipe the confirm control to the end - Verify the sheet dismisses and "Journey Sub" leaves the ACTIVE section + Verify the sheet dismisses and "Journey Sub" stays in the ACTIVE section with "Expires" and the end date of the paid period as its subtitle + Tap the "Journey Sub" row + Verify STATUS reads "Active", the timing cell reads "EXPIRES" with the end date of the paid period, and the footer shows More Info without Cancel + Navigate back and verify the Subscriptions list is shown again Switch to the creator instance and open Subscriptions Verify "Journey Sub" is still listed under CREATED, because its paid periods are retained as history Tap the pending "Journey Sub Two" row in the CREATED section