diff --git a/Bitkit/Services/PaykitSubscription.swift b/Bitkit/Services/PaykitSubscription.swift index 878aca61a..c35a9d010 100644 --- a/Bitkit/Services/PaykitSubscription.swift +++ b/Bitkit/Services/PaykitSubscription.swift @@ -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 { diff --git a/Bitkit/Views/Subscriptions/SubscriptionsView.swift b/Bitkit/Views/Subscriptions/SubscriptionsView.swift index 45713c1e7..7ac8e7f1a 100644 --- a/Bitkit/Views/Subscriptions/SubscriptionsView.swift +++ b/Bitkit/Views/Subscriptions/SubscriptionsView.swift @@ -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 { @@ -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() @@ -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( @@ -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() } @@ -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)") @@ -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) @@ -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" ) @@ -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 } @@ -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 { @@ -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())]) diff --git a/BitkitTests/PaykitPaymentRequestServiceTests.swift b/BitkitTests/PaykitPaymentRequestServiceTests.swift index 771295a43..d6715cd61 100644 --- a/BitkitTests/PaykitPaymentRequestServiceTests.swift +++ b/BitkitTests/PaykitPaymentRequestServiceTests.swift @@ -447,6 +447,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( @@ -1878,6 +1914,183 @@ 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 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())]) + + 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 { + 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( diff --git a/changelog.d/next/846.fixed.md b/changelog.d/next/846.fixed.md new file mode 100644 index 000000000..d76428af4 --- /dev/null +++ b/changelog.d/next/846.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 672571c58..ba02aee0f 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 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" 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