Skip to content

test(ui): cover a period-end cancellation whose final invoice is paid - #7320

Merged
otavio merged 1 commit into
masterfrom
fix/final-invoice-resubscribe
Oct 5, 2026
Merged

otavio merged 1 commit into
masterfrom
fix/final-invoice-resubscribe

Conversation

@otavio

@otavio otavio commented Oct 3, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds a cloud-edition Playwright test for shellhub-io/team#260: an owner who cancels at the end of the period must stay canceled after Stripe charges the final invoice. The fix is shellhub-io/cloud#2616, on the same branch name, so this PR's cloud e2e leg builds it.

create a Stripe test clock and a customer on it
write that customer_id into the namespace's billing (psql)   # the console skips creating one
subscribe through the UI (4242 card)
subscriptions update cancel_at_period_end=true
  poll namespace → to_cancel_at_end_of_period
advance the clock past cancel_at, poll it → ready            # ends the subscription, drafts the final invoice
  poll namespace → canceled
finalize the draft final invoice (stripe CLI)                # Stripe leaves it a draft; $0 is paid on finalization
  poll server log → invoice.paid abandoned for this subscription
assert canceled, max_devices = 3, no active Stripe subscription
teardown: delete the clock, which deletes its customer

The log poll gives the test a positive signal that invoice.paid was handled. Without it, the final assertions would already hold before the webhook arrives.

Evidence

  • Before: the server never writes the abandon line, so the poll times out (or, if invoice.paid re-subscribes the namespace first, the status assertion fails).
    After: the cloud e2e leg of this PR's ui-e2e run.
  • I couldn't run this locally because the dev environment has no Stripe test keys. The cloud e2e leg in CI is its first real run. ESLint, tsc, knip and Prettier pass.

Merge Danger

Door: two-way

Blast Radius: cloud e2e leg

Test-only. The test adds about a minute to the cloud leg, most of it waiting for Stripe to advance the clock by a month. A slow advance shows up as a timeout on the clock poll.

Merge shellhub-io/cloud#2616 first. Once this is on master, the cloud leg fails against a cloud master that lacks the fix.

@otavio
otavio requested a review from a team as a code owner October 3, 2026 22:08
@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Claude finished @otavio's task in 4m 9s —— View job


Code Review Complete

Reviewed 2 files. 1 inline issue posted. Standards: 0 violations, 0 judgement calls. Spec: 0. Correctness: 1.

Standards

No violations. setBillingCustomer follows the existing sql + UPDATE 1 check pattern, the diff adds no comments, and the commit message explains each workaround: the customer written through psql, teardown that deletes the clock, the 2 h offset, and the log poll.

Correctness

billing.spec.ts:377: test.setTimeout(240_000) is smaller than the test's own poll timeouts (270 s), even before the UI subscribe flow. If the clock advance is slow, the test-level timeout fires first and hides the invoice.paid abandon message.

I also checked these against the sibling cloud/ checkout, and they hold:

  • The console and cloud CreateCustomer both skip creating a customer when billing.customer_id is set.
  • The log string matches cloud/internal/billing/services/handles.go, and that log line carries the subscription ID.
  • Teardown covers every failure point once testClock is set.

Spec

The Spec axis did not run. The PR closes no issue in this repo, and the issue it cites (shellhub-io/team#260) is in a repo this run cannot read.


To request another review round, comment /review.

Comment thread ui/apps/console/e2e/billing.spec.ts Outdated
@otavio
otavio force-pushed the fix/final-invoice-resubscribe branch from af7d62e to a6cac77 Compare October 3, 2026 22:14
@otavio

otavio commented Oct 3, 2026

Copy link
Copy Markdown
Member Author

/review

@github-actions

github-actions Bot commented Oct 3, 2026 •

Copy link
Copy Markdown

Claude finished @otavio's task in 6m 11s —— View job


Code Review Complete

Reviewed 2 files on two axes: Standards (repo rules, language conventions, tests, code smells) and Correctness (security and runtime defects). No new issues found. The code looks good as-is.

The Spec axis did not run. The PR closes no issue in this repo, and the issue it cites (shellhub-io/team#260) is in a repo this run cannot read.

@otavio
otavio force-pushed the fix/final-invoice-resubscribe branch from a6cac77 to f749de3 Compare October 3, 2026 23:07
A namespace canceled at the end of its period was subscribed again when Stripe charged the final
invoice of that period (shellhub-io/team#260). The test runs the real period end on a Stripe test
clock and fails without the cloud fix that abandons that invoice.paid.

A test clock can only be attached when a customer is created, and the console creates customers
without one, so the test creates the customer on the clock and writes its ID into the namespace's
billing through psql. The console then skips creating its own. Deleting the clock deletes its
customer, so teardown does that and skips deleteStripeCustomer.

Advancing the clock past the period end ends the subscription and drafts its final invoice, but
Stripe does not finalize that draft during the advance: in CI the clock read ready two hours past
the period end with the invoice still a draft and no invoice.paid sent. The test finalizes it with
the CLI, as the issue's manual reproduction does, and a $0 invoice is paid on finalization, which
sends invoice.paid. Advancing a month takes Stripe tens of seconds or more, hence the long timeouts.

The state the test checks (canceled, the free device limit, no active subscription) already holds
before invoice.paid arrives, so asserting it alone would pass while the webhook is still in flight.
The test first waits for the server's log line that abandons the event, which never appears on the
old code. Usage reports use real time, not the clock's, so Stripe may reject one after the advance;
the server logs the error and the assertions do not depend on it.

When a billing test fails, its fixture attaches the events stripe-cli forwarded, with the status the
server answered, and the server's lines for the webhook route or the test's tenant. The webhook
route logs only errors, so without them a failed run cannot tell a missing event from one handled
on another path. Only the event lines of the stripe-cli log go in: it prints the webhook signing
secret when it starts, and the report is a public artifact.
@otavio
otavio force-pushed the fix/final-invoice-resubscribe branch from f749de3 to 51d0785 Compare October 5, 2026 11:11
@otavio
otavio merged commit cb6c06d into master Oct 5, 2026
24 checks passed
@otavio
otavio deleted the fix/final-invoice-resubscribe branch October 5, 2026 11:33
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.

1 participant