fix: stripe webhook not syncing checkout.session.completed to order status (#264) #266
No reviewers
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
ldraney/basketball-api!266
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "264-fix-webhook-sync"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Fixes three issues in the Stripe webhook handler that could cause order status to stay
pendingafter successful payment. Addspayment_statuschecking per Stripe best practices, defensivemetadatanull handling, error isolation for jersey sync, and structured logging for debugging.Changes
src/basketball_api/routes/webhooks.pypayment_status == "paid"guard in_handle_generic_order_completed-- Stripe best practice to prevent fulfillment before payment is confirmedsession_data.get("metadata", {})tosession_data.get("metadata") or {}in all three checkout handlers -- preventsAttributeErrorwhen Stripe sendsmetadata: nullsync_player_jersey_from_orderin try/except so a sync failure does not prevent the order statusdb.commit()from executinglogger.infoat webhook entry with event type and session IDlogger.infoon fallthrough to registration handler (no handler matched)payment_intenttests/test_checkout.pypayment_status: "paid"to all existing webhook test event data (required by new guard)test_webhook_skips_fulfillment_when_payment_status_not_paid-- verifies order stays pending whenpayment_status != "paid"test_webhook_skips_fulfillment_when_payment_status_missing-- verifies order stays pending whenpayment_statuskey is absenttest_webhook_handles_null_metadata-- verifies no crash whenmetadata: nulltest_webhook_updates_player_on_upgrade_from_opt_out-- verifies player jersey fields update when upgrading from opt-out to real jerseytest_webhook_commits_order_status_even_if_sync_fails-- verifies order marked paid even whensync_player_jersey_from_orderraisesTest Plan
ruff formatandruff checkcleanReview Checklist
payment_status: "paid"to match new guardRelated Notes
None -- standalone bug fix.
Related
Closes #264
The webhook handler for checkout.session.completed had three issues that could cause order status to stay pending after successful payment: 1. No payment_status check -- Stripe best practice requires verifying payment_status == "paid" before fulfilling. Without this, async payment methods could trigger premature fulfillment, and the lack of the check meant the handler had no guard against partial payment states. 2. Defensive metadata handling -- session_data.get("metadata", {}) returns None (not {}) when the key exists with a null value. Changed to `get("metadata") or {}` to prevent AttributeError on None.get(). 3. No error isolation for jersey sync -- if sync_player_jersey_from_order raised an exception, the entire handler would fail and db.commit() would never execute, leaving the order as pending. Wrapped sync in try/except so order status is always committed even if jersey sync fails. Also added structured logging at webhook entry, handler decisions, and fallthrough paths to make future debugging possible without guessing which code path was taken. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>QA Review -- PR #266
Scope Check
PR touches exactly 2 files (
webhooks.py+test_checkout.py), both directly related to issue #264. No unrelated changes.Code Quality
webhooks.py changes (3 fixes + logging):
payment_statusguard (lines 186-197) -- Correct implementation. Checkspayment_status != "paid"before fulfilling. ReturnsTrue(claims the event) so it doesn't fall through to other handlers. Good: uses warning-level log with order ID and the unexpected status value.Defensive metadata (
or {}pattern, lines 170, 237, 332) -- Fixes theNonemetadata edge case where.get("metadata", {})returnsNonewhen the key exists with a null value. Applied consistently across all three checkout handler paths.Jersey sync error isolation (lines 206-215) --
try/except Exceptionwrappingsync_player_jersey_from_orderensuresdb.commit()on line 217 always executes. Useslogger.exceptionwhich captures the full traceback. Order status update is decoupled from jersey sync success.Structured logging (lines 316-320, 417-420) -- Entry log on every webhook with event type and session ID. Fallthrough log when no handler matched. Good for production debugging.
Test Quality
5 new tests added (20 -> 25 total):
test_webhook_skips_fulfillment_when_payment_status_not_paid-- verifiesunpaidstatus is rejectedtest_webhook_skips_fulfillment_when_payment_status_missing-- verifies missing key is rejectedtest_webhook_handles_null_metadata-- verifiesmetadata: Nonedoesn't crashtest_webhook_updates_player_on_upgrade_from_opt_out-- the exact production scenario from issue #264test_webhook_commits_order_status_even_if_sync_fails-- verifies error isolationAll 4 existing webhook tests updated with
"payment_status": "paid"to match the new guard. No test logic changed, only event data enriched.Nits
None. Changes are minimal, focused, and well-tested.
Full Suite
694 tests pass. Ruff clean.
VERDICT: APPROVED
PR #266 Review
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy -- applying Python/FastAPI domain checklist.
Stripe webhook correctness:
payment_status == "paid"guard (webhooks.pyline ~187 in diff): Correctly placed AFTER the order lookup but BEFORE settingorder.status = OrderStatus.paid. This prevents fulfillment on async payment methods (bank debits, etc.) wherecheckout.session.completedfires before payment clears. The default of""when the key is absent means missingpayment_statusalso blocks fulfillment -- correct and safe.Null metadata fix (
session_data.get("metadata") or {}): Applied to all three metadata access points:_handle_generic_order_completed(line 170)_handle_jersey_checkout_completed(line 234 post-diff)stripe_webhookinline registration handler (line 329 post-diff)The pattern
get("metadata") or {}correctly handles bothmetadatakey absent (returnsNone->{}) ANDmetadatakey present with explicitnullvalue (returnsNone->{}). The old pattern.get("metadata", {})only handled the absent-key case. This is the correct fix.Jersey sync isolation (try/except around
sync_player_jersey_from_order): Thedb.commit()is positioned AFTER the try/except block at line ~203 in the diff, so the order status update (order.status = OrderStatus.paid) is always committed regardless of sync failure.logger.exceptioncaptures the full traceback. The bareexcept Exceptionis appropriate here -- this is a fire-and-forget sync, not a control flow decision.Structured logging: Entry-point log with
event_typeandsession_id, enhanced completion log withpayment_intent, and fallthrough log are all useful for production debugging. Useslogger.infofor happy path andlogger.warning/logger.exceptionfor anomalies -- correct severity levels.Webhook signature verification: Untouched.
stripe.Webhook.construct_eventwithstripe_signatureandsettings.stripe_webhook_secretremains intact at the top ofstripe_webhook(). No security regression.Test coverage (5 new tests):
test_webhook_skips_fulfillment_when_payment_status_not_paidpendingwhenpayment_status="unpaid"test_webhook_skips_fulfillment_when_payment_status_missingpendingwhen key absenttest_webhook_handles_null_metadatametadata: Nonetest_webhook_updates_player_on_upgrade_from_opt_outtest_webhook_commits_order_status_even_if_sync_failssync_player_jersey_from_orderto raise, verifies order stillpaidAll three root causes have direct test coverage. The sync failure test correctly patches the sync function at the module level (
basketball_api.routes.webhooks.sync_player_jersey_from_order) and verifies bothorder.statusandstripe_payment_intent_id.Existing tests updated: All 4 existing webhook tests (
test_webhook_updates_order_to_paid,test_webhook_syncs_jersey_fields_to_player,test_webhook_syncs_warmup_jersey_option,test_webhook_skips_player_sync_for_non_jersey_product) havepayment_status: "paid"added to their event data, which is required by the new guard.BLOCKERS
None.
NITS
NIT:
_handle_jersey_checkout_completedlackspayment_statusguard -- This legacy handler at line 205 also marksplayer.jersey_order_status = JerseyOrderStatus.paidand commits, but does not checkpayment_status == "paid"first. While it is documented as "backwards compatibility" and the generic order handler is the primary path now, it is still called for any checkout session withjersey_optionmetadata. This is a consistency gap. Recommend tracking as discovered scope for a follow-up ticket.NIT: PR body "Related Notes" section says "None -- standalone bug fix" -- Convention expects the Related section to reference a plan slug. Since this is a standalone bug fix (not plan-driven), "None" is acceptable, but it could reference the board item directly (board-westside-basketball item #719 is already mentioned at the bottom, which covers this).
SOP COMPLIANCE
264-fix-webhook-syncfollows{issue-number}-{kebab-case-purpose}fix:)PROCESS OBSERVATIONS
This is a well-scoped bug fix with clear root cause analysis. The three bugs are related (all in the same webhook handler path), so bundling them in one PR is appropriate. The PR body is thorough -- it documents each change, explains the Stripe best practice for
payment_status, and includes a test plan with concrete numbers.The
_handle_jersey_checkout_completedconsistency gap (NIT #1) should become a Forgejo issue to close the loop on legacy handler hardening.VERDICT: APPROVED