From cf0067e8145e4a9c62515ab47f74371c299a7bf7 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 02:56:19 +0200 Subject: [PATCH 1/8] fix: show a canceled subscription as canceled until its paid period ends --- .../Subscriptions/SubscriptionsView.swift | 29 +++++--- .../PaykitPaymentRequestServiceTests.swift | 69 +++++++++++++++++++ changelog.d/next/845.fixed.md | 1 + journeys/subscriptions/cancel-and-delete.xml | 4 ++ 4 files changed, 95 insertions(+), 8 deletions(-) create mode 100644 changelog.d/next/845.fixed.md diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 9668e750a..ee67ec44b 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -440,7 +440,7 @@ struct SubscriptionDetailView: View { ) if subscription.isActive(at: now) || subscription.isExpired(at: now) || subscription.recurrence.endsAt != nil { LabeledDetailCell( - title: timingTitle(subscription), + title: subscription.timingTitle(at: now), value: renewalText(subscription), icon: "calendar" ) @@ -514,17 +514,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.lifecycleState == .canceled ? subscriptionEndDate(subscription: subscription) : nil, ] .compactMap { $0 } .filter { $0 > now } @@ -1048,12 +1044,26 @@ 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") + if isActive(at: now) { + return t("subscriptions__active") + } + return lifecycleState == .canceled ? t("wallet__payment_request_status_canceled") : t("subscriptions__expired") + } + + /// An inactive subscription still runs until its last paid period ends, so a future date "Expires". + func timingTitle(at now: Date) -> String { + if isActive(at: now) { + return recurrence.endsAt == nil ? t("subscriptions__renews") : t("subscriptions__expires") + } + if let endDate = subscriptionEndDate(subscription: self), endDate > now { + return t("subscriptions__expires") + } + return t("subscriptions__expired") } func rowSubtitle(at now: Date) -> String { @@ -1073,6 +1083,9 @@ private extension PaykitSubscription { if isProposalVisible(at: now) || !recurrence.unit.isSupported { return recurrence.subscriptionFrequencyLabel } + if lifecycleState == .canceled { + return t("wallet__payment_request_status_canceled") + } 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())]) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 82046776f..4e9197d22 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1504,6 +1504,75 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } + func testCanceledSubscriptionReadsCanceledAndExpiresAtTheEndOfItsPaidPeriod() throws { + let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z") + let canceled = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + state: .canceled, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: period.startsAt, + anchor: period.startsAt, + endsAt: nil + ), + paymentProofs: [paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: period + )] + ))) + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let paidThrough = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-02-01T08:00:00Z")) + + XCTAssertEqual(canceled.statusLabel(at: now), t("wallet__payment_request_status_canceled")) + XCTAssertEqual(canceled.rowSubtitle(at: now), t("wallet__payment_request_status_canceled")) + XCTAssertEqual(canceled.timingTitle(at: now), t("subscriptions__expires")) + XCTAssertEqual(canceled.timingTitle(at: paidThrough), t("subscriptions__expired")) + XCTAssertEqual(canceled.statusLabel(at: paidThrough), t("wallet__payment_request_status_canceled")) + } + + func testCanceledSubscriptionWithoutPaidPeriodReadsExpiredTiming() throws { + let canceled = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + state: .canceled, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: "2027-01-01T08:00:00Z", + anchor: "2027-01-01T08:00:00Z", + endsAt: nil + ) + ))) + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + + XCTAssertEqual(canceled.statusLabel(at: now), t("wallet__payment_request_status_canceled")) + XCTAssertEqual(canceled.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( diff --git a/changelog.d/next/845.fixed.md b/changelog.d/next/845.fixed.md new file mode 100644 index 000000000..35afb5dce --- /dev/null +++ b/changelog.d/next/845.fixed.md @@ -0,0 +1 @@ +A canceled subscription now reads Canceled and shows when its paid period ends, instead of Expired. diff --git a/journeys/subscriptions/cancel-and-delete.xml b/journeys/subscriptions/cancel-and-delete.xml index 672571c58..2d4587c80 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -18,6 +18,10 @@ 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 "Journey Sub" is listed under EXPIRED with "Canceled" as its subtitle + Tap the "Journey Sub" row + Verify STATUS reads "Canceled" and the timing cell reads "EXPIRES" with the end date of the paid period, not "EXPIRED" + 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 1b6dde6742751a4a2cf22385dd9240f4596cece8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Wed, 30 Sep 2026 03:46:32 +0200 Subject: [PATCH 2/8] chore: rename changelog fragment --- changelog.d/next/{845.fixed.md => 846.fixed.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{845.fixed.md => 846.fixed.md} (100%) diff --git a/changelog.d/next/845.fixed.md b/changelog.d/next/846.fixed.md similarity index 100% rename from changelog.d/next/845.fixed.md rename to changelog.d/next/846.fixed.md From 8c09b8541ff4ef3af55fcafe88514a6e98986ac3 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Thu, 1 Oct 2026 21:47:01 +0200 Subject: [PATCH 3/8] fix: keep canceled subscriptions active until they expire --- Bitkit/Services/PaykitSubscription.swift | 21 ++++++ .../Subscriptions/SubscriptionsView.swift | 47 +++++++----- .../PaykitPaymentRequestServiceTests.swift | 75 +++++++++++++++---- changelog.d/next/846.fixed.md | 2 +- journeys/subscriptions/cancel-and-delete.xml | 5 +- 5 files changed, 112 insertions(+), 38 deletions(-) diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 82fad3803..ce8b4fc73 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -492,6 +492,27 @@ struct PaykitSubscription: Identifiable, Hashable { lifecycleState == .activeRecurring && recurrence.endsAt.map { $0 > date } ?? true } + /// The end of the last paid period of a canceled subscription that still runs; nil otherwise. + func canceledPaidThroughDate(at date: Date) -> Date? { + guard lifecycleState == .canceled, let paidThrough = subscriptionEndDate(subscription: self), paidThrough > date else { return nil } + return paidThrough + } + + /// 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)) } diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index ee67ec44b..a4e78ba35 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -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] { @@ -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() @@ -264,6 +264,17 @@ func subscriptionEndDate(subscription: PaykitSubscription) -> Date? { 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) } + ) +} + func subscriptionNextTransitionDate( subscriptions: [PaykitSubscription], now: Date @@ -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() } @@ -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)") @@ -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) @@ -438,7 +450,7 @@ 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: subscription.timingTitle(at: now), value: renewalText(subscription), @@ -520,7 +532,7 @@ struct SubscriptionDetailView: View { subscription.recurrence.startsAt, subscription.recurrence.endsAt, subscription.isActive(at: now) ? subscription.recurrence.nextPeriod(after: now)?.startsAt : nil, - subscription.lifecycleState == .canceled ? subscriptionEndDate(subscription: subscription) : nil, + subscription.canceledPaidThroughDate(at: now), ] .compactMap { $0 } .filter { $0 > now } @@ -1049,21 +1061,15 @@ extension PaykitSubscription { if isProposalVisible(at: now) { return t("subscriptions__pending") } - if isActive(at: now) { - return t("subscriptions__active") - } - return lifecycleState == .canceled ? t("wallet__payment_request_status_canceled") : t("subscriptions__expired") + return runsUntilPaidThrough(at: now) ? t("subscriptions__active") : t("subscriptions__expired") } - /// An inactive subscription still runs until its last paid period ends, so a future date "Expires". + /// 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) { - return recurrence.endsAt == nil ? t("subscriptions__renews") : t("subscriptions__expires") - } - if let endDate = subscriptionEndDate(subscription: self), endDate > now { - return t("subscriptions__expires") + if isActive(at: now) && recurrence.endsAt == nil { + return t("subscriptions__renews") } - return t("subscriptions__expired") + return runsUntilPaidThrough(at: now) ? t("subscriptions__expires") : t("subscriptions__expired") } func rowSubtitle(at now: Date) -> String { @@ -1083,8 +1089,9 @@ extension PaykitSubscription { if isProposalVisible(at: now) || !recurrence.unit.isSupported { return recurrence.subscriptionFrequencyLabel } - if lifecycleState == .canceled { - return t("wallet__payment_request_status_canceled") + 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") } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 4e9197d22..c657ce195 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1504,48 +1504,95 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } - func testCanceledSubscriptionReadsCanceledAndExpiresAtTheEndOfItsPaidPeriod() throws { + private func canceledSubscription(paid: Bool, endsAt: String? = nil) throws -> PaykitSubscription { let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z") - let canceled = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( + return try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( state: .canceled, + amount: "0.000012", recurrence: PaymentRequestRecurrence( every: 1, unit: "month", startsAt: period.startsAt, anchor: period.startsAt, - endsAt: nil + endsAt: endsAt ), - paymentProofs: [paymentProofRecord( + 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("wallet__payment_request_status_canceled")) - XCTAssertEqual(canceled.rowSubtitle(at: now), t("wallet__payment_request_status_canceled")) + 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")) - XCTAssertEqual(canceled.statusLabel(at: paidThrough), t("wallet__payment_request_status_canceled")) } - func testCanceledSubscriptionWithoutPaidPeriodReadsExpiredTiming() throws { - let canceled = try XCTUnwrap(PaykitSubscription(record: paymentRequestRecord( - state: .canceled, + 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 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: nil + endsAt: "2027-06-01T08:00:00Z" ) ))) let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) - XCTAssertEqual(canceled.statusLabel(at: now), t("wallet__payment_request_status_canceled")) - XCTAssertEqual(canceled.timingTitle(at: now), t("subscriptions__expired")) + XCTAssertEqual(rejected.statusLabel(at: now), t("subscriptions__expired")) + XCTAssertEqual(rejected.timingTitle(at: now), t("subscriptions__expired")) } func testActiveAndEndedSubscriptionsKeepTheirStatusAndTiming() throws { diff --git a/changelog.d/next/846.fixed.md b/changelog.d/next/846.fixed.md index 35afb5dce..d76428af4 100644 --- a/changelog.d/next/846.fixed.md +++ b/changelog.d/next/846.fixed.md @@ -1 +1 @@ -A canceled subscription now reads Canceled and shows when its paid period ends, instead of Expired. +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 2d4587c80..eecd74386 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -17,10 +17,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" leaves the ACTIVE section - Verify "Journey Sub" is listed under EXPIRED with "Canceled" as its subtitle + 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 "Canceled" and the timing cell reads "EXPIRES" with the end date of the paid period, not "EXPIRED" + 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 From bf840d1cf16f215f20c686bc0fb4bec578d86721 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 00:51:16 +0200 Subject: [PATCH 4/8] fix: end a canceled subscription at its last paid period --- Bitkit/Services/PaykitSubscription.swift | 10 ++++-- .../Subscriptions/SubscriptionsView.swift | 6 ++-- .../PaykitPaymentRequestServiceTests.swift | 33 ++++++++++++++++++- 3 files changed, 42 insertions(+), 7 deletions(-) diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index ce8b4fc73..8a2bd6473 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -492,10 +492,14 @@ struct PaykitSubscription: Identifiable, Hashable { lifecycleState == .activeRecurring && recurrence.endsAt.map { $0 > date } ?? true } - /// The end of the last paid period of a canceled subscription that still runs; nil otherwise. + /// 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? { - guard lifecycleState == .canceled, let paidThrough = subscriptionEndDate(subscription: self), paidThrough > date else { return nil } - return paidThrough + canceledPaidThrough.flatMap { $0 > date ? $0 : nil } } /// Active, or canceled with its last paid period still ahead: it runs until its paid-through date. diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index a4e78ba35..214d465e0 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -258,10 +258,10 @@ 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. diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index c657ce195..418489c58 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1504,10 +1504,15 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { ) } - private func canceledSubscription(paid: Bool, endsAt: String? = nil) throws -> PaykitSubscription { + 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, @@ -1557,6 +1562,32 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { 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")) + 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")) From a6e42ed49aeaf63dceefe0986442e1c3f707695a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 11:40:42 +0200 Subject: [PATCH 5/8] fix: show the paid-through date on a lapsed canceled subscription row --- Bitkit/Views/Subscriptions/SubscriptionsView.swift | 3 ++- BitkitTests/PaykitPaymentRequestServiceTests.swift | 4 ++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 214d465e0..8d45db425 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -1095,7 +1095,8 @@ extension PaykitSubscription { } 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())]) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 418489c58..6a6230c77 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -1574,6 +1574,10 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { 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) From 50cffccb4f5266c48c86dcad4f41f234088e2936 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 2 Oct 2026 12:01:53 +0200 Subject: [PATCH 6/8] chore: make the canceled journey footer step conditional on a description --- journeys/subscriptions/cancel-and-delete.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/journeys/subscriptions/cancel-and-delete.xml b/journeys/subscriptions/cancel-and-delete.xml index eecd74386..50c1d5151 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -19,7 +19,7 @@ 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 + 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 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 From e3744f38e7db9ee682c3bd05070ef1ec35413937 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Oct 2026 14:36:30 +0200 Subject: [PATCH 7/8] chore: expect a canceled created subscription under expired in the journey --- journeys/subscriptions/cancel-and-delete.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/journeys/subscriptions/cancel-and-delete.xml b/journeys/subscriptions/cancel-and-delete.xml index 50c1d5151..e9656ff77 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -6,8 +6,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. + removes it from CREATED. A canceled subscription that has taken a payment is then listed under EXPIRED + to retain its history, whereas a proposal with no paid periods leaves the list once deleted. Launch the E2E Bitkit app with an active subscription the payer accepted, per review-and-subscribe.xml @@ -22,7 +22,7 @@ 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 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" leaves CREATED and is listed under EXPIRED, because its paid periods are retained as history 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 0bbc3324706a1cdc03f05ff52557a06504fc5d7f Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 5 Oct 2026 15:39:24 +0200 Subject: [PATCH 8/8] fix: list a canceled created subscription until its paid period ends --- Bitkit/Services/PaykitSubscription.swift | 6 +- .../Subscriptions/SubscriptionsView.swift | 10 +-- .../PaykitPaymentRequestServiceTests.swift | 70 +++++++++++++++++-- journeys/subscriptions/cancel-and-delete.xml | 14 ++-- 4 files changed, 82 insertions(+), 18 deletions(-) diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 8a2bd6473..f0efd7b16 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -492,9 +492,9 @@ 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. + /// A canceled subscription is paid up to its last paid period, whatever its fixed end date. var canceledPaidThrough: Date? { - lifecycleState == .canceled && isPayer ? paidPeriods.map(\.endsAt).max() : nil + lifecycleState == .canceled ? paidPeriods.map(\.endsAt).max() : nil } /// The paid-through date of a canceled subscription that still runs; nil otherwise. @@ -518,7 +518,7 @@ struct PaykitSubscription: Identifiable, Hashable { } 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 { diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 8d45db425..1407ae1a2 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -58,7 +58,7 @@ struct SubscriptionsView: View { } private var created: [PaykitSubscription] { - paymentRequests.subscriptions.filter { $0.isCreatedVisible(at: now) } + subscriptionSections(subscriptions: paymentRequests.subscriptions, now: now).created } private var hasVisibleSubscriptions: Bool { @@ -264,14 +264,16 @@ func subscriptionEndDate(subscription: PaykitSubscription) -> Date? { 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. +/// 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]) { +) -> (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) } + expired: subscriptions.filter { $0.isExpiredVisible(at: now) && $0.isLapsed(at: now) }, + created: subscriptions.filter { $0.isCreatedVisible(at: now) } ) } diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 6a6230c77..ba12fabef 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -214,6 +214,42 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertTrue(manager.historyRequests.isEmpty) } + func testBlockedPeerHidesACanceledSubscriptionStillPaidThrough() async throws { + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + let period = BillingPeriod(startsAt: "2027-01-01T08:00:00Z", endsAt: "2027-02-01T08:00:00Z") + let record = try paymentRequestRecord( + counterparty: "pubky3rsduhcxpw74snwyct86m38c63j3pq8x4ycqikxg64roik8yw5xy", + state: .canceled, + recurrence: PaymentRequestRecurrence( + every: 1, + unit: "month", + startsAt: period.startsAt, + anchor: period.startsAt, + endsAt: nil + ), + paymentProofs: [paymentProofRecord( + endpoint: PublicPaykitService.MethodId.bitcoinLightningBolt11.rawValue, + kind: .lightning, + billingPeriod: period + )] + ) + let sdk = PaymentRequestSdkMock(records: [record]) + let manager = paymentRequestManager(sdk: sdk, clock: PaymentRequestTestClock(now)) + await manager.refresh() + let subscription = try XCTUnwrap(manager.subscriptions.first) + XCTAssertTrue(subscription.runsUntilPaidThrough(at: now)) + + await sdk.configureRecipients( + peers: [linkedPeer(counterparty: record.counterparty, path: record.counterpartyReceiverPath, state: .blocked)], + receiverPathsByPublicKey: [:] + ) + await manager.refresh() + XCTAssertTrue(manager.subscriptions.isEmpty) + let sections = subscriptionSections(subscriptions: manager.subscriptions, now: now) + XCTAssertTrue(sections.active.isEmpty && sections.created.isEmpty && sections.expired.isEmpty) + XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: manager.subscriptions, now: now), 0) + } + func testBlockingAnAlreadyPresentedAcceptedRequestPreventsPayment() async throws { let now = Date(timeIntervalSince1970: 1_800_000_000) let record = try paymentRequestRecord( @@ -1583,13 +1619,39 @@ final class PaykitPaymentRequestServiceTests: XCTestCase { XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: paidThrough), 0) } - func testCanceledSubscriptionCreatedByTheUserStaysExpiredAndListed() throws { + func testCanceledSubscriptionCreatedByTheUserStaysCreatedUntilPaidThroughThenExpires() throws { let canceled = try canceledSubscription(paid: true, role: .payee) 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__expired")) - XCTAssertTrue(subscriptionSections(subscriptions: [canceled], now: now).active.isEmpty) - XCTAssertEqual(subscriptionSections(subscriptions: [canceled], now: now).expired.map(\.id), [canceled.id]) + var sections = subscriptionSections(subscriptions: [canceled], now: now) + XCTAssertEqual(sections.created.map(\.id), [canceled.id]) + XCTAssertTrue(sections.active.isEmpty) + XCTAssertTrue(sections.expired.isEmpty) + XCTAssertEqual(canceled.statusLabel(at: now), t("subscriptions__active")) + XCTAssertEqual(canceled.rowSubtitle(at: now), expiresDate) + XCTAssertEqual(canceled.timingTitle(at: now), t("subscriptions__expires")) + XCTAssertEqual(subscriptionNextTransitionDate(subscriptions: [canceled], now: now), paidThrough) + XCTAssertEqual(subscriptionMonthlyCostSats(subscriptions: [canceled], now: now), 0) + XCTAssertFalse(canceled.canCancel(at: now)) + + sections = subscriptionSections(subscriptions: [canceled], now: paidThrough) + XCTAssertTrue(sections.created.isEmpty) + XCTAssertTrue(sections.active.isEmpty) + XCTAssertEqual(sections.expired.map(\.id), [canceled.id]) + XCTAssertEqual(canceled.statusLabel(at: paidThrough), t("subscriptions__expired")) + XCTAssertEqual(canceled.timingTitle(at: paidThrough), t("subscriptions__expired")) + } + + func testCanceledCreatorSubscriptionWithoutPaymentsStaysUnlisted() throws { + let canceled = try canceledSubscription(paid: false, role: .payee) + let now = try XCTUnwrap(ISO8601DateFormatter().date(from: "2027-01-15T08:00:00Z")) + + let sections = subscriptionSections(subscriptions: [canceled], now: now) + XCTAssertTrue(sections.created.isEmpty) + XCTAssertTrue(sections.active.isEmpty) + XCTAssertTrue(sections.expired.isEmpty) } func testCanceledSubscriptionTimerFlipsAtThePaidThroughDate() throws { diff --git a/journeys/subscriptions/cancel-and-delete.xml b/journeys/subscriptions/cancel-and-delete.xml index e9656ff77..ba02aee0f 100644 --- a/journeys/subscriptions/cancel-and-delete.xml +++ b/journeys/subscriptions/cancel-and-delete.xml @@ -6,8 +6,9 @@ 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 from CREATED. A canceled subscription that has taken a payment is then listed under EXPIRED - to retain its history, whereas a proposal with no paid periods leaves the list once deleted. + 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, 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,12 +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" 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, with More Info when the subscription has a description - 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 no Cancel button, with More Info when the subscription has a description + 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" leaves CREATED and is listed under EXPIRED, 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