Skip to content

fix: keep canceled subscriptions active until they expire - #846

Open
ovitrif wants to merge 6 commits into
masterfrom
fix/subscription-canceled-status
Open

ovitrif wants to merge 6 commits into
masterfrom
fix/subscription-canceled-status

Conversation

@ovitrif

@ovitrif ovitrif commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #845
Twin: synonymdev/bitkit-android#1391

This PR fixes a canceled subscription reading "Expired" while its paid period is still running.

Description

  • Fixes a canceled subscription reading "Expired" right after a cancel: until its last paid period ends it reads like an active one with an end date, with STATUS "Active", the row subtitle "Expires " and the timing cell "EXPIRES ".
  • Keeps a canceled subscription under ACTIVE (and in the ACTIVE count and monthly cost) until its paid period ends, then moves it to EXPIRED with "EXPIRED ".
  • Adds one predicate, runsUntilPaidThrough(at:) (active, or canceled with its last paid period still ahead), that drives status, row subtitle, section grouping, timing title and the refresh timers. A canceled subscription ends with its last paid period, whatever its fixed end date. Once it has ended, its row reads "Expires ", matching the detail "EXPIRED ".
  • Refreshes the list and the detail screen when the paid period of a canceled subscription ends, so an open screen flips without reopening.
  • Hides the detail timing cell for a subscription that has no end date.
  • Leaves a subscription the user created, once canceled, under EXPIRED as before.
  • Leaves the footer as it is: a canceled subscription has no Cancel button, and More Info shows when the subscription has metadata.

Out of Scope

Design

Subscription Details Renew is the state for a canceled subscription that has not expired yet: STATUS "Active", "EXPIRES ". Overview list lists it under ACTIVE with "Expires ".

Preview

List (canceled and expired) Detail: canceled, paid period not ended Detail: expired

QA Notes

Journeys

  • updated cancel-and-delete.xml

Manual Tests

  • set the device clock past the paid-through date of a canceled subscription while its list and detail are open → both flip to Expired and EXPIRED without reopening — the journey environment has no device-clock control

Automated Checks

  • updated PaykitPaymentRequestServiceTests.swift

@ovitrif ovitrif self-assigned this Sep 30, 2026
@ovitrif

ovitrif commented Sep 30, 2026

Copy link
Copy Markdown
Collaborator Author

Pushed fff51ab: renamed the changelog fragment to this PR's number, as pr.md asks once the PR exists. No code change.

@ovitrif
ovitrif force-pushed the fix/subscription-canceled-status branch from fff51ab to 71d760b Compare October 1, 2026 21:08
@ovitrif ovitrif changed the title fix: show canceled subscriptions as canceled fix: keep canceled subscriptions active until they expire Oct 1, 2026
@ovitrif
ovitrif force-pushed the fix/subscription-canceled-status branch from 71d760b to 8c09b85 Compare October 1, 2026 22:06
@ovitrif
ovitrif marked this pull request as ready for review October 1, 2026 22:07
@ovitrif
ovitrif requested review from a team, coreyphillips and pwltr and removed request for a team October 1, 2026 22:07
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-01T22:10:01.048717Z 8c09b85 Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@ovitrif

ovitrif commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Head 8c09b854 follows the Figma v3 Renew frame (48063:282720) instead of the earlier "Canceled" label: a canceled subscription now reads Active with "Expires " under ACTIVE until its paid period ends, then Expired. The list and detail timers flip at that moment. The description and tests are updated to match.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c09b8541f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread Bitkit/Views/Subscriptions/SubscriptionsView.swift
Comment thread Bitkit/Services/PaykitSubscription.swift Outdated
@greptile-apps

greptile-apps Bot commented Oct 1, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium risk] Changes how canceled subscriptions display and calculate costs.

The PR should not merge until canceled creator subscriptions remain accessible during their paid period.

Findings

  1. P1 Canceled creator subscriptions disappear ▶

Summary

The PR keeps canceled subscriptions in the active presentation until their paid period ends, updates list and detail timing, and adds transition tests and journey expectations. Creator-side canceled subscriptions with remaining paid time need a visibility fix.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  C[Canceled with paid time remaining] --> P{Payer?}
  P -- Yes --> A[ACTIVE]
  P -- No --> X[Not ACTIVE]
  X --> L{Lapsed?}
  L -- No --> H[Not EXPIRED or CREATED: hidden]
  C --> T[Paid-through date passes]
  T --> E[EXPIRED]
Loading

Reviews (1) · Last reviewed commit: "fix: keep canceled subscriptions active ..."

Comment thread Bitkit/Views/Subscriptions/SubscriptionsView.swift
@ovitrif

ovitrif commented Oct 1, 2026

Copy link
Copy Markdown
Collaborator Author

Head bf840d1c ends a canceled subscription at its last paid period, whatever its fixed end date, and keeps a canceled subscription the user created under EXPIRED as before. This answers the three inline review findings on the previous head. The description is updated to match.

@coreyphillips

Copy link
Copy Markdown
Contributor

Two independent reviews, nothing blocking a merge.

worth doing, does not block

  • Lapsed canceled row subtitle shows the fixed end date, not the paid-through date (Bitkit/Views/Subscriptions/SubscriptionsView.swift:1096). Once its paid period ends, a canceled subscription with a fixed endsAt gets a row subtitle that disagrees with its detail screen. rowSubtitle(at:) (SubscriptionsView.swift:1096-1098) still formats recurrence.endsAt, but subscriptionEndDate now prefers canceledPaidThrough. I traced this through the PR's own fixture from testCanceledSubscriptionEndsAtItsLastPaidPeriodWhateverItsFixedEndDate: canceled, paid 2027-01-01 to 2027-02-01, endsAt 2027-06-01, viewed at 2027-02-01. - canceledPaidThroughDate(at:) is nil, because the date comparison is strict. - isExpired is true. - The row under EXPIRED therefore reads "Expires June 1", a future date. - The detail reads STATUS "Expired" and "EXPIRED February 1". The PR says a canceled subscription ends with its last paid period "whatever its fixed end date", but the row does not follow that rule. That test does not assert rowSubtitle after the paid-through date. Using subscriptionEndDate(subscription:) in the expired branch, or canceledPaidThrough ahead of recurrence.endsAt, would make the row and detail agree. This only happens when a canceled payer subscription has a fixed end date, which a payer cannot cancel themselves (canCancel requires endsAt == nil). That is why this is non-blocking.
  • Automatic expiry refresh lacks QA coverage (journeys/subscriptions/cancel-and-delete.xml:20). The automatic expiry refresh is not covered by the PR's QA contract. The updated journey verifies the state immediately after cancellation, then navigates away without crossing the paid-through boundary, while the PR lists no manual tests. The unit tests only exercise the date helpers, not either SwiftUI timer. Add a manual clock-boundary check for an open list and detail screen, since the journey environment does not support device-clock control.

nits

  • Journey asserts More Info unconditionally after cancel (journeys/subscriptions/cancel-and-delete.xml:22). The new step at cancel-and-delete.xml:22 says the footer "shows More Info without Cancel". The footer only shows More Info when the subscription has a description or benefits. Step 16 of the same journey hedges this ("when the subscription has a description"), and create-and-propose.xml allows skipping the description. A run that skipped the description would fail this step even though the app is behaving correctly. Use the same conditional wording as step 16.
  • Model predicate depends on a free function in a view file (Bitkit/Services/PaykitSubscription.swift:511). PaykitSubscription.showsTiming(at:) in Bitkit/Services/PaykitSubscription.swift calls subscriptionEndDate(subscription:), which is defined in Bitkit/Views/Subscriptions/SubscriptionsView.swift. That makes the service-layer model depend on a view file. Moving subscriptionEndDate onto PaykitSubscription, next to canceledPaidThrough, would keep the dependency pointing from views to model.

coreyphillips
coreyphillips previously approved these changes Oct 2, 2026
@ovitrif

ovitrif commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Head a6e42ed4: once a canceled subscription with a fixed end date has ended, its list row now reads "Expires " like its detail ("EXPIRED "), instead of showing the later fixed end date. A canceled subscription without a fixed end date still reads "Expired". The assertion is in testCanceledSubscriptionEndsAtItsLastPaidPeriodWhateverItsFixedEndDate, and the description is updated.

@ovitrif

ovitrif commented Oct 2, 2026

Copy link
Copy Markdown
Collaborator Author

Lapsed canceled row subtitle shows the fixed end date, not the paid-through date

@coreyphillips Fixed in a6e42ed4: once a canceled payer subscription with a fixed end date has ended, its row reads "Expires " like its detail ("EXPIRED "); without a fixed end date it still reads "Expired". testCanceledSubscriptionEndsAtItsLastPaidPeriodWhateverItsFixedEndDate now asserts rowSubtitle(at:) at the paid-through date, and it failed on the previous head.

Automatic expiry refresh lacks QA coverage

Added a manual clock-boundary check to the description, since the journey environment has no device-clock control.

Journey asserts More Info unconditionally after cancel

Fixed in 50cffccb: the step now reads "no Cancel button, with More Info when the subscription has a description".

Model predicate depends on a free function in a view file

Left as is: subscriptionEndDate already lives in the view file and the tests call it there; I would move it separately from this fix.

@ovitrif
ovitrif requested a review from coreyphillips October 2, 2026 10:02
<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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug: a canceled subscription reads expired while its paid period runs

3 participants