Skip to content
Merged
27 changes: 26 additions & 1 deletion Bitkit/Services/PaykitSubscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -492,8 +492,33 @@ struct PaykitSubscription: Identifiable, Hashable {
lifecycleState == .activeRecurring && recurrence.endsAt.map { $0 > date } ?? true
}

/// A canceled subscription is paid up to its last paid period, whatever its fixed end date.
var canceledPaidThrough: Date? {
lifecycleState == .canceled ? paidPeriods.map(\.endsAt).max() : nil
}

/// The paid-through date of a canceled subscription that still runs; nil otherwise.
func canceledPaidThroughDate(at date: Date) -> Date? {
canceledPaidThrough.flatMap { $0 > date ? $0 : nil }
}

/// Active, or canceled with its last paid period still ahead: it runs until its paid-through date.
func runsUntilPaidThrough(at date: Date) -> Bool {
isActive(at: date) || canceledPaidThroughDate(at: date) != nil
}

/// The detail's timing cell needs a date to show: active, or an end date from the terms or a paid period.
func showsTiming(at date: Date) -> Bool {
isActive(at: date) || subscriptionEndDate(subscription: self) != nil
}

/// Expired and no longer running; a canceled subscription is not lapsed before its paid-through date.
func isLapsed(at date: Date) -> Bool {
isExpired(at: date) && !runsUntilPaidThrough(at: date)
}

func isCreatedVisible(at date: Date) -> Bool {
isCreatedByUser && (isProposalVisible(at: date) || isActive(at: date))
isCreatedByUser && (isProposalVisible(at: date) || isActive(at: date) || canceledPaidThroughDate(at: date) != nil)
}

func isExpiredVisible(at date: Date) -> Bool {
Expand Down
61 changes: 42 additions & 19 deletions Bitkit/Views/Subscriptions/SubscriptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,15 @@ struct SubscriptionsView: View {
}

private var active: [PaykitSubscription] {
paymentRequests.subscriptions.filter { $0.isPayer && $0.isActive(at: now) }
subscriptionSections(subscriptions: paymentRequests.subscriptions, now: now).active
}

private var expired: [PaykitSubscription] {
paymentRequests.subscriptions.filter { $0.isExpiredVisible(at: now) }
subscriptionSections(subscriptions: paymentRequests.subscriptions, now: now).expired
}

private var created: [PaykitSubscription] {
paymentRequests.subscriptions.filter { $0.isCreatedVisible(at: now) }
subscriptionSections(subscriptions: paymentRequests.subscriptions, now: now).created
}

private var hasVisibleSubscriptions: Bool {
Expand Down Expand Up @@ -248,7 +248,7 @@ func subscriptionMonthlyCostSats(subscriptions: [PaykitSubscription], now: Date)
}
}
let maximum = NSDecimalNumber(value: Int.max)
return subscriptions.filter { $0.isPayer && $0.isActive(at: now) }.reduce(into: 0) { total, subscription in
return subscriptions.filter { $0.isPayer && $0.runsUntilPaidThrough(at: now) }.reduce(into: 0) { total, subscription in
var monthlyCost = Decimal(subscription.amountSats) * annualPeriods(subscription.recurrence.unit)
/ Decimal(subscription.recurrence.every) / 12
var roundedMonthlyCost = Decimal()
Expand All @@ -258,10 +258,23 @@ func subscriptionMonthlyCostSats(subscriptions: [PaykitSubscription], now: Date)
}
}

/// When a subscription stopped running. An open-ended one has no end date of its own, so the last
/// period it was paid for is when it lapsed.
/// When a subscription stopped running. A canceled one ends with its last paid period; an open-ended
/// one has no end date of its own, so the last period it was paid for is when it lapsed.
func subscriptionEndDate(subscription: PaykitSubscription) -> Date? {
subscription.recurrence.endsAt ?? subscription.paidPeriods.map(\.endsAt).max()
subscription.canceledPaidThrough ?? subscription.recurrence.endsAt ?? subscription.paidPeriods.map(\.endsAt).max()
}

/// The ACTIVE, EXPIRED and CREATED sections: a canceled subscription stays under ACTIVE, or under CREATED
/// when the user created it, until it is paid through.
func subscriptionSections(
subscriptions: [PaykitSubscription],
now: Date
) -> (active: [PaykitSubscription], expired: [PaykitSubscription], created: [PaykitSubscription]) {
(
active: subscriptions.filter { $0.isPayer && $0.runsUntilPaidThrough(at: now) },
expired: subscriptions.filter { $0.isExpiredVisible(at: now) && $0.isLapsed(at: now) },
created: subscriptions.filter { $0.isCreatedVisible(at: now) }
)
}

func subscriptionNextTransitionDate(
Expand All @@ -273,6 +286,7 @@ func subscriptionNextTransitionDate(
[$0.recurrence.startsAt, $0.proposalExpiresAt, $0.recurrence.endsAt].compactMap { $0 }
}
dates += activeSubscriptions.compactMap { $0.recurrence.nextPeriod(after: now)?.startsAt }
dates += subscriptions.compactMap { $0.canceledPaidThroughDate(at: now) }
return dates.filter { $0 > now }.min()
}

Expand Down Expand Up @@ -318,7 +332,7 @@ struct SubscriptionRow: View {
.padding(16)
.background(Color.gray6)
.clipShape(RoundedRectangle(cornerRadius: 16))
.opacity(subscription.isExpired(at: now) ? 0.5 : 1)
.opacity(subscription.isLapsed(at: now) ? 0.5 : 1)
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityIdentifier("SubscriptionRow-\(subscription.paymentRequestId)")
Expand Down Expand Up @@ -401,7 +415,7 @@ struct SubscriptionDetailView: View {
}
.padding(.top, 24)
.padding(.bottom, 120)
.opacity(subscription.isExpired(at: now) ? 0.5 : 1)
.opacity(subscription.isLapsed(at: now) ? 0.5 : 1)
}

footer(subscription)
Expand Down Expand Up @@ -438,9 +452,9 @@ struct SubscriptionDetailView: View {
value: subscription.statusLabel(at: now),
icon: "check-mark"
)
if subscription.isActive(at: now) || subscription.isExpired(at: now) || subscription.recurrence.endsAt != nil {
if subscription.showsTiming(at: now) {
LabeledDetailCell(
title: timingTitle(subscription),
title: subscription.timingTitle(at: now),
value: renewalText(subscription),
icon: "calendar"
)
Expand Down Expand Up @@ -514,17 +528,13 @@ struct SubscriptionDetailView: View {
return date.map(Self.dateFormatter.string) ?? t("subscriptions__ongoing")
}

private func timingTitle(_ subscription: PaykitSubscription) -> String {
guard subscription.isActive(at: now) else { return t("subscriptions__expired") }
return subscription.recurrence.endsAt == nil ? t("subscriptions__renews") : t("subscriptions__expires")
}

private var nextTransitionDate: Date? {
guard let subscription else { return nil }
return [
subscription.recurrence.startsAt,
subscription.recurrence.endsAt,
subscription.isActive(at: now) ? subscription.recurrence.nextPeriod(after: now)?.startsAt : nil,
subscription.canceledPaidThroughDate(at: now),
]
.compactMap { $0 }
.filter { $0 > now }
Expand Down Expand Up @@ -1048,12 +1058,20 @@ extension PaykitSubscriptionRecurrence {
}
}

private extension PaykitSubscription {
extension PaykitSubscription {
func statusLabel(at now: Date) -> String {
if isProposalVisible(at: now) {
return t("subscriptions__pending")
}
return isActive(at: now) ? t("subscriptions__active") : t("subscriptions__expired")
return runsUntilPaidThrough(at: now) ? t("subscriptions__active") : t("subscriptions__expired")
}

/// A canceled subscription that still runs "Expires" on its paid-through date, like one with an end date.
func timingTitle(at now: Date) -> String {
if isActive(at: now) && recurrence.endsAt == nil {
return t("subscriptions__renews")
}
return runsUntilPaidThrough(at: now) ? t("subscriptions__expires") : t("subscriptions__expired")
}

func rowSubtitle(at now: Date) -> String {
Expand All @@ -1073,9 +1091,14 @@ private extension PaykitSubscription {
if isProposalVisible(at: now) || !recurrence.unit.isSupported {
return recurrence.subscriptionFrequencyLabel
}
if let paidThrough = canceledPaidThroughDate(at: now) {
let date = paidThrough.formatted(.dateTime.month(.wide).day())
return t("subscriptions__expires_date", variables: ["date": date])
}
if isExpired(at: now) {
guard let endsAt = recurrence.endsAt else { return t("subscriptions__expired") }
return t("subscriptions__expires_date", variables: ["date": endsAt.formatted(.dateTime.month(.wide).day())])
let end = canceledPaidThrough ?? endsAt
return t("subscriptions__expires_date", variables: ["date": end.formatted(.dateTime.month(.wide).day())])
}
if let endsAt = recurrence.endsAt {
return t("subscriptions__expires_date", variables: ["date": endsAt.formatted(.dateTime.month(.wide).day())])
Expand Down
Loading
Loading