Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@file:OptIn(ExperimentalTime::class)
@file:Suppress("TooManyFunctions")

package to.bitkit.ui.screens.subscriptions

Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -290,6 +289,24 @@ internal fun SubscriptionsContent(
}
}

internal data class SubscriptionSections(
val proposals: List<PaykitSubscription>,
val active: List<PaykitSubscription>,
val expired: List<PaykitSubscription>,
val created: List<PaykitSubscription>,
)

internal fun subscriptionSections(
subscriptions: List<PaykitSubscription>,
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<PaykitSubscription>,
Expand All @@ -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) },
)
}
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<Int, Instant?>? = 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
Expand Down Expand Up @@ -1089,20 +1098,35 @@ internal fun PaykitSubscription.shouldShowTiming(now: Instant): Boolean =
isActive(now) || expiryDate() != null

internal fun PaykitSubscription.expiryDate(): Instant? =
recurrence.endsAt ?: paidPeriods.maxOfOrNull { it.endsAt }
canceledPaidThrough() ?: recurrence.endsAt ?: paidPeriods.maxOfOrNull { it.endsAt }

@Composable
private fun PaykitSubscription.statusText(now: Instant): String = when {
isProposalVisible(now) -> stringResource(R.string.subscriptions__pending)
isActive(now) -> stringResource(R.string.subscriptions__active)
else -> stringResource(R.string.subscriptions__expired)
/** A canceled subscription we pay is paid for up to its last paid period, whatever its fixed end date. */
private fun PaykitSubscription.canceledPaidThrough(): Instant? =
if (isPayer && lifecycleState == PaymentRequestLifecycleState.CANCELED) {
paidPeriods.maxOfOrNull { it.endsAt }
} else {
null
}

/** Active, or canceled with its last paid period still ahead: it keeps running until it is paid through. */
internal fun PaykitSubscription.runsUntilPaidThrough(now: Instant): Boolean =
isActive(now) || canceledPaidThrough()?.let { it > now } == true

/** Shown as expired: it no longer runs, whether canceled, rejected or lapsed. */
internal fun PaykitSubscription.hasEnded(now: Instant): Boolean = isExpired(now) && !runsUntilPaidThrough(now)

@StringRes
internal fun PaykitSubscription.statusRes(now: Instant): Int = when {
isProposalVisible(now) -> R.string.subscriptions__pending
runsUntilPaidThrough(now) -> R.string.subscriptions__active
else -> R.string.subscriptions__expired
}

@Composable
private fun PaykitSubscription.timingTitle(now: Instant): String = when {
!isActive(now) -> stringResource(R.string.subscriptions__expired)
recurrence.endsAt == null -> stringResource(R.string.subscriptions__renews)
else -> stringResource(R.string.subscriptions__expires)
@StringRes
internal fun PaykitSubscription.timingTitleRes(now: Instant): Int = when {
isActive(now) && recurrence.endsAt == null -> R.string.subscriptions__renews
runsUntilPaidThrough(now) -> R.string.subscriptions__expires
else -> R.string.subscriptions__expired
}

@Composable
Expand Down Expand Up @@ -1136,6 +1160,7 @@ internal fun nextSubscriptionTransition(
}.filterNotNull().toMutableList()
dates += activeSubscriptions.mapNotNull { it.recurrence.nextPeriodAfter(now)?.startsAt }
dates += subscriptions.mapNotNull { it.paymentDueOnAcceptance(now)?.billingPeriod?.endsAt }
dates += subscriptions.mapNotNull { it.canceledPaidThrough() }
return dates.filter { it > now }.minOrNull()
}

Expand All @@ -1149,7 +1174,8 @@ internal fun subscriptionMonthlyCostSats(
subscriptions: List<PaykitSubscription>,
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,10 @@ import to.bitkit.models.NewTransactionSheetType
import to.bitkit.repositories.PaykitBillingPeriod
import to.bitkit.repositories.PaykitRecurrenceUnit
import to.bitkit.repositories.PaykitSubscription
import to.bitkit.repositories.PaykitSubscriptionId
import to.bitkit.repositories.PaykitSubscriptionMetadata
import to.bitkit.repositories.PaykitSubscriptionRecurrence
import to.bitkit.repositories.PaykitSubscriptionRole
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
Expand All @@ -19,6 +21,7 @@ import kotlin.time.Instant

class SubscriptionsScreenTest {
private val now = Instant.parse("2027-01-15T08:00:00Z")
private val paidThrough = Instant.parse("2027-02-01T08:00:00Z")

@Test
fun `next transition includes the next recurring period`() {
Expand Down Expand Up @@ -156,6 +159,131 @@ class SubscriptionsScreenTest {
assertFalse(fixedEnd.canCancel(now))
}

@Test
fun `canceled subscription before its paid period ends reads active and expires`() {
val canceled = canceledWithPaidThrough()

assertTrue(canceled.runsUntilPaidThrough(now))
assertEquals(R.string.subscriptions__active, canceled.statusRes(now))
assertEquals(R.string.subscriptions__expires, canceled.timingTitleRes(now))
assertEquals(R.string.subscriptions__expires_date to paidThrough, canceled.rowSubtitleSpec(now))
assertTrue(canceled.shouldShowTiming(now))
assertEquals(paidThrough, canceled.expiryDate())
assertFalse(canceled.canCancel(now))
}

@Test
fun `canceled subscription expires when its paid period ends`() {
val canceled = canceledWithPaidThrough()

assertFalse(canceled.runsUntilPaidThrough(paidThrough))
assertEquals(R.string.subscriptions__expired, canceled.statusRes(paidThrough))
assertEquals(R.string.subscriptions__expired, canceled.timingTitleRes(paidThrough))
assertEquals(R.string.subscriptions__expired to null, canceled.rowSubtitleSpec(paidThrough))
assertTrue(canceled.hasEnded(paidThrough))
assertFalse(canceled.hasEnded(now))
}

@Test
fun `canceled subscription stops running at its last paid period even with a later end date`() {
val canceled = canceledWithPaidThrough().let {
it.copy(recurrence = it.recurrence.copy(endsAt = Instant.parse("2027-06-01T08:00:00Z")))
}

assertEquals(paidThrough, canceled.expiryDate())
assertTrue(canceled.runsUntilPaidThrough(now))
assertFalse(canceled.runsUntilPaidThrough(paidThrough))
assertEquals(R.string.subscriptions__expired, canceled.statusRes(paidThrough))
assertEquals(R.string.subscriptions__expires_date to paidThrough, canceled.rowSubtitleSpec(now))
assertEquals(R.string.subscriptions__expires_date to paidThrough, canceled.rowSubtitleSpec(paidThrough))
assertEquals(paidThrough, nextSubscriptionTransition(listOf(canceled), now))
}

@Test
fun `canceled subscription the user created keeps its expired treatment and stays under created`() {
val created = canceledWithPaidThrough().copy(role = PaykitSubscriptionRole.Payee)
val sections = subscriptionSections(listOf(created), { now }, now)

assertFalse(created.runsUntilPaidThrough(now))
assertTrue(created.hasEnded(now))
assertEquals(R.string.subscriptions__expired, created.statusRes(now))
assertEquals(R.string.subscriptions__expired, created.timingTitleRes(now))
assertEquals(listOf(created), sections.created)
assertEquals(emptyList(), sections.active)
assertEquals(emptyList(), sections.expired)
}

@Test
fun `canceled subscription without an end date shows no timing`() {
val canceled = subscription(PaykitRecurrenceUnit.Month)
.copy(lifecycleState = PaymentRequestLifecycleState.CANCELED)

assertFalse(canceled.runsUntilPaidThrough(now))
assertFalse(canceled.shouldShowTiming(now))
assertEquals(R.string.subscriptions__expired, canceled.statusRes(now))
}

@Test
fun `active and ended subscriptions keep their status and timing`() {
val active = subscription(PaykitRecurrenceUnit.Month)
val fixedEnd = active.copy(recurrence = active.recurrence.copy(endsAt = Instant.parse("2027-06-01T08:00:00Z")))
val ended = active.copy(recurrence = active.recurrence.copy(endsAt = Instant.parse("2027-01-10T08:00:00Z")))

assertEquals(R.string.subscriptions__active, active.statusRes(now))
assertEquals(R.string.subscriptions__renews, active.timingTitleRes(now))
assertEquals(
R.string.subscriptions__renews_date to Instant.parse("2027-02-01T08:00:00Z"),
active.rowSubtitleSpec(now),
)
assertEquals(R.string.subscriptions__expires, fixedEnd.timingTitleRes(now))
assertEquals(R.string.subscriptions__expired, ended.statusRes(now))
assertEquals(R.string.subscriptions__expired, ended.timingTitleRes(now))
}

@Test
fun `expired proposal with a future end date does not run until paid through`() {
val expiredProposal = subscription(PaykitRecurrenceUnit.Month).let {
it.copy(
lifecycleState = PaymentRequestLifecycleState.PROPOSAL_EXPIRED,
recurrence = it.recurrence.copy(endsAt = Instant.parse("2027-06-01T08:00:00Z")),
)
}

assertFalse(expiredProposal.runsUntilPaidThrough(now))
assertEquals(R.string.subscriptions__expired, expiredProposal.timingTitleRes(now))
}

@Test
fun `canceled subscription is listed as active until its paid period ends then as expired`() {
val canceled = canceledWithPaidThrough()
val active = subscription(PaykitRecurrenceUnit.Month).copy(paymentRequestId = "active")
val subscriptions = listOf(canceled, active)
val accepted = { _: PaykitSubscriptionId -> now }

val before = subscriptionSections(subscriptions, accepted, now)
assertEquals(listOf(canceled, active), before.active)
assertEquals(emptyList(), before.expired)

val after = subscriptionSections(subscriptions, accepted, paidThrough)
assertEquals(listOf(active), after.active)
assertEquals(listOf(canceled), after.expired)
}

@Test
fun `monthly cost counts a canceled subscription until its paid period ends`() {
val canceled = canceledWithPaidThrough().copy(amountSats = 1_200u)

assertEquals(1_200L, subscriptionMonthlyCostSats(listOf(canceled), now))
assertEquals(0L, subscriptionMonthlyCostSats(listOf(canceled), paidThrough))
}

@Test
fun `next transition includes the end of a canceled subscription's paid period`() {
val canceled = canceledWithPaidThrough()

assertEquals(paidThrough, nextSubscriptionTransition(listOf(canceled), now))
}

@Test
fun `subscription payment confetti follows the settled rail`() {
assertEquals(
Expand All @@ -169,6 +297,13 @@ class SubscriptionsScreenTest {
assertEquals(R.raw.confetti_purple, subscriptionConfettiResource(null))
}

private fun canceledWithPaidThrough() = subscription(PaykitRecurrenceUnit.Month).copy(
lifecycleState = PaymentRequestLifecycleState.CANCELED,
paidPeriods = listOf(
PaykitBillingPeriod(startsAt = Instant.parse("2027-01-01T08:00:00Z"), endsAt = paidThrough),
),
)

private fun subscription(
unit: PaykitRecurrenceUnit,
every: Int = 1,
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/1391.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
A canceled subscription now stays active until its paid period ends, then expires.
Loading
Loading