From c392424fe8547b34e884664509c081f46877097b Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 21:44:57 +0200 Subject: [PATCH 1/7] 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/7] 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/7] 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/7] 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)) } From cf5acce0cdce961e522766cd58d61e6328f95044 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Oct 2026 15:22:25 +0200 Subject: [PATCH 5/7] fix: list canceled created subscriptions as created until paid through --- .../subscriptions/SubscriptionsScreen.kt | 18 ++++---- .../subscriptions/SubscriptionsScreenTest.kt | 41 ++++++++++++++++--- journeys/subscriptions/cancel-and-delete.xml | 5 ++- 3 files changed, 49 insertions(+), 15 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 33178617fc..5e20641d40 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 @@ -304,8 +304,10 @@ internal fun subscriptionSections( ) = 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) }, + expired = subscriptions.filter { + (it.isPayer && it.hasEnded(now) && acceptedAt(it.id) != null) || it.isCreatedAndLapsed(now) + }, + created = subscriptions.filter { it.isCreatedVisible(now) && !it.isCreatedAndLapsed(now) }, ) private fun LazyListScope.subscriptionSection( @@ -1101,13 +1103,13 @@ internal fun PaykitSubscription.shouldShowTiming(now: Instant): Boolean = internal fun PaykitSubscription.expiryDate(): Instant? = canceledPaidThrough() ?: recurrence.endsAt ?: paidPeriods.maxOfOrNull { it.endsAt } -/** A canceled subscription we pay is paid for up to its last paid period, whatever its fixed end date. */ +/** A canceled subscription 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 - } + if (lifecycleState == PaymentRequestLifecycleState.CANCELED) paidPeriods.maxOfOrNull { it.endsAt } else null + +/** A canceled subscription the user created, past its last paid period: listed as expired, not as created. */ +private fun PaykitSubscription.isCreatedAndLapsed(now: Instant): Boolean = + isCreatedByUser && canceledPaidThrough()?.let { it <= now } == true /** 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 86961f585e..1842fb2b7f 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 @@ -216,17 +216,48 @@ class SubscriptionsScreenTest { } @Test - fun `canceled subscription the user created keeps its expired treatment and stays under created`() { + fun `canceled subscription the user created stays under created until its paid period ends`() { 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)) + assertTrue(created.runsUntilPaidThrough(now)) + assertFalse(created.hasEnded(now)) + assertEquals(R.string.subscriptions__active, created.statusRes(now)) + assertEquals(R.string.subscriptions__expires, created.timingTitleRes(now)) + assertEquals(R.string.subscriptions__expires_date to paidThrough, created.rowSubtitleSpec(now)) assertEquals(listOf(created), sections.created) assertEquals(emptyList(), sections.active) assertEquals(emptyList(), sections.expired) + assertEquals(paidThrough, nextSubscriptionTransition(listOf(created), now)) + assertEquals(0L, subscriptionMonthlyCostSats(listOf(created), now)) + } + + @Test + fun `canceled subscription the user created moves to expired when its paid period ends`() { + val created = canceledWithPaidThrough().copy(role = PaykitSubscriptionRole.Payee) + val sections = subscriptionSections(listOf(created), { null }, paidThrough) + + assertFalse(created.runsUntilPaidThrough(paidThrough)) + assertTrue(created.hasEnded(paidThrough)) + assertEquals(R.string.subscriptions__expired, created.statusRes(paidThrough)) + assertEquals(R.string.subscriptions__expired, created.timingTitleRes(paidThrough)) + assertEquals(R.string.subscriptions__expired to null, created.rowSubtitleSpec(paidThrough)) + assertEquals(listOf(created), sections.expired) + assertEquals(emptyList(), sections.created) + assertEquals(emptyList(), sections.active) + } + + @Test + fun `created subscription that was never canceled stays under created after its end date`() { + val ended = canceledWithPaidThrough().copy( + role = PaykitSubscriptionRole.Payee, + lifecycleState = PaymentRequestLifecycleState.ACTIVE_RECURRING, + ).let { it.copy(recurrence = it.recurrence.copy(endsAt = paidThrough)) } + val sections = subscriptionSections(listOf(ended), { null }, paidThrough) + + assertEquals(listOf(ended), sections.created) + assertEquals(emptyList(), sections.expired) + assertTrue(ended.hasEnded(paidThrough)) } @Test diff --git a/journeys/subscriptions/cancel-and-delete.xml b/journeys/subscriptions/cancel-and-delete.xml index eecd74386d..53c2039f16 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -7,7 +7,8 @@ Deletion needs its own subscription rather than the one just cancelled: the creator's Delete button is gated on the subscription still being proposed or active, so a cancellation reaching the creator removes it. A proposal with no paid periods also leaves CREATED once deleted, whereas one that has - taken a payment stays there to retain its history. + taken a payment stays there to retain its history, and once canceled keeps its place until the paid + period ends, then moves to EXPIRED. Launch the E2E Bitkit app with an active subscription the payer accepted, per review-and-subscribe.xml @@ -22,7 +23,7 @@ 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 + Verify "Journey Sub" is still listed under CREATED with "Expires" and the end date of the paid period as its subtitle, because its paid periods are retained as history until that period ends, when it moves to EXPIRED Tap the pending "Journey Sub Two" row in the CREATED section Verify the detail shows SUBSCRIBERS and PAYMENTS cells, that PAYMENTS uses the coins icon, and that the footer Delete button carries the trash icon Tap Delete From f75355d04ced2b1296b409e0c3d90467d44dabe5 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Oct 2026 15:27:19 +0200 Subject: [PATCH 6/7] fix: hide canceled blocked peer subscriptions while paid --- .../repositories/PaykitPaymentRequestRepo.kt | 2 +- .../bitkit/repositories/PaykitSubscription.kt | 8 ++++++ .../subscriptions/SubscriptionsScreen.kt | 10 ++----- ...aykitPaymentRequestRepoSubscriptionTest.kt | 28 +++++++++++++++++++ .../subscriptions/SubscriptionsScreenTest.kt | 1 + 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt index 8d97c66ed6..767df7a1db 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitPaymentRequestRepo.kt @@ -945,7 +945,7 @@ class PaykitPaymentRequestRepo @Inject constructor( subscription.id in blockedSubscriptionIds && ( subscription.paidPeriods.isEmpty() || subscription.isProposalVisible(subscriptionNow) || - subscription.isActive(subscriptionNow) + subscription.runsUntilPaidThrough(subscriptionNow) ) } val restoredAcceptances = allSubscriptions diff --git a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt index 34dcedb4ff..b4d3dc61a2 100644 --- a/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt +++ b/app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt @@ -288,6 +288,14 @@ data class PaykitSubscription( } } +/** A canceled subscription is paid for up to its last paid period, whatever its fixed end date. */ +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. */ +fun PaykitSubscription.runsUntilPaidThrough(now: Instant): Boolean = + isActive(now) || canceledPaidThrough()?.let { it > now } == true + @Suppress("CyclomaticComplexMethod", "ReturnCount") internal fun PaymentRequestRecord.toPaykitSubscription( deliveryStatusOverride: PaykitPaymentRequestDeliveryStatus? = null, 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 5e20641d40..0fed380841 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 @@ -72,6 +72,8 @@ import to.bitkit.repositories.PaykitPaymentRequestId import to.bitkit.repositories.PaykitRecurrenceUnit import to.bitkit.repositories.PaykitSubscription import to.bitkit.repositories.PaykitSubscriptionId +import to.bitkit.repositories.canceledPaidThrough +import to.bitkit.repositories.runsUntilPaidThrough import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.BodyS @@ -1103,18 +1105,10 @@ 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. */ -private fun PaykitSubscription.canceledPaidThrough(): Instant? = - if (lifecycleState == PaymentRequestLifecycleState.CANCELED) paidPeriods.maxOfOrNull { it.endsAt } else null - /** A canceled subscription the user created, past its last paid period: listed as expired, not as created. */ private fun PaykitSubscription.isCreatedAndLapsed(now: Instant): Boolean = isCreatedByUser && canceledPaidThrough()?.let { it <= now } == true -/** 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) diff --git a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt index 61027d4415..baaa8cc031 100644 --- a/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PaykitPaymentRequestRepoSubscriptionTest.kt @@ -716,8 +716,35 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat assertEquals(PaykitPaymentProofKind.Lightning, request.paymentProofKind) } + @Test + fun `blocking a canceled subscription hides it while its paid period runs`() = test { + val proof = mock { + on { billingPeriod } doReturn BillingPeriod( + startsAt = "2027-01-01T08:00:00Z", + endsAt = "2027-02-01T08:00:00Z", + ) + on { paymentEndpointIdentifier } doReturn MethodId.Bolt11.rawValue + } + whenever(paykitSdkService.paymentRequests()).thenReturn( + listOf( + paymentRequestRecord( + state = PaymentRequestLifecycleState.CANCELED, + paymentProofs = listOf(proof), + ), + ), + ) + whenever(paykitSdkService.linkedPeers()).thenReturn( + listOf(linkedPeer(COUNTERPARTY, LinkedPeerState.BLOCKED, PaykitReceiverPaths.SERVER)), + ) + + sut.refresh().getOrThrow() + + assertTrue(sut.subscriptions.value.isEmpty()) + } + @Test fun `blocking a paid payer subscription keeps payment history`() = test { + advanceTimeBy(20 * 24 * 60 * 60 * 1000L) val proof = mock { on { billingPeriod } doReturn BillingPeriod( startsAt = "2027-01-01T08:00:00Z", @@ -750,6 +777,7 @@ class PaykitPaymentRequestRepoSubscriptionTest : BaseUnitTest(StandardTestDispat @Test fun `blocking a paid creator subscription keeps received history accessible`() = test { + advanceTimeBy(20 * 24 * 60 * 60 * 1000L) val proof = mock { on { billingPeriod } doReturn BillingPeriod( startsAt = "2027-01-01T08:00:00Z", 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 1842fb2b7f..804aa4a369 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 @@ -13,6 +13,7 @@ import to.bitkit.repositories.PaykitSubscriptionId import to.bitkit.repositories.PaykitSubscriptionMetadata import to.bitkit.repositories.PaykitSubscriptionRecurrence import to.bitkit.repositories.PaykitSubscriptionRole +import to.bitkit.repositories.runsUntilPaidThrough import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue From 4e451e731cad3624afd2efeff1da18641a27db41 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Oct 2026 15:27:19 +0200 Subject: [PATCH 7/7] fix: order the cancel journey steps by screen --- journeys/subscriptions/cancel-and-delete.xml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/journeys/subscriptions/cancel-and-delete.xml b/journeys/subscriptions/cancel-and-delete.xml index 53c2039f16..ebb5756225 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -18,10 +18,9 @@ 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 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 + Verify the sheet dismisses and the "Journey Sub" detail shows STATUS "Active", the timing cell "EXPIRES" with the end date of the paid period, and the footer shows More Info without Cancel + Navigate back and verify the Subscriptions list shows "Journey Sub" in the ACTIVE section with "Expires" and the end date of the paid period as its subtitle + Tap the "Journey Sub" row and verify its detail opens again Switch to the creator instance and open Subscriptions Verify "Journey Sub" is still listed under CREATED with "Expires" and the end date of the paid period as its subtitle, because its paid periods are retained as history until that period ends, when it moves to EXPIRED Tap the pending "Journey Sub Two" row in the CREATED section