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 26ee057e24..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 @@ -1,4 +1,5 @@ @file:OptIn(ExperimentalTime::class) +@file:Suppress("TooManyFunctions") package to.bitkit.ui.screens.subscriptions @@ -71,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 @@ -168,12 +171,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] @@ -215,34 +216,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, @@ -291,6 +292,26 @@ 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) || it.isCreatedAndLapsed(now) + }, + created = subscriptions.filter { it.isCreatedVisible(now) && !it.isCreatedAndLapsed(now) }, +) + private fun LazyListScope.subscriptionSection( @StringRes titleRes: Int, subscriptions: List, @@ -307,7 +328,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) }, ) } @@ -448,7 +469,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()) { @@ -514,13 +535,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), @@ -1040,27 +1061,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 @@ -1090,20 +1103,27 @@ 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 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 + +/** 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 @@ -1139,6 +1159,7 @@ internal fun nextSubscriptionTransition( }.filterNotNull().toMutableList() dates += activeSubscriptions.mapNotNull { it.recurrence.nextPeriodAfter(now)?.startsAt } dates += subscriptions.mapNotNull { it.paymentDueOnAcceptance(now, acceptedAt)?.billingPeriod?.endsAt } + dates += subscriptions.mapNotNull { it.canceledPaidThrough() } return dates.filter { it > now }.minOrNull() } @@ -1152,7 +1173,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/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 9625990a2a..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 @@ -9,8 +9,11 @@ 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 to.bitkit.repositories.runsUntilPaidThrough import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -19,6 +22,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`() { @@ -172,6 +176,162 @@ 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 stays under created until its paid period ends`() { + val created = canceledWithPaidThrough().copy(role = PaykitSubscriptionRole.Payee) + val sections = subscriptionSections(listOf(created), { now }, 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 + 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( @@ -185,6 +345,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..ebb5756225 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 @@ -17,9 +18,11 @@ 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 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, 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