@@ -185,6 +185,72 @@ func TestBillingWebhookGuardRefusesTeamPriceOnUserSubject(t *testing.T) { |
| 185 | 185 | } |
| 186 | 186 | } |
| 187 | 187 | |
| 188 | +// TestBillingWebhookDropsStaleEvent locks PRO08 D4: a Stripe event |
| 189 | +// with `created` older than the persisted last_event_at must NOT |
| 190 | +// regress state. Pre-PRO08 a reverse-ordered retry could re-activate |
| 191 | +// a canceled subscription. The handler returns 200 (Stripe stops |
| 192 | +// retrying THIS delivery) and leaves state alone. |
| 193 | +func TestBillingWebhookDropsStaleEvent(t *testing.T) { |
| 194 | + t.Parallel() |
| 195 | + ctx := context.Background() |
| 196 | + pool := dbtest.NewTestDB(t) |
| 197 | + ownerID := insertOrgAvatarUser(t, pool, "owner") |
| 198 | + orgID := insertOrgAvatarOrg(t, pool, ownerID, "acme") |
| 199 | + |
| 200 | + // Establish a fresh canceled state via direct apply + touch. |
| 201 | + if _, err := orgbilling.MarkCanceledForPrincipal(ctx, orgbilling.Deps{Pool: pool}, orgbilling.PrincipalForOrg(orgID), "evt_canceled"); err != nil { |
| 202 | + t.Fatalf("MarkCanceled: %v", err) |
| 203 | + } |
| 204 | + freshTime := time.Now().UTC() |
| 205 | + if err := orgbilling.TouchBillingLastEventAtForPrincipal(ctx, orgbilling.Deps{Pool: pool}, orgbilling.PrincipalForOrg(orgID), freshTime); err != nil { |
| 206 | + t.Fatalf("touch fresh: %v", err) |
| 207 | + } |
| 208 | + |
| 209 | + // A stale (older) subscription.updated[active] arrives. event.Created |
| 210 | + // is 1 hour BEFORE the persisted last_event_at. |
| 211 | + staleCreated := freshTime.Add(-1 * time.Hour).Unix() |
| 212 | + raw, err := json.Marshal(map[string]any{ |
| 213 | + "id": "sub_stale_active", |
| 214 | + "customer": "cus_stale", |
| 215 | + "status": "active", |
| 216 | + "metadata": map[string]string{stripebilling.MetadataOrgID: strconv.FormatInt(orgID, 10)}, |
| 217 | + "items": map[string]any{"data": []map[string]any{{ |
| 218 | + "id": "si_stale", |
| 219 | + "current_period_start": time.Now().UTC().Add(-time.Hour).Unix(), |
| 220 | + "current_period_end": time.Now().UTC().Add(30 * 24 * time.Hour).Unix(), |
| 221 | + "price": map[string]string{"id": testTeamPriceID}, |
| 222 | + }}}, |
| 223 | + }) |
| 224 | + if err != nil { |
| 225 | + t.Fatalf("marshal: %v", err) |
| 226 | + } |
| 227 | + fake := &fakeStripeRemote{ |
| 228 | + verifyWebhookFn: func(_ []byte, _ string) (stripeapi.Event, error) { |
| 229 | + return stripeapi.Event{ |
| 230 | + ID: "evt_stale_active", |
| 231 | + Type: stripeapi.EventType("customer.subscription.updated"), |
| 232 | + Created: staleCreated, |
| 233 | + Data: &stripeapi.EventData{Raw: raw}, |
| 234 | + }, nil |
| 235 | + }, |
| 236 | + } |
| 237 | + mux := newOrgBillingMuxWithPrices(t, pool, ownerID, fake, testTeamPriceID, testProPriceID) |
| 238 | + resp := postBillingWebhook(t, mux, "evt_stale_active") |
| 239 | + if resp.Code != http.StatusOK { |
| 240 | + t.Fatalf("stale event status=%d body=%s", resp.Code, resp.Body.String()) |
| 241 | + } |
| 242 | + state, err := orgbilling.GetOrgBillingState(ctx, orgbilling.Deps{Pool: pool}, orgID) |
| 243 | + if err != nil { |
| 244 | + t.Fatalf("get: %v", err) |
| 245 | + } |
| 246 | + if state.Plan != orgbilling.PlanFree { |
| 247 | + t.Fatalf("stale event corrupted state: plan=%s want free", state.Plan) |
| 248 | + } |
| 249 | + if state.SubscriptionStatus != orgbilling.SubscriptionStatusCanceled { |
| 250 | + t.Fatalf("stale event corrupted status: got %s want canceled", state.SubscriptionStatus) |
| 251 | + } |
| 252 | +} |
| 253 | + |
| 188 | 254 | // TestBillingWebhookGuardRefusesSecondSubscriptionForSameCustomer locks |
| 189 | 255 | // PRO08 D3: when the principal already has a Stripe subscription on |
| 190 | 256 | // file, a webhook event referencing a DIFFERENT subscription must be |