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
25 changes: 25 additions & 0 deletions Bitkit/Services/PaykitSubscription.swift
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,31 @@ struct PaykitSubscription: Identifiable, Hashable {
lifecycleState == .activeRecurring && recurrence.endsAt.map { $0 > date } ?? true
}

/// A canceled subscription the user pays for is paid up to its last paid period, whatever its fixed end date.
var canceledPaidThrough: Date? {
lifecycleState == .canceled && isPayer ? 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))
}
Expand Down
57 changes: 39 additions & 18 deletions Bitkit/Views/Subscriptions/SubscriptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,11 @@ 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] {
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,21 @@ 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 and EXPIRED sections: a canceled subscription stays under ACTIVE until it is paid through.
func subscriptionSections(
subscriptions: [PaykitSubscription],
now: Date
) -> (active: [PaykitSubscription], expired: [PaykitSubscription]) {
(
active: subscriptions.filter { $0.isPayer && $0.runsUntilPaidThrough(at: now) },
expired: subscriptions.filter { $0.isExpiredVisible(at: now) && $0.isLapsed(at: now) }
Comment thread
ovitrif marked this conversation as resolved.
Comment thread
ovitrif marked this conversation as resolved.
)
}

func subscriptionNextTransitionDate(
Expand All @@ -273,6 +284,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 +330,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 +413,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 +450,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 +526,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 +1056,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 +1089,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
151 changes: 151 additions & 0 deletions BitkitTests/PaykitPaymentRequestServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1504,6 +1504,157 @@ final class PaykitPaymentRequestServiceTests: XCTestCase {
)
}

private func canceledSubscription(
paid: Bool,
endsAt: String? = nil,
role: PaymentRequestLocalRole = .payer
) throws -> PaykitSubscription {
let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z")
return try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord(
state: .canceled,
role: role,
amount: "0.000012",
recurrence: PaymentRequestRecurrence(
every: 1,
unit: "month",
startsAt: period.startsAt,
anchor: period.startsAt,
endsAt: endsAt
),
paymentProofs: paid ? [paymentProofRecord(
endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue,
kind: .lightning,
billingPeriod: period
)] : []
)))
}

func testCanceledSubscriptionStaysActiveUntilItsPaidPeriodEnds() throws {
let canceled = try canceledSubscription(paid: true)
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
let paidThrough = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z"))
let expiresDate = t("subscriptions__expires_date", variables: ["date": paidThrough.formatted(.dateTime.month(.wide).day())])

XCTAssertEqual(canceled.statusLabel(at: now), t("subscriptions__active"))
XCTAssertEqual(canceled.rowSubtitle(at: now), expiresDate)
XCTAssertEqual(canceled.timingTitle(at: now), t("subscriptions__expires"))
XCTAssertTrue(canceled.showsTiming(at: now))
XCTAssertFalse(canceled.canCancel(at: now))

XCTAssertEqual(canceled.statusLabel(at: paidThrough), t("subscriptions__expired"))
XCTAssertEqual(canceled.rowSubtitle(at: paidThrough), t("subscriptions__expired"))
XCTAssertEqual(canceled.timingTitle(at: paidThrough), t("subscriptions__expired"))
}

func testCanceledSubscriptionMovesFromActiveToExpiredSectionAtThePaidThroughDate() throws {
let canceled = try canceledSubscription(paid: true)
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
let paidThrough = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z"))

var sections = subscriptionSections(subscriptions: [canceled], now: now)
XCTAssertEqual(sections.active.map(\.id), [canceled.id])
XCTAssertTrue(sections.expired.isEmpty)
XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: now), 1200)

sections = subscriptionSections(subscriptions: [canceled], now: paidThrough)
XCTAssertTrue(sections.active.isEmpty)
XCTAssertEqual(sections.expired.map(\.id), [canceled.id])
XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: paidThrough), 0)
}

func testCanceledSubscriptionEndsAtItsLastPaidPeriodWhateverItsFixedEndDate() throws {
let canceled = try canceledSubscription(paid: true, endsAt: "2027-06-01T08:00:00Z")
let beforePaidThrough = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
let paidThrough = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z"))

XCTAssertEqual(subscriptionEndDate(subscription: canceled), paidThrough)
XCTAssertEqual(canceled.statusLabel(at: beforePaidThrough), t("subscriptions__active"))
XCTAssertEqual(canceled.timingTitle(at: beforePaidThrough), t("subscriptions__expires"))
XCTAssertEqual(subscriptionNextTransitionDate(subscriptions: [canceled], now: beforePaidThrough), paidThrough)

XCTAssertEqual(canceled.statusLabel(at: paidThrough), t("subscriptions__expired"))
XCTAssertEqual(canceled.timingTitle(at: paidThrough), t("subscriptions__expired"))
XCTAssertEqual(
canceled.rowSubtitle(at: paidThrough),
t("subscriptions__expires_date", variables: ["date": paidThrough.formatted(.dateTime.month(.wide).day())])
)
XCTAssertTrue(subscriptionSections(subscriptions: [canceled], now: paidThrough).active.isEmpty)
XCTAssertEqual(subscriptionSections(subscriptions: [canceled], now: paidThrough).expired.map(\.id), [canceled.id])
XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: paidThrough), 0)
}

func testCanceledSubscriptionCreatedByTheUserStaysExpiredAndListed() throws {
let canceled = try canceledSubscription(paid: true, role: .payee)
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))

XCTAssertEqual(canceled.statusLabel(at: now), t("subscriptions__expired"))
XCTAssertTrue(subscriptionSections(subscriptions: [canceled], now: now).active.isEmpty)
XCTAssertEqual(subscriptionSections(subscriptions: [canceled], now: now).expired.map(\.id), [canceled.id])
}

func testCanceledSubscriptionTimerFlipsAtThePaidThroughDate() throws {
let canceled = try canceledSubscription(paid: true)
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))

XCTAssertEqual(
subscriptionNextTransitionDate(subscriptions: [canceled], now: now),
ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z")
)
}

func testCanceledSubscriptionWithoutPaidPeriodIsExpiredWithoutEndDate() throws {
let canceled = try canceledSubscription(paid: false)
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))

XCTAssertNil(subscriptionEndDate(subscription: canceled))
XCTAssertFalse(canceled.showsTiming(at: now))
XCTAssertEqual(canceled.statusLabel(at: now), t("subscriptions__expired"))
XCTAssertEqual(canceled.rowSubtitle(at: now), t("subscriptions__expired"))
XCTAssertNil(subscriptionNextTransitionDate(subscriptions: [canceled], now: now))
}

func testRejectedSubscriptionWithFutureEndDateStaysExpired() throws {
let rejected = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord(
state: .rejected,
recurrence: PaymentRequestRecurrence(
every: 1,
unit: "month",
startsAt: "2027-01-01T08:00:00Z",
anchor: "2027-01-01T08:00:00Z",
endsAt: "2027-06-01T08:00:00Z"
)
)))
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))

XCTAssertEqual(rejected.statusLabel(at: now), t("subscriptions__expired"))
XCTAssertEqual(rejected.timingTitle(at: now), t("subscriptions__expired"))
}

func testActiveAndEndedSubscriptionsKeepTheirStatusAndTiming() throws {
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
func subscription(endsAt: String?) throws -> PaykitSubscription {
try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord(
state: .activeRecurring,
recurrence: PaymentRequestRecurrence(
every: 1,
unit: "month",
startsAt: "2027-01-01T08:00:00Z",
anchor: "2027-01-01T08:00:00Z",
endsAt: endsAt
)
)))
}
let openEnded = try subscription(endsAt: nil)
let fixedEnd = try subscription(endsAt: "2027-06-01T08:00:00Z")
let ended = try subscription(endsAt: "2027-01-10T08:00:00Z")

XCTAssertEqual(openEnded.statusLabel(at: now), t("subscriptions__active"))
XCTAssertEqual(openEnded.timingTitle(at: now), t("subscriptions__renews"))
XCTAssertEqual(fixedEnd.timingTitle(at: now), t("subscriptions__expires"))
XCTAssertEqual(ended.statusLabel(at: now), t("subscriptions__expired"))
XCTAssertEqual(ended.timingTitle(at: now), t("subscriptions__expired"))
}

func testActiveSubscriptionTransitionUsesNextPeriodBoundary() throws {
let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z"))
let weekly = PaymentRequestRecurrence(
Expand Down
1 change: 1 addition & 0 deletions changelog.d/next/846.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.
5 changes: 4 additions & 1 deletion journeys/subscriptions/cancel-and-delete.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@
<action>Tap Cancel</action>
<action>Verify the sheet title reads "Cancel Subscription", the cross illustration is shown, and the swipe control reads "Swipe To Cancel"</action>
<action>Swipe the confirm control to the end</action>
<action>Verify the sheet dismisses and "Journey Sub" leaves the ACTIVE section</action>
<action>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</action>
<action>Tap the "Journey Sub" row</action>
<action>Verify STATUS reads "Active", the timing cell reads "EXPIRES" with the end date of the paid period, and the footer shows no Cancel button, with More Info when the subscription has a description</action>
<action>Navigate back and verify the Subscriptions list is shown again</action>
<action>Switch to the creator instance and open Subscriptions</action>
<action>Verify "Journey Sub" is still listed under CREATED, because its paid periods are retained as history</action>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking QA correction: this creator-side assertion still expects the canceled subscription under CREATED, but isCreatedVisible(at:) excludes canceled records, and testCanceledSubscriptionCreatedByTheUserStaysExpiredAndListed confirms that a canceled payee subscription with paid history is listed under EXPIRED. The listed journey therefore fails here even when the app behaves correctly. Please update this assertion and the matching description prose, and carry the same correction into the Android twin journey in synonymdev/bitkit-android#1391 so the shared specs remain aligned.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOW — correction to my cross-platform note: Please update the iOS journeys/subscriptions/cancel-and-delete.xml:25 assertion and matching prose for the EXPIRED section, but leave the Android twin journey unchanged. Android's app/src/main/java/to/bitkit/repositories/PaykitSubscription.kt:217-218 keeps creator subscriptions with non-empty paidPeriods under CREATED, and app/src/test/java/to/bitkit/ui/screens/subscriptions/SubscriptionsScreenTest.kt:202-214 verifies that behavior. Android's journeys/subscriptions/cancel-and-delete.xml:25 therefore has the correct expectation. My request above to carry the iOS correction into Android #1391 was incorrect.

<action>Tap the pending "Journey Sub Two" row in the CREATED section</action>
Expand Down
Loading