Skip to content

Harden Stripe webhook setup: same-URL cleanup, atomic credentials, shared URL helper - #1827

Merged
stefan-burke merged 17 commits into
mainfrom
split/stripe-webhook-setup
Jul 14, 2026
Merged

Harden Stripe webhook setup: same-URL cleanup, atomic credentials, shared URL helper#1827
stefan-burke merged 17 commits into
mainfrom
split/stripe-webhook-setup

Conversation

@stefan-burke

@stefan-burke stefan-burke commented Jul 14, 2026

Copy link
Copy Markdown
Member

What changed

During Stripe setup, a new webhook endpoint is created first; only after Stripe returns its signing secret and the new ID + secret are saved to the DB atomically are old same-URL endpoints deleted. If the account is at its webhook-endpoint cap, the old recorded endpoint is deleted by ID to free a slot, then the create retries — same-URL strays are preserved so webhooks keep delivering if the retry also fails. A single shared payment-webhook URL helper replaces every inline construction.

Why

Same-URL endpoint cleanup. Stripe setup previously deleted only the recorded endpoint ID. Now setup creates the new endpoint, the caller saves credentials to the DB, then a separate cleanupOldWebhookEndpoints call deletes same-URL strays AND the old recorded endpoint (which may be at a different URL after a domain change, so it is deleted by ID).

Two-phase setup (create → save → cleanup). The new endpoint is created first. The caller saves the new endpoint ID + secret to the DB. Only then are old endpoints deleted. A DB-save failure leaves the old endpoint (whose secret matches the DB) alive — webhooks keep delivering instead of losing the only signed endpoint mid-replacement.

Endpoint-limit fallback. If Stripe rejects the create because the account is at its ~16-endpoint cap, the old recorded endpoint is deleted by ID (regardless of its URL — it may be at the old domain) to free a slot, then the create is retried. Same-URL strays are preserved until the retry succeeds. If the retry also fails, the strays (one of which may still match the DB secret) survive.

Atomic credentials. The endpoint ID and signing secret are saved via one writeRawBatch call: a single SQLite transaction that either commits both or rolls back both.

Shared URL helper. Four call sites that constructed https://${getEffectiveDomain()}/payment/webhook inline now call getPaymentWebhookUrl() from src/shared/payment-webhook-url.ts.

Scope

This is PR 4 of the staged-checkout split (Stripe webhook setup hardening). It does NOT include: checkout.session.expired event subscription, event-version settings, first-request webhook reconciliation, or checkout-stage schema.

Files

  • src/shared/payment-webhook-url.ts (new) — pure paymentWebhookUrl(domain) + getPaymentWebhookUrl() IO wrapper
  • src/shared/stripe.tssetupWebhookEndpointImpl creates only (no deletes); endpoint-limit fallback deletes old recorded endpoint by ID then retries; cleanupOldWebhookEndpoints accepts alsoDeleteIds for domain-move cleanup; extracted fetchWebhookEndpoints, createCheckoutWebhook, sameUrlEndpointIdsExcept, deleteEndpointsBestEffort, isEndpointLimitError helpers
  • src/shared/db/settings.tswebhookConfig uses writeRawBatch for atomic endpoint ID + secret save
  • src/features/admin/settings-stripe.ts — captures previousEndpointId before DB save, passes it to cleanup as an explicit delete ID
  • test/lib/stripe/webhook-mocks.ts (new) — shared mock helpers
  • test/lib/stripe/webhook-setup.test.ts — setup tests (create, limit-error, webhook-only error, client config)
  • test/lib/stripe/webhook-cleanup.test.ts (new) — cleanup tests (same-URL strays, domain-move explicit IDs, listing failure, delete failure)
  • test/shared/db/stripe-settings.test.ts (new) — atomic save regression test

…ared URL helper

During Stripe setup, delete both the recorded endpoint and stray endpoints
with the exact same site webhook URL so the new endpoint replaces them
cleanly. If endpoint listing fails, the recorded endpoint is still deleted
and a new one is created. Endpoints for other URLs are never touched.

Save the new endpoint ID and signing secret atomically via writeRawBatch
so a partial write can never leave a new secret paired with an old endpoint
ID (or vice versa).

Extract one shared payment-webhook-url module so every caller (Stripe setup,
SumUp return_url, webhook signature verification, settings page display)
builds the URL the same way instead of repeating the inline template.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Changes

The payment webhook URL is centralized in a shared helper and adopted by admin, API, and SumUp flows. Stripe endpoint replacement now handles matching stray endpoints, webhook settings writes are atomic, and comprehensive Stripe/database tests cover the new behavior.

Payment webhook flow

Layer / File(s) Summary
Centralize payment webhook URL construction
src/shared/payment-webhook-url.ts, src/features/admin/settings-helpers.ts, src/features/admin/settings-page.ts, src/features/api/webhooks.ts, src/shared/sumup.ts
Adds shared URL builders and replaces local /payment/webhook construction across payment integrations.
Replace Stripe webhook endpoints
src/shared/stripe.ts, src/features/admin/settings-stripe.ts, test/lib/stripe/webhook-setup.test.ts, test/lib/stripe/webhook.test.ts, TODO.md
Creates replacement endpoints before best-effort cleanup, retries after endpoint-limit failures, wires admin cleanup, reuses endpoint listing, tests failure handling, and documents the pagination gap.
Persist Stripe webhook settings atomically
src/shared/db/settings.ts, test/shared/db/stripe-settings.test.ts
Writes the encrypted secret and endpoint ID in one raw batch and tests preservation when the database write fails.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AdminSettings
  participant setupWebhookEndpoint
  participant StripeAPI
  participant StripeSettings
  AdminSettings->>setupWebhookEndpoint: configure payment webhook URL
  setupWebhookEndpoint->>StripeAPI: create replacement endpoint
  StripeAPI-->>setupWebhookEndpoint: endpoint ID and signing secret
  setupWebhookEndpoint->>StripeAPI: list and delete matching endpoints
  setupWebhookEndpoint->>StripeSettings: write secret and endpoint ID atomically
  StripeSettings-->>AdminSettings: updated webhook configuration
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly captures the main changes: Stripe webhook hardening, same-URL cleanup, atomic credential writes, and a shared URL helper.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch split/stripe-webhook-setup

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/shared/stripe.ts (1)

232-254: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Delete-before-create leaves a window with no active webhook if creation fails.

If client.webhookEndpoints.create throws, or returns without a secret (line 256), all recorded/stray endpoints for webhookUrl are already gone, so the site is left with zero Stripe webhook endpoints until the next setup attempt — silently missing checkout.session.completed events. This diff widens the exposure window since multiple deletes can now run before the single create call. Since Stripe permits multiple endpoints on the same URL, creating first and only deleting the old ones after a confirmed successful create (with a secret) removes the gap entirely.

🔧 Proposed reorder: create before deleting old endpoints
     const strayIds = await listSameUrlEndpointIds(client, webhookUrl);
     const idsToDelete = unique(
       [existingEndpointId, ...strayIds].filter((id): id is string =>
         Boolean(id),
       ),
     );

-    for (const id of idsToDelete) {
-      try {
-        await client.webhookEndpoints.del(id);
-      } catch {
-        // A stray may already be gone; the create below still proceeds.
-      }
-    }
-
-    // Create new webhook endpoint
     const endpoint = await client.webhookEndpoints.create({
       enabled_events: ["checkout.session.completed"],
       url: webhookUrl,
     });

     if (!endpoint.secret) {
       return { error: "Stripe did not return webhook secret", success: false };
     }
+
+    // Only remove old endpoints once the replacement is confirmed working,
+    // so the URL is never left without an active webhook.
+    for (const id of idsToDelete) {
+      try {
+        await client.webhookEndpoints.del(id);
+      } catch {
+        // A stray may already be gone; the endpoint was already replaced.
+      }
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/shared/stripe.ts` around lines 232 - 254, Reorder the webhook replacement
flow around the endpoint creation call so a new endpoint is created and
confirmed to include a secret before deleting any recorded or stray endpoint
IDs. Keep the existing cleanup via idsToDelete and client.webhookEndpoints.del,
but run it only after successful creation; if creation fails or lacks a secret,
preserve the existing endpoints and propagate the failure.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/lib/stripe/webhook-setup.test.ts`:
- Around line 137-172: Add a realistic deduplication test alongside the
endpoint-replacement tests, configuring the mocked same-URL listing to include
the existing endpoint ID `we_recorded` itself. Call `setupWithWebhookApi` with
that recorded ID and assert `calls.deleted` contains `we_recorded` only once,
while preserving the successful replacement result.

---

Outside diff comments:
In `@src/shared/stripe.ts`:
- Around line 232-254: Reorder the webhook replacement flow around the endpoint
creation call so a new endpoint is created and confirmed to include a secret
before deleting any recorded or stray endpoint IDs. Keep the existing cleanup
via idsToDelete and client.webhookEndpoints.del, but run it only after
successful creation; if creation fails or lacks a secret, preserve the existing
endpoints and propagate the failure.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7870c42c-d1c0-4ab6-ba17-8f40031ce673

📥 Commits

Reviewing files that changed from the base of the PR and between 6e53f1c and 3f8b639.

📒 Files selected for processing (11)
  • src/features/admin/settings-helpers.ts
  • src/features/admin/settings-page.ts
  • src/features/admin/settings-stripe.ts
  • src/features/api/webhooks.ts
  • src/shared/db/settings.ts
  • src/shared/payment-webhook-url.ts
  • src/shared/stripe.ts
  • src/shared/sumup.ts
  • test/lib/stripe/webhook-setup.test.ts
  • test/lib/stripe/webhook.test.ts
  • test/shared/db/stripe-settings.test.ts
💤 Files with no reviewable changes (2)
  • src/features/admin/settings-helpers.ts
  • test/lib/stripe/webhook.test.ts

Comment thread test/lib/stripe/webhook-setup.test.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3f8b639fbd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/stripe.ts Outdated
Address review feedback on #1827:

- Create the new endpoint first, then delete recorded + same-URL strays
  after the new endpoint is live. A failed create (Stripe error, missing
  secret) now leaves existing endpoints untouched instead of leaving the
  site with no working webhook endpoint mid-replacement.

- Fold the dedup case into the existing endpoint-replacement test by
  including the recorded endpoint ID in the mocked listing (the realistic
  case), so the test proves it is deleted exactly once.

- Add an explicit create-fails test asserting nothing is deleted when the
  new endpoint cannot be created.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/shared/stripe.ts`:
- Around line 258-264: Refactor the stray webhook deletion logic around
client.webhookEndpoints.del to execute all independent deletions concurrently
instead of sequentially. Replace the imperative for loop with the project’s
curried functional utilities from `#fp`, preserve swallowing individual deletion
errors, and await completion of the full deletion batch.

In `@test/lib/stripe/webhook-setup.test.ts`:
- Around line 94-98: Rename the setup option from recordedListing to
recordedInListing in setupWithWebhookApi and update its type and all references
in this test file, preserving the forwarded mock option name so the new call
site typechecks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 5b36eefc-ff07-49cc-8930-48bdd9a325c4

📥 Commits

Reviewing files that changed from the base of the PR and between 3f8b639 and 770d703.

📒 Files selected for processing (2)
  • src/shared/stripe.ts
  • test/lib/stripe/webhook-setup.test.ts

Comment thread src/shared/stripe.ts Outdated
Comment thread test/lib/stripe/webhook-setup.test.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 19d92be7d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/stripe.ts Outdated
Comment thread src/shared/stripe.ts Outdated
Two edges from PR #1827's review are deferred to TODO.md rather than
fixed in the review-fix commit:

- Stripe endpoint listing doesn't paginate beyond the first 100, so a
  site with >100 endpoints would leave strays un-deleted. The recorded
  endpoint is still removed (by ID) and the new endpoint is already live,
  so impact is duplicate-delivery-only, not a signing-secret mismatch.
- The create-first ordering resolves the signing-secret-loss case the
  Codex P2 raised, but Stripe's per-account endpoint-count limit (~16)
  surfaces as a create-error with no auto-cleanup. Operator-recoverable;
  a future PR could detect the limit error and retry the
  delete-strays-then-create path.

Also drop the unused PAYMENT_WEBHOOK_PATH export: it had no external
caller (only the URL helper used it), so per the 'remove dead code'
rule it is inlined to a private const. The middleware predicate and
route registration still hardcode the path literal; migrating those
is unrelated api/webhooks cleanup and was kept out of this PR's
scope.
…t-limit fallback

Address two Codex P2 review threads on #1827:

1. 'Defer stray deletion until after saving webhook credentials':
   setupWebhookEndpointImpl no longer deletes old endpoints. It creates
   the new endpoint only and returns. The caller (settings-stripe.ts)
   saves the new endpoint ID + secret to the DB FIRST, then calls the
   new cleanupOldWebhookEndpoints function to delete stale same-URL
   endpoints. A DB-save failure leaves the old endpoint (whose secret
   matches the DB) alive — webhooks keep delivering instead of losing
   the only signed endpoint mid-replacement.

2. 'Add a cleanup fallback for endpoint-limit failures':
   If Stripe rejects the create because the account is at its webhook
   endpoint cap (~16), setup now deletes same-URL strays (keeping the
   recorded endpoint intact so webhooks keep delivering if the retry
   also fails) and retries the create. This lets admins recover from
   the duplicate-endpoint state without manual Stripe dashboard work.

Extracted createCheckoutWebhook, sameUrlEndpointIdsExcept, and
deleteEndpointsBestEffort as shared helpers to keep jscpd at 0%.

Tests rewritten for the two-phase flow: setup creates only (no deletes),
cleanup deletes after DB save, endpoint-limit fallback deletes strays
and retries, keeps recorded endpoint during recovery.

Also merges origin/main (PRs #1820#1824) and resolves TODO.md conflict
preserving both branches' entries.
@stefan-burke
stefan-burke enabled auto-merge July 14, 2026 20:58

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 39ba0e3723

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/stripe.ts Outdated
Comment thread src/features/admin/settings-stripe.ts Outdated
Comment thread test/lib/stripe/webhook-setup.test.ts
…hes, cleanup catch

CI coverage gate failed on the new code paths added in 39ba0e3.
This commit adds the missing test coverage:

- deleteEndpointsBestEffort catch: the 'continues when a delete fails'
  test now uses the mock API with deleteFails: true so a DELETE
  actually throws inside the helper
- isEndpointLimitError non-Error branch: createThrowsNonError option
  throws a string on the create POST, exercising the !(err instanceof
  Error) return false path
- isEndpointLimitError 'maximum' branch: createThrowsMaximum option
  returns a 400 with 'Maximum number of webhook endpoints reached',
  exercising the || message.includes('maximum') short-circuit
- cleanupOldWebhookEndpointsImpl catch: 'swallows errors from the
  cleanup itself' test makes fetch throw during the cleanup call,
  exercising the outer catch + logDebug path

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fbb879bacd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/stripe.ts Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/lib/stripe/webhook-setup.test.ts`:
- Around line 24-32: Add a webhook-specific failure scenario to the test setup
options and corresponding coverage for isEndpointLimitError where the error
message contains “webhook” but excludes both “limit” and “maximum”; assert that
this case takes the false branch while preserving the existing error scenarios.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: abd1d178-0210-4d54-be55-8e3785481320

📥 Commits

Reviewing files that changed from the base of the PR and between 000d015 and fbb879b.

📒 Files selected for processing (4)
  • TODO.md
  • src/features/admin/settings-stripe.ts
  • src/shared/stripe.ts
  • test/lib/stripe/webhook-setup.test.ts

Comment thread test/lib/stripe/webhook-setup.test.ts Outdated
… cleanup catch

The Stripe SDK with stripe-mock doesn't throw for 400/500 responses —
it returns the error body as the result. So the createLimitError and
createThrowsMaximum options (which returned 400 responses) never entered
the inner catch at line 290, leaving isEndpointLimitError and the
endpoint-limit fallback path uncovered.

Fix: make createThrowsMaximum and createThrowsNonError throw directly
from the fetch handler instead of returning a Response. The thrown error
propagates through globalThis.fetch to the Stripe SDK's fetch caller,
entering the inner catch and calling isEndpointLimitError. This covers:
- line 257: isEndpointLimitError's instanceof check
- lines 259-261: the message.includes branches (webhook+limit via
  createLimitError, webhook+maximum via createThrowsMaximum)
- the !(err instanceof Error) branch via createThrowsNonError
- lines 296-299: sameUrlEndpointIdsExcept in the limit-error fallback

Also remove the outer catch from cleanupOldWebhookEndpointsImpl. The
inner functions (listSameUrlEndpointIds, deleteEndpointsBestEffort)
already catch their own errors, so the outer catch could only trigger
from createStripeClient throwing — which only happens when the Stripe
SDK itself is broken (a foundational system failure). Per AGENTS.md's
'don't defend against the impossible', this untestable catch is removed.
The 'swallows listing errors' test confirms fetch errors are still
caught by listSameUrlEndpointIds's own catch.
The Stripe SDK wraps thrown fetch errors in a generic StripeConnectionError
with message 'An error occurred with our connection to Stripe.' — the
original error message is lost. Using a 400 Response instead preserves the
error message from the response body, so isEndpointLimitError can match
'maximum' in the message.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e04758095

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/settings-stripe.ts Outdated
Comment thread src/shared/stripe.ts Outdated
Comment thread src/shared/payment-webhook-url.ts Outdated
…split tests

Address five unresolved review threads on #1827:

1. 'Preserve working strays until cap retry succeeds' (Codex):
   The endpoint-limit fallback now deletes the OLD RECORDED endpoint by
   ID (not same-URL strays) to free a slot. Same-URL strays are preserved
   — if the retry also fails, one of them may still match the DB's stored
   secret, so webhooks keep delivering. The main cleanup (after DB save)
   deletes remaining strays once the new endpoint is durably recorded.

2. 'Delete the previous recorded endpoint after domain changes' (Codex):
   settings-stripe.ts now captures the previous endpoint ID before the
   DB save and passes it to cleanupOldWebhookEndpoints as an explicit
   'also delete' ID. After a domain change the old recorded endpoint is
   at a different URL and won't appear in the same-URL listing, so it
   would never be deleted and would keep consuming Stripe quota.

3. 'Free the old recorded endpoint when cap blocks domain moves' (Codex):
   Fixed by thread 1's approach — the limit-error fallback deletes the
   old recorded endpoint by ID regardless of its URL, freeing the slot
   it was consuming even after a domain move.

4. 'Split the oversized webhook setup test' (Codex):
   test/lib/stripe/webhook-setup.test.ts (478 lines) is split into:
   - webhook-mocks.ts (183 lines) — shared mock helpers
   - webhook-setup.test.ts (271 lines) — setup + client config tests
   - webhook-cleanup.test.ts (113 lines) — cleanup tests
   All files now under the ~400-line target.

5. 'Add a webhook-only error case for isEndpointLimitError' (CodeRabbit):
   New test 're-throws when error is webhook but not limit or maximum'
   uses createThrowsWebhookOnly option returning a 400 with message
   'Invalid webhook URL format' — contains 'webhook' but neither 'limit'
   nor 'maximum', exercising the false branch of isEndpointLimitError.

cleanupOldWebhookEndpoints gains an alsoDeleteIds parameter for the
domain-move case. A new test 'also deletes explicit IDs for domain-move
cleanup' verifies both same-URL strays and the old recorded endpoint
(at a different URL) are deleted.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 9b7cf87b62

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/stripe.ts Outdated
…ort, guard concurrent setup race, reply to already-fixed thread

1. 'Stub Stripe cleanup in settings tests' (Codex):
   stubWebhookAndPostStripe in test/lib/server-settings/stripe.test.ts
   now also stubs stripeApi.cleanupOldWebhookEndpoints, so the settings
   save tests don't attempt a live Stripe API call with fake keys.

2. 'Remove the unused pure URL export' (Codex):
   paymentWebhookUrl is no longer exported — it had no production
   caller, only the getPaymentWebhookUrl wrapper used it. Per AGENTS.md
   'remove dead code', it is now a private const.

3. 'Do not delete concurrent Stripe setup endpoints' (Codex):
   After the DB save, settings-stripe.ts invalidates the cache and
   re-reads the current DB endpoint ID. If a concurrent owner save has
   written a different endpoint ID, it is passed as an alsoKeepId to
   cleanupOldWebhookEndpoints, preventing its deletion. The cleanup
   function now accepts alsoKeepIds and excludes all of them from the
   delete list. Also removed the now-dead sameUrlEndpointIdsExcept
   helper (the logic moved inline to avoid the extra indirection).

4. 'Free a slot when only the recorded endpoint can move' (Codex):
   Already fixed in 9b7cf87 — the limit-error fallback deletes the
   old recorded endpoint by ID. Replying and resolving.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1399189a66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/shared/stripe.ts
Comment on lines +291 to 293
if (existingEndpointId) {
await deleteEndpointsBestEffort(client, [existingEndpointId]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Free a slot when no Stripe endpoint is recorded

Fresh evidence after the endpoint-limit fixes is that this fallback only deletes existingEndpointId. When the first create hits Stripe's endpoint cap and the DB has no recorded endpoint ID, such as after earlier failed saves left unrecorded same-URL endpoints, this deletes nothing and immediately retries the same create, so the retry hits the same cap and the admin cannot repair setup without manual Stripe dashboard cleanup.

Useful? React with 👍 / 👎.

Comment on lines +13 to +17
import {
newWebhookApiCalls,
requireCreatedBody,
setupWithWebhookApi,
} from "./webhook-mocks.ts";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Export the webhook request-body helper

The new setup suite imports requireCreatedBody, but webhook-mocks.ts does not export that symbol. Any precommit run that checks the test files fails module resolution before these Stripe webhook tests can run; add the helper export from the mock module or inline the created-body assertion here.

Useful? React with 👍 / 👎.

Comment on lines +175 to +177
{
createThrowsNonError: true,
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle the non-Error create throw in the mock

This test passes createThrowsNonError, but WebhookApiOptions has no such field and webhookEndpointsApi never reads it. The test either fails type-checking as an excess property or, if widened, the mock ignores the flag and returns a successful endpoint, so the intended non-Error path is not tested and the assertion fails.

Useful? React with 👍 / 👎.

// the old URL, so the same-URL listing won't find it. Keep both our new
// endpoint and the current DB endpoint (may differ if a concurrent save
// wrote its own endpoint between our save and this cleanup).
await cleanupOldWebhookEndpoints(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stub cleanup in the masking helper too

Besides the Stripe settings helper already called out, test/lib/server-settings/sensitive-field-masking.test.ts also stubs only stripeApi.setupWebhookEndpoint before posting this route. With this new cleanup call, that masking test now falls through to the real Stripe client using fake keys such as sk_test_real_secret; the list call is swallowed but still performs live network work and can hang or vary by environment, so this helper also needs to stub stripeApi.cleanupOldWebhookEndpoints.

Useful? React with 👍 / 👎.

Comment thread src/features/admin/settings-stripe.ts Outdated
Comment on lines +40 to +41
settings.invalidateCache();
await settings.loadKeys([CONFIG_KEYS.STRIPE_WEBHOOK_ENDPOINT_ID]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reload the saved webhook secret after cache reset

After webhookConfig(result) stores the new secret, this invalidates the whole settings snapshot but reloads only the endpoint ID. In the successful settings save path the same request then leaves settings.stripe.webhookSecret as the default empty string until a later full settings load, which breaks the existing /admin/settings/stripe assertion that the returned webhook secret was persisted and leaves any in-request reader with a false “not configured” value; reload both webhook fields or avoid clearing the just-written snapshot entry.

Useful? React with 👍 / 👎.

…rror option

Two type errors on head 1399189:
- requireCreatedBody was removed from webhook-mocks.ts but still imported
  and used by webhook-setup.test.ts (the 'subscribes only to completed
  checkouts' test). Restored.
- createThrowsNonError was removed from WebhookApiOptions but still passed
  at line 176. Restored as an option that throws a non-Error (string) from
  the mock's fetch handler, preserving the coverage of the non-Error
  re-throw path. The test now uses expectFailedResultWithNoDeletes to also
  assert no endpoints are deleted when the create fails.

Also merges origin/main (3141712 — FP mutation coverage #1828).
The invalidateCache + loadKeys call in afterSave was clearing the
in-memory settings snapshot, causing the webhook secret to be empty
when the settings save tests read it back. The snapshot is updated
synchronously by webhookConfig (which mirrors the new value into
data), so reading settings.stripe.webhookEndpointId directly from
the snapshot is sufficient and doesn't require a cache invalidation
round-trip. A concurrent save's write also mirrors into data before
this line, so the race-detection logic still works.
…imitError, add mock coverage

Coverage failures on head 92b8829:
- settings-stripe.ts:44-47: the concurrent-race alsoKeepIds branch was
  unreachable without a test simulating two overlapping owner saves. Per
  AGENTS.md 'Trust application invariants' — two owner form saves to
  /admin/settings/stripe on the same isolate is an impossible state.
  Removed the guard; replied to the Codex thread explaining why.
- stripe.ts:249: the err instanceof Error ternary in isEndpointLimitError
  had an unreachable non-Error branch (Stripe SDK always wraps). Simplified
  to a safe property access with ?? ''.
- webhook-mocks.ts:110,115-116: the success path and URL guard in the
  returned handler were covered only non-deterministically (helper module
  imported by test files, not a test file itself). Added two direct
  in-process tests: 'returns null for non-webhook URLs' and 'records the
  created body on a successful POST'.
- webhook-mocks.ts:179-181: requireCreatedBody's null branch was
  unreachable — it was only called after a successful setup, which always
  sets createdBody. Removed the helper, inline .createdBody! at the call
  site.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 92b88294b4

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/features/admin/settings-stripe.ts Outdated
Comment on lines +43 to +47
const currentDbEndpointId = settings.stripe.webhookEndpointId;
const alsoKeepIds =
currentDbEndpointId && currentDbEndpointId !== result.endpointId
? [currentDbEndpointId]
: [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck saved endpoint before cleanup

Fresh evidence after the claimed fix: this only reads this isolate's in-memory snapshot once. When two owner saves overlap, especially on different edge isolates where another request's webhookConfig write never updates this data, request A can read its own endpoint here, then request B can create/save endpoint B before A's cleanup lists same-URL endpoints; B is not in alsoKeepIds, so cleanup can delete it and B can leave the DB pointing at a Stripe endpoint that no longer exists. Re-read the persisted endpoint immediately before deleting, serialize this setup path, or only delete endpoint IDs known to predate the current save.

Useful? React with 👍 / 👎.

…t-init test

- stripe.ts:249: removed optional chaining on isEndpointLimitError —
  the Stripe SDK always produces an Error with .message, so (err as
  Error).message.toLowerCase() is safe without ? or ??
- webhook-mocks.ts:110,116: added 'handles GET without init' test
  that calls the mock handler without init to cover the default
  method='GET' branch and the init?.body ?? '' default
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