fix(api): keep schedule Instances/ScheduledDates consistent + Stripe session idempotency (#403 follow-up) - #405
Conversation
…session idempotency (#403 follow-up) Post-merge re-review of #403 returned CHANGES_REQUESTED with two findings that landed on an orphaned branch and are now fixed-forward onto main. HIGH — GetHabitScheduleQuery returned a full-range ScheduledDates (uncapped GetScheduledDates, validator allows up to MaxRangeDays=366) alongside an Instances list capped at MaxInstanceHorizonDays=90, so a validator-legal 91-366-day request produced silently inconsistent fields. The query handler now clamps the effective dateTo to dateFrom + MaxInstanceHorizonDays before it flows into both FilterScheduledHabits (parent ScheduledDates) and the ScheduleMapContext (Instances + child dates), so both fields derive from the same 90-day window. Server-authoritative; no reliance on frontend clamping and no change to HabitScheduleService.cs. MEDIUM — CreateCheckoutSessionAsync / CreatePortalSessionAsync gained a deterministic RequestOptions.IdempotencyKey (mirroring CreateCustomerAsync) so the new transient-retry policy cannot duplicate a session. The checkout key includes the referral-coupon marker so a retry with a different discount body cannot collide (Stripe 409). Refs thomasluizon/orbit-ui-mobile#243, #403 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review: PR #405
Recommendation: APPROVE
Summary
Small, well-scoped fixed-forward PR addressing two findings from a post-merge re-review of #403: (1) a clamp in GetHabitScheduleQuery.HandleScheduledHabits forcing ScheduledDates and Instances to derive from the same 90-day-capped window, and (2) Stripe RequestOptions.IdempotencyKey on CreateCheckoutSessionAsync/CreatePortalSessionAsync to prevent duplicate-session creation on the transient-retry path added in #403. Both fixes verified against the actual call graph — no prior review threads exist on this PR, nothing to re-flag.
Findings
Critical / High
None.
Medium
Deterministic Stripe idempotency key isn't scoped to a single retry cycle
src/Orbit.Infrastructure/Services/StripeBillingService.cs:78,95orbit-checkout-{userId}-{priceId}-{referralCouponId ?? "std"}andorbit-portal-{customerId}are deterministic across every call, not just across the retries of one logical attempt (StripeRetryPolicy.ExecuteWithRetryAsync, ~200-800ms backoff). Stripe caches an idempotency key's response for its documented TTL (~24h).- Risk: a user who abandons checkout/portal and repeats the same action (same price + referral state) same-day gets back the identical cached Stripe response instead of a fresh session — if the first session has since expired/been used, the user is silently handed a dead URL. Not exploitable cross-user (
userIdis always server-derived and part of the key, confirmed viaCreateCheckoutCommand.cs:44,61-67— no raw client-supplied strings at this call site), just a UX-correctness edge case. - Suggested fix: scope the key to the individual user-initiated attempt (e.g. a short-lived correlation id minted per command invocation) rather than the (userId, priceId, coupon) tuple indefinitely.
- Not blocking — doesn't regress anything #403 was trying to fix, and is a reasonable fast-follow rather than part of this PR's scope.
Low / Info
- Schedule-handler fix verified correct end-to-end:
dateToclamp atGetHabitScheduleQuery.cs:217-218flows through to bothScheduledDates(HabitScheduleFilters.FilterScheduledHabits) andInstances(ScheduleMapContext.DateTo→GetScheduledDates/GetInstances, parent and children), matchingHabitScheduleService.GetInstances's own internal 90-day clamp. Regression testHandle_RangeExceedingHorizon_BoundsScheduledDatesAndInstancesConsistentlyexercises exactly the prior failure mode. Validator untouched at 366 days (backward compatible). - No unit test added for the Stripe idempotency-key change, but
StripeBillingServicehas zero pre-existing unit tests (concrete Stripe SDK types, not abstractions) — a pre-existing gap this diff doesn't worsen. - Security pass: no auth/authorization changes, no secret/credential exposure, no new injection surface (
priceId/referralCouponId/customerIdare server-resolved, not raw client input). Idempotency-key composition is not a cross-tenant collision risk.
What's good
- Both fixes are root-caused at the source rather than patched at each symptom.
- Regression test asserts the actual prior failure mode (
ScheduledDates.Max() == Instances.Max()), not just "doesn't crash." - Idempotency key on checkout correctly includes the referral-coupon marker to avoid a retry-with-differing-discount 409 conflict.
- Small, single-purpose diff (7 lines of production code across 2 files).
Deferred / not applicable
- Cross-repo dimensions (contract-aligner,
packages/sharedbackward-compat) — not verifiable in this job (orbit-ui-mobilenot checked out), but also not triggered: no DTO/record field added, removed, or renamed;openapi.jsonunchanged per PR body and confirmed by direct diff read. - Frontend-only dimensions (DESIGN.md/AI-slop, parity, i18n, FEATURES.md) — N/A, no
apps/*files touched.
There was a problem hiding this comment.
Review Complete
Scope: PR #405 in thomasluizon/orbit-api (fix/api-schedule-consistency-stripe-idempotency → main)
Recommendation: APPROVE
Summary
Checked prior review state first. The PR's head commit is aec6e94f2c75d90e92ad0c868f3c7bbbd7a66614 (a main-merge commit; the substantive fix commit is d7ffab6). A prior claude bot review already APPROVED this exact commit (submitted 2026-07-14T17:39:46Z). gh pr view confirms the PR has not moved since — no new commits, no diff to review. This run reaffirms the standing approval.
Recap (unchanged, still valid)
- Fix 1 (schedule consistency):
GetHabitScheduleQuery.cs:217-218clampsdateTotoMaxInstanceHorizonDaysbefore it feeds bothFilterScheduledHabits(→ScheduledDates) andScheduleMapContext(→Instances), so both fields now derive from the same capped window. Backed by a regression test (Handle_RangeExceedingHorizon_BoundsScheduledDatesAndInstancesConsistently) assertingScheduledDates.Max() == Instances.Max() == Today + MaxInstanceHorizonDays. - Fix 2 (Stripe idempotency):
StripeBillingService.cs:78,95adds deterministicRequestOptions.IdempotencyKeytoCreateCheckoutSessionAsync/CreatePortalSessionAsync, closing a duplicate-session gap on the transient-retry path from #403. - One Medium, non-blocking finding carried over from the prior review (not new, doesn't force NEEDS WORK): the idempotency key is deterministic across any call with the same
(userId, priceId, coupon)/customerId, not scoped to a single retry cycle — Stripe's ~24h key-response cache means a same-day repeat checkout/portal attempt could get a cached (possibly stale/expired) session URL instead of a fresh one. Suggested as a fast-follow, not this PR's scope. - No Critical/High findings. Validator (
MaxRangeDays = 366) untouched,openapi.jsonunchanged, no contract-shape change — contract drift / backward-compat guard not triggered.
| Severity | Count |
|---|---|
| Critical (incl. |
0 |
| High | 0 |
| Medium | 1 (carried over, non-blocking) |
| Low / Info | 3 (carried over) |
Subagents
| Agent | Verdict |
|---|---|
| security-reviewer | No auth/authz change, no new injection surface, server-derived userId/priceId/customerId |
| contract-aligner | Not triggered — no DTO/route/shared-type change in the diff |
Validation
| Check | Result |
|---|---|
| Build / Unit Tests / SonarCloud | Covered by separate required CI checks (not re-run here per instructions) |
Deferred — N/A dimensions
- Parity / i18n — frontend-only, N/A (no
apps/*changed, orbit-ui-mobile not checked out here — not verifiable in CI) - DESIGN.md/AI-slop — N/A (no UI files)
- FEATURES.md parity — N/A (pure bugfix, no user-facing feature surface changed)
- Contract drift — checked, none;
openapi.jsonconfirmed unchanged
Recommendation
APPROVE. Clean, targeted bugfix PR — no Critical/High findings, diff unchanged since the last approved review at this commit.
|



Why
These are the two review findings from PR #403's post-merge re-review. #403 was squash-merged at an earlier APPROVED state; the review bot then re-reviewed and returned
CHANGES_REQUESTEDwith the two issues below. The fix commit lived on an orphaned branch and was never merged, so the findings are currently live on main. This PR lands them fixed-forward.[HIGH] Schedule field consistency
GetHabitScheduleQueryreturned:ScheduledDatescomputed via the uncappedHabitScheduleService.GetScheduledDates(validatorGetHabitScheduleQueryValidatorallows a range up toMaxRangeDays = 366), andInstancescomputed viaGetInstances, which internally caps the forward horizon toMaxInstanceHorizonDays = 90.So a validator-legal 91–366-day request returned a full-range
ScheduledDatesnext to a 90-day-truncatedInstanceson the same DTO — silently inconsistent.Fix (query-handler level, server-authoritative): the handler now clamps the effective
dateTotodateFrom + MaxInstanceHorizonDaysbefore it flows into bothFilterScheduledHabits(which produces parentScheduledDates) and theScheduleMapContext(which drivesInstancesand childScheduledDates/Instances). Both fields now derive from the same 90-day window, so they cannot diverge. No reliance on frontend clamping.HabitScheduleService.csis not touched. The validator is left at 366 so 91–366-day requests still succeed (backward-compatible) — they just return consistent, both-capped data.Regression test added:
Handle_RangeExceedingHorizon_BoundsScheduledDatesAndInstancesConsistentlyproves a 200-day request returnsScheduledDates.Max() == Instances.Max() == Today + MaxInstanceHorizonDays.[MEDIUM] Stripe session idempotency
CreateCheckoutSessionAsyncandCreatePortalSessionAsynclacked aRequestOptions.IdempotencyKey. Now that both are wrapped in the new transient-retry policy (#403), a fail-then-retry could create duplicate sessions.CreateCustomerAsyncalready sets a deterministic key — mirrored here:orbit-checkout-{userId}-{priceId}-{referralCouponId ?? "std"}— deterministic per the reviewer's ask; the coupon marker is included so a retry with a different discount body (referral vs promo branch) cannot trigger a Stripe409 Idempotencyconflict.orbit-portal-{customerId}—returnUrlis static config, so no body-variance risk.Verification
dotnet build Orbit.slnx— 0 errorsdotnet test Orbit.slnx— green (Analyzers 7, Domain 516, Application 2895, Infrastructure 2055)dotnet ef migrations has-pending-model-changes— false (no model changes)src/Orbit.Api/openapi.json— unchanged (validator max untouched; only computed values and internal Stripe options changed — no contract shape change)Refs thomasluizon/orbit-ui-mobile#243, #403
🤖 Generated with Claude Code