fix(payg): cancelled subscription left team gated as subscribed (#6611)

## Problem

A team that **cancelled** its PAYG subscription kept full subscribed
access:

- **UI didn't reflect cancellation** — the Plan tab still rendered the
subscribed view, never the free/upgrade view.
- **Automation wasn't stopped** — automation / AI / API kept running
without ever falling back to the free-grant gate.

## Root cause

`TeamBillingService.compute` decided `subscribed` as:

```java
boolean subscribed =
    subscriptionId != null
        || extOpt.map(PaygTeamExtensions::getStripeCustomerId).filter(s -> !s.isBlank()).isPresent();
```

On cancellation, the `customer.subscription.deleted` webhook calls
`payg_unlink_subscription`, which nulls `payg_subscription_id` but
**deliberately keeps `stripe_customer_id`** (so a future re-subscribe
can reuse the Stripe customer).

`payg_link_subscription` is the **only** writer of
`payg_team_extensions.stripe_customer_id`, and it writes it in the
*same* `UPDATE` as `payg_subscription_id` (on
`customer.subscription.created`). So the customer id is never set before
the subscription id — the "pre-webhook stand-in" the old comment claimed
**cannot happen**. The fallback only ever pinned a team that *ever*
subscribed to `subscribed` forever, because the Stripe customer outlives
the subscription.

Both symptoms are this one flag:
- `PaygWalletController` status → `SUBSCRIBED` vs `FREE`
- `EntitlementService` gate branch → monthly-cap vs free-grant

## Fix

Gate `subscribed` purely on `payg_subscription_id != null`. A cancelled
team now correctly drops to free (UI shows free; billable ops gate on
the one-time grant). This aligns the wallet/entitlement read with the
**meter path** (`JobChargeService.close`), which already gated on
`payg_subscription_id`.

Handles both Stripe cancel modes: "cancel at period end" keeps the sub
`active` (id stays set) until `.deleted` fires at period end → access
through the paid period; immediate cancel fires `.deleted` now → flips
to free now.

**No data migration / backfill** — already-cancelled teams have
`payg_subscription_id = NULL`, so they flip to free as soon as this
ships (within the 30s billing-cache TTL).

## Tests

Adds `TeamBillingServiceTest` — the `subscribed` computation previously
had **no** unit coverage (which is how this shipped). Covers: subscribed
iff subscription id present; **cancelled team (customer id remains,
subscription id null) ≠ subscribed** + free grant survives;
no-subscription/no-customer; no extension row.

`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.

## Not included (optional hardening, can fast-follow)

- Cross-check the synced `stripe.subscriptions.status` to guard a
*missed* `.deleted` webhook leaving `payg_subscription_id` stale.
- Push cache-invalidation from the webhook (currently ≤30s TTL
staleness).
This commit is contained in:
ConnorYoh
2026-06-11 17:26:58 +01:00
committed by GitHub
parent f16ca4795c
commit 5bc7ae626d
3 changed files with 136 additions and 10 deletions
@@ -16,8 +16,8 @@ import java.time.LocalDateTime;
* + cap only.
* </ul>
*
* @param subscribed team has an active PAYG subscription (or a Stripe customer awaiting its
* subscription-created webhook)
* @param subscribed team has a live PAYG subscription — i.e. {@code payg_subscription_id} is set.
* Cleared by {@code payg_unlink_subscription} on cancellation, so a cancelled team reads false.
* @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free
* @param periodStart inclusive start of the monthly billing window — the Stripe subscription's
* current period when subscribed, calendar month otherwise
@@ -111,14 +111,19 @@ public class TeamBillingService {
Optional<WalletPolicy> walletPolicyOpt = walletPolicyRepository.findByTeamId(teamId);
String subscriptionId = extOpt.map(PaygTeamExtensions::getPaygSubscriptionId).orElse(null);
// payg_subscription_id is the designed switch; stripe_customer_id presence is the
// pre-webhook stand-in kept so a team whose checkout completed but whose
// subscription-created webhook hasn't landed yet still renders as subscribed.
boolean subscribed =
subscriptionId != null
|| extOpt.map(PaygTeamExtensions::getStripeCustomerId)
.filter(s -> !s.isBlank())
.isPresent();
// payg_subscription_id is the single subscription switch. payg_link_subscription sets it
// (alongside stripe_customer_id, in the same write) on customer.subscription.created;
// payg_unlink_subscription nulls it on customer.subscription.deleted while deliberately
// keeping stripe_customer_id so a future re-subscribe can reuse the Stripe customer. So a
// cancelled team has a null subscription id and must read as free again.
//
// We deliberately do NOT fall back to stripe_customer_id presence. payg_link_subscription
// is the only writer of that column and it writes it together with the subscription id, so
// it can never be set "before the webhook lands" — there is no gap for it to bridge. A
// customer-id fallback would instead keep every team that ever subscribed pinned to
// subscribed forever (the customer outlives the subscription), which is the cancelled-team
// bug this guards against.
boolean subscribed = subscriptionId != null;
long freeGrant = resolveGrant(teamId);
long freeRemaining =