From c392424fe8547b34e884664509c081f46877097b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 21:44:57 +0200 Subject: [PATCH 1/4] fix: keep canceled subscriptions active until they expire --- .../subscriptions/SubscriptionsScreen.kt | 112 +++++++++++------- .../subscriptions/SubscriptionsScreenTest.kt | 105 ++++++++++++++++ changelog.d/next/1391.fixed.md | 1 + journeys/subscriptions/cancel-and-delete.xml | 5 +- 4 files changed, 176 insertions(+), 47 deletions(-) create mode 100644 changelog.d/next/1391.fixed.md 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..c22a8941f6 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 it } + ?: (R.string.subscriptions__expired to null) } @Composable @@ -1091,18 +1100,26 @@ internal fun PaykitSubscription.shouldShowTiming(now: Instant): Boolean = internal fun PaykitSubscription.expiryDate(): Instant? = 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) +/** 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) || + (lifecycleState == PaymentRequestLifecycleState.CANCELED && expiryDate()?.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 +1153,8 @@ internal fun nextSubscriptionTransition( }.filterNotNull().toMutableList() dates += activeSubscriptions.mapNotNull { it.recurrence.nextPeriodAfter(now)?.startsAt } dates += subscriptions.mapNotNull { it.paymentDueOnAcceptance(now)?.billingPeriod?.endsAt } + dates += subscriptions.filter { it.lifecycleState == PaymentRequestLifecycleState.CANCELED } + .mapNotNull { it.expiryDate() } return dates.filter { it > now }.minOrNull() } @@ -1149,7 +1168,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..a9e87d551a 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,6 +9,7 @@ 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 kotlin.test.assertEquals @@ -19,6 +20,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 +158,102 @@ 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 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 +267,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..9337fdced7 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 under ACTIVE, because the period already paid for is still running, now with "Expires" and the end date of that period as its subtitle instead of "Renews" + 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 no Cancel button + Press Android 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 From 5e0597042e265abf5735118c569581e6f3e0a8c3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 00:36:56 +0200 Subject: [PATCH 2/4] fix: end a canceled subscription at its last paid period --- .../ui/screens/subscriptions/SubscriptionsScreen.kt | 12 +++++++----- .../subscriptions/SubscriptionsScreenTest.kt | 13 +++++++++++++ journeys/subscriptions/cancel-and-delete.xml | 6 +++--- 3 files changed, 23 insertions(+), 8 deletions(-) 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 c22a8941f6..6e5f30e2c8 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 @@ -1098,12 +1098,15 @@ 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 } + +/** A canceled subscription is paid for up to its last paid period, whatever its fixed end date. */ +private fun PaykitSubscription.canceledPaidThrough(): Instant? = + if (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) || - (lifecycleState == PaymentRequestLifecycleState.CANCELED && expiryDate()?.let { it > now } == true) + 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) @@ -1153,8 +1156,7 @@ internal fun nextSubscriptionTransition( }.filterNotNull().toMutableList() dates += activeSubscriptions.mapNotNull { it.recurrence.nextPeriodAfter(now)?.startsAt } dates += subscriptions.mapNotNull { it.paymentDueOnAcceptance(now)?.billingPeriod?.endsAt } - dates += subscriptions.filter { it.lifecycleState == PaymentRequestLifecycleState.CANCELED } - .mapNotNull { it.expiryDate() } + dates += subscriptions.mapNotNull { it.canceledPaidThrough() } return dates.filter { it > now }.minOrNull() } 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 a9e87d551a..274f186412 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 @@ -183,6 +183,19 @@ class SubscriptionsScreenTest { 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(paidThrough, nextSubscriptionTransition(listOf(canceled), now)) + } + @Test fun `canceled subscription without an end date shows no timing`() { val canceled = subscription(PaykitRecurrenceUnit.Month) diff --git a/journeys/subscriptions/cancel-and-delete.xml b/journeys/subscriptions/cancel-and-delete.xml index 9337fdced7..eecd74386d 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -17,10 +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" stays under ACTIVE, because the period already paid for is still running, now with "Expires" and the end date of that period as its subtitle instead of "Renews" + 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 no Cancel button - Press Android back and verify the Subscriptions list is shown again + 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 From ae6525c92756002a4956a3aa6b74fd5fe2cbb903 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 01:34:14 +0200 Subject: [PATCH 3/4] fix: keep canceled created subscriptions expired --- .../screens/subscriptions/SubscriptionsScreen.kt | 8 ++++++-- .../subscriptions/SubscriptionsScreenTest.kt | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 2 deletions(-) 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 6e5f30e2c8..95713b7b08 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 @@ -1100,9 +1100,13 @@ internal fun PaykitSubscription.shouldShowTiming(now: Instant): Boolean = internal fun PaykitSubscription.expiryDate(): Instant? = canceledPaidThrough() ?: recurrence.endsAt ?: paidPeriods.maxOfOrNull { it.endsAt } -/** A canceled subscription is paid for up to its last paid period, whatever its fixed end date. */ +/** 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 (lifecycleState == PaymentRequestLifecycleState.CANCELED) paidPeriods.maxOfOrNull { it.endsAt } else null + 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 = 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 274f186412..bf2653a9df 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 @@ -12,6 +12,7 @@ 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 @@ -196,6 +197,20 @@ class SubscriptionsScreenTest { 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) From fa6fb4cfc0e32f971f23bc6ccda32d29e407391a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 11:36:39 +0200 Subject: [PATCH 4/4] fix: align ended canceled row date with the detail --- .../to/bitkit/ui/screens/subscriptions/SubscriptionsScreen.kt | 2 +- .../bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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 95713b7b08..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 @@ -1067,7 +1067,7 @@ internal fun PaykitSubscription.rowSubtitleSpec(now: Instant): Pair 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 it } + else -> recurrence.endsAt?.let { R.string.subscriptions__expires_date to (canceledPaidThrough() ?: it) } ?: (R.string.subscriptions__expired to null) } 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 bf2653a9df..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 @@ -194,6 +194,8 @@ class SubscriptionsScreenTest { 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)) }