Skip to content

feat(payment): optional Stripe Checkout for USD top-up - #355

Open
paxaq wants to merge 7 commits into
Laisky:mainfrom
paxaq:feat/stripe-checkout
Open

feat(payment): optional Stripe Checkout for USD top-up#355
paxaq wants to merge 7 commits into
Laisky:mainfrom
paxaq:feat/stripe-checkout

Conversation

@paxaq

@paxaq paxaq commented Jul 18, 2026

Copy link
Copy Markdown

Summary

Adds optional Stripe Checkout so operators can accept freeform USD top-ups and credit user quota after a signed webhook.

The feature is disabled when STRIPE_SECRET_KEY is unset (returns a clear "Stripe is not configured" message).

Backend

  • POST /api/user/topup/stripe (authenticated) — create a Checkout Session for amount_usd
  • POST /api/payment/stripe/webhook (public) — verify Stripe signature, mark order paid, credit quota (idempotent via payment_orders)
  • New model PaymentOrder + AutoMigrate
  • Config / admin options:
    • STRIPE_SECRET_KEY
    • STRIPE_WEBHOOK_SECRET
    • MIN_TOPUP_USD (default 5)

Frontend (modern)

Top-up page updates:

  • Freeform / preset USD amounts
  • Redirect to Stripe Checkout
  • Existing redemption code flow retained
  • Recent top-up history

Configuration

STRIPE_SECRET_KEY=sk_live_...   # or sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...
MIN_TOPUP_USD=5

Webhook endpoint to configure in the Stripe Dashboard:

https://<your-host>/api/payment/stripe/webhook

Behavior notes

  • User is charged exactly amount_usd (platform absorbs Stripe processing fees in this implementation).
  • Quota credited = amount_usd * QuotaPerUnit.
  • No schema/API impact for sites that do not set the Stripe secrets.

Test plan

  • go build / go test on touched packages
  • With secrets unset: top-up returns "Stripe is not configured"
  • With test-mode secrets: create Checkout Session, complete payment, webhook credits quota once
  • Replay webhook: no double credit
  • Modern TopUp UI: presets, min amount validation, success/cancel return paths

Out of scope

This PR intentionally excludes fork-only items (custom open theme, Resend, Railway/VPS deploy packs).

Summary by CodeRabbit

  • New Features
    • Added Stripe-powered USD top-ups with idempotent checkout creation, tracked payment order history (list and view by session), and verified webhook processing.
    • Added Stripe webhook callbacks plus new endpoints to fetch and list Stripe payment orders for the signed-in user.
    • Rebuilt the top-up page into a USD-based flow with Stripe and redemption-code options, recent activity, and success/cancel status banners.
  • Configuration
    • Added Stripe secret, webhook secret, public base URL, and min_topup_usd (default 5; enforced whole-USD minimum of 1); exposed Stripe readiness in status.
    • Marked sensitive Stripe keys as protected from being overwritten with empty values.
  • Infrastructure
    • Added payment-order persistence, webhook event deduplication, transactional settlement, and expanded tests (including concurrent settlement).

Introduce Stripe Checkout sessions for freeform USD top-ups, webhook
credit with idempotent payment_orders, config/admin options, and modern
TopUp UI. Disabled when STRIPE_SECRET_KEY is unset.
@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown

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

Adds Stripe configuration, checkout and webhook handlers, payment-order persistence, API routes, database migration, and a redesigned top-up page with Stripe payments and activity history.

Changes

Stripe top-up billing

Layer / File(s) Summary
Payment configuration and persistence
common/config/config.go, model/payment_order.go, model/option.go, model/main.go, model/payment_order_test.go, go.mod, controller/option.go
Adds Stripe settings, payment-order persistence and status transitions, webhook deduplication, migration support, option synchronization, sensitive-option handling, the Stripe SDK, and model tests.
Checkout creation and API routes
controller/payment_stripe.go, router/api.go, controller/payment_stripe_ready_test.go
Adds readiness and trusted URL validation, idempotent checkout creation, pending-order storage, session binding, order lookup APIs, and routing.
Webhook settlement and quota crediting
controller/payment_stripe.go
Verifies and deduplicates Stripe events, settles paid orders, handles terminal statuses, credits quota, and records top-up activity.
Top-up page and status-driven display
web/modern/src/pages/topup/TopUpPage.tsx, controller/misc.go, web/modern/src/pages/topup/__tests__/TopUpPage.amount.test.tsx, web/modern/src/pages/topup/TopUpPage.test.tsx
Replaces the previous top-up UI with Stripe and redemption forms, status-driven settings, return polling, balance refresh, activity history, portal access, payment notes, and updated tests.

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

Suggested reviewers: laisky

Sequence Diagram(s)

sequenceDiagram
  participant TopUpPage
  participant CreateStripeCheckout
  participant Stripe
  participant StripeWebhook
  participant PaymentOrder
  participant UserQuota
  TopUpPage->>CreateStripeCheckout: Submit amount_usd and request_id
  CreateStripeCheckout->>PaymentOrder: Store pending order
  CreateStripeCheckout->>Stripe: Create Checkout Session
  Stripe-->>CreateStripeCheckout: Return session URL and ID
  CreateStripeCheckout-->>TopUpPage: Return checkout URL
  Stripe->>StripeWebhook: Send signed payment event
  StripeWebhook->>PaymentOrder: Claim and settle payment order
  PaymentOrder->>UserQuota: Increment quota
  StripeWebhook-->>Stripe: Return received acknowledgement
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 summarizes the main change: optional Stripe Checkout support for USD top-ups.
Docstring Coverage ✅ Passed Docstring coverage is 90.00% which is sufficient. The required threshold is 80.00%.
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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request implements Stripe payment integration for quota top-ups, introducing new backend endpoints, database models to track payment orders, and a redesigned frontend billing page with payment history. The review feedback highlights critical issues that need to be addressed: a race condition in the webhook handler that could cause permanent quota loss (requiring the quota increase to be transactional with the order status update), concurrent data races on the global Stripe key (requiring a thread-safe Stripe client), and a potential DoS vulnerability on the webhook endpoint (requiring an io.LimitReader). Additionally, the feedback points out UI text contradictions regarding the minimum top-up amount and suggests allowing decimal inputs.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread model/payment_order.go Outdated
Comment thread controller/payment_stripe.go Outdated
Comment thread controller/payment_stripe.go
Comment thread controller/payment_stripe.go Outdated
Comment thread controller/payment_stripe.go Outdated
Comment thread controller/payment_stripe.go Outdated
Comment thread controller/payment_stripe.go Outdated
Comment thread web/modern/src/pages/topup/TopUpPage.tsx Outdated
Comment thread web/modern/src/pages/topup/TopUpPage.tsx
Comment thread web/modern/src/pages/topup/TopUpPage.tsx Outdated

@coderabbitai coderabbitai 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.

Actionable comments posted: 8

🧹 Nitpick comments (3)
controller/payment_stripe.go (2)

152-154: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add missing parameter and return value descriptions to the function comment.

As per coding guidelines, every function must have a comment that starts with the function name and describes its purpose, parameters, and return values in complete sentences.

📝 Proposed comment
 // StripeWebhook handles checkout.session.completed events. The route MUST receive
 // the raw request body — do not interpose middleware that consumes it.
+// It takes a gin.Context to parse the Stripe webhook payload and writes a JSON status response.
 func StripeWebhook(c *gin.Context) {
🤖 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 `@controller/payment_stripe.go` around lines 152 - 154, Update the
StripeWebhook function comment to start with “StripeWebhook” and describe the
handler’s purpose, its gin.Context parameter, and its return behavior in
complete sentences, while preserving the raw-request-body requirement.

Source: Coding guidelines


32-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add missing parameter and return value descriptions to the function comment.

As per coding guidelines, every function must have a comment that starts with the function name and describes its purpose, parameters, and return values in complete sentences.

📝 Proposed comment
 // CreateStripeCheckout creates a Stripe Checkout Session for a freeform USD top-up.
 // The fee is absorbed by the platform: the user is charged exactly AmountUSD.
+// It takes a gin.Context to read the request and writes a JSON response containing the checkout URL.
 func CreateStripeCheckout(c *gin.Context) {
🤖 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 `@controller/payment_stripe.go` around lines 32 - 34, Update the
CreateStripeCheckout comment to retain its purpose description and add
complete-sentence descriptions of the gin.Context parameter and the function’s
return behavior, including that it writes the checkout response through the
context rather than returning a value.

Source: Coding guidelines

model/payment_order.go (1)

41-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add missing function comments.

As per coding guidelines, every function must have a comment that starts with the function name and describes its purpose, parameters, and return values in complete sentences. CreatePaymentOrder and GetPaymentOrderBySession are missing these comments.

📝 Proposed comments
+// CreatePaymentOrder persists a new payment order to the database.
+// It takes a context for cancellation and the order to create, returning an error if the operation fails.
 func CreatePaymentOrder(ctx context.Context, order *PaymentOrder) error {
 	if err := DB.WithContext(ctx).Create(order).Error; err != nil {
 		return errors.Wrap(err, "create payment order")
 	}
 	return nil
 }
 
+// GetPaymentOrderBySession retrieves a payment order by its session ID.
+// It takes a context and the sessionID string, returning the matched PaymentOrder or nil if not found, along with any error encountered.
 func GetPaymentOrderBySession(ctx context.Context, sessionID string) (*PaymentOrder, error) {
 	var order PaymentOrder
 	err := DB.WithContext(ctx).Where("session_id = ?", sessionID).First(&order).Error
🤖 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 `@model/payment_order.go` around lines 41 - 58, Add GoDoc comments immediately
above CreatePaymentOrder and GetPaymentOrderBySession, starting each comment
with its function name and describing the function’s purpose, parameters, and
return values in complete sentences.

Source: Coding guidelines

🤖 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 `@controller/payment_stripe.go`:
- Around line 201-216: Make the payment transition and quota credit atomic by
adding a model-layer operation that marks the order paid and increments the user
quota within one database transaction. Update the webhook flow around
MarkPaymentOrderPaid and IncreaseUserQuota to use this atomic operation,
preserving idempotent handling of already-processed payments and returning
errors only when the combined transaction fails.

In `@web/modern/src/pages/topup/TopUpPage.tsx`:
- Around line 30-40: Add comments beginning with each symbol’s name for
readQuotaPerUnit, formatUSD, TopUpPage, all loaders and handlers, and every
derived helper in the affected section. Each comment must describe the
function’s purpose, parameters, and return value in complete sentences,
following the project’s documentation guidelines.
- Around line 106-107: Update the catch blocks in TopUpPage’s user-data and
top-up request flows to avoid passing raw error objects to console.error. Log
only a sanitized string derived from the error name or message, with a safe
fallback for non-Error values, and apply this consistently to all referenced
catch blocks.
- Around line 509-545: Convert the history table in the TopUpPage component to
the repository’s responsive table pattern by using useResponsive,
ResponsivePageContainer, and AdaptiveGrid as appropriate. Add mobile data-label
attributes to each history cell using the existing translated Date, Source, and
Amount labels, while preserving the current history mapping, formatting, and
styling.
- Around line 113-122: Update loadSystemStatus and the checkout card rendering
in TopUpPage to consume an explicit Stripe capability from the parsed system
status, such as a stripe-enabled flag. Store that capability in component state
and render the Stripe checkout UI only when it is enabled; preserve the existing
top-up link behavior for enabled installations and hide the payment action when
Stripe is disabled or the capability is absent.
- Around line 26-28: Replace the hardcoded MIN_TOPUP_USD and related $20/$5
values in TopUpPage with the effective minimum from system status. Use that
configured minimum consistently for amount validation, preset/input constraints,
and all user-facing minimum descriptions so checkout and page behavior remain
aligned.
- Around line 35-37: Update formatUSD and the related date-formatting logic to
use the active i18n.language locale instead of hardcoded en-US values, while
retaining USD as the currency. Obtain the locale from the page’s existing i18n
context and pass it to both Intl.NumberFormat and Intl.DateTimeFormat so all
displayed currency and dates follow the selected application language.
- Around line 317-325: Update the affected icon-only controls in TopUpPage,
including both buttons using loadUserData and RefreshCw, to provide translated
aria-label text and a data-label for mobile layouts. Ensure each control has a
minimum 44px mobile tap target while preserving its existing sizing and behavior
on larger screens.

---

Nitpick comments:
In `@controller/payment_stripe.go`:
- Around line 152-154: Update the StripeWebhook function comment to start with
“StripeWebhook” and describe the handler’s purpose, its gin.Context parameter,
and its return behavior in complete sentences, while preserving the
raw-request-body requirement.
- Around line 32-34: Update the CreateStripeCheckout comment to retain its
purpose description and add complete-sentence descriptions of the gin.Context
parameter and the function’s return behavior, including that it writes the
checkout response through the context rather than returning a value.

In `@model/payment_order.go`:
- Around line 41-58: Add GoDoc comments immediately above CreatePaymentOrder and
GetPaymentOrderBySession, starting each comment with its function name and
describing the function’s purpose, parameters, and return values in complete
sentences.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3cbce84a-eb98-4adc-bdd8-c5548c1a65ae

📥 Commits

Reviewing files that changed from the base of the PR and between ddd2ec5 and 9a7c9be.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (9)
  • common/config/config.go
  • controller/payment_stripe.go
  • go.mod
  • model/main.go
  • model/option.go
  • model/payment_order.go
  • model/payment_order_test.go
  • router/api.go
  • web/modern/src/pages/topup/TopUpPage.tsx

Comment thread controller/payment_stripe.go Outdated
Comment thread web/modern/src/pages/topup/TopUpPage.tsx Outdated
Comment thread web/modern/src/pages/topup/TopUpPage.tsx
Comment thread web/modern/src/pages/topup/TopUpPage.tsx Outdated
Comment thread web/modern/src/pages/topup/TopUpPage.tsx Outdated
Comment thread web/modern/src/pages/topup/TopUpPage.tsx Outdated
Comment thread web/modern/src/pages/topup/TopUpPage.tsx
Comment thread web/modern/src/pages/topup/TopUpPage.tsx Outdated
Address PR review: credit quota in the same transaction as marking paid,
use stripe client.New instead of package-level Key, expose stripe_enabled
and min_topup_usd on /api/status, and harden Modern TopUpPage (dynamic min
presets, hide Stripe when disabled, string-only logs, a11y/responsive).

@coderabbitai coderabbitai 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.

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 `@controller/misc.go`:
- Around line 55-56: Update the stripe_enabled value in the configuration map to
require both the trimmed StripeSecretKey and StripeWebhookSecret to be present,
keeping the top-up UI disabled unless checkout settlement is fully configured.

In `@model/payment_order.go`:
- Around line 123-128: Update the payment settlement update in the transaction
to condition the query on the order still having PaymentStatusPending, then
inspect RowsAffected and abort without crediting quota when no row was updated.
Preserve the existing error handling for database failures and ensure only the
successful conditional update proceeds to settlement.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: b5bff773-5fdb-45c6-a672-2b4b807af674

📥 Commits

Reviewing files that changed from the base of the PR and between 9a7c9be and 071caa6.

📒 Files selected for processing (4)
  • controller/misc.go
  • controller/payment_stripe.go
  • model/payment_order.go
  • web/modern/src/pages/topup/TopUpPage.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • controller/payment_stripe.go
  • web/modern/src/pages/topup/TopUpPage.tsx

Comment thread controller/misc.go Outdated
Comment thread model/payment_order.go Outdated
- Optimistic pending→paid claim (RowsAffected) to prevent double settle
- Limit Stripe webhook body to 1 MiB
- Gate stripe_enabled on both secret and webhook secret
- Allow cent precision (step 0.01) on top-up amount input
@paxaq

paxaq commented Jul 18, 2026

Copy link
Copy Markdown
Author

Addressed review feedback in 071caa6e and 7ec6afbb:

Already fixed earlier (071caa6e)

  • Atomic settle: SettlePaidPaymentOrder marks paid + credits quota in one TX
  • Per-request Stripe client.New (no global stripe.Key)
  • Quota derived from amountCents
  • UI: min from /api/status (min_topup_usd), hide Stripe when disabled, locale-aware format, string-only logs, mobile history cards, 44px targets

Follow-up (7ec6afbb)

  • Optimistic pending → paid claim with RowsAffected (no double-credit race)
  • Webhook body limited to 1 MiB (io.LimitReader)
  • stripe_enabled requires both secret key and webhook secret
  • Amount input step="0.01" for cent precision

Resolving the review threads accordingly.

Laisky commented Jul 22, 2026

Copy link
Copy Markdown
Owner

Thanks for this PR. The overall direction is good, and the current code already has several important protections: signed webhook verification, a request-body size limit, and an atomic pending -> paid update with quota crediting.

However, I do not think this is ready to merge into production yet. Please address the following issues.

1. The Stripe secret key can be returned to the browser

StripeSecretKey is stored in OptionMap, but isSensitiveOptionKey() only hides names ending in Token, Secret, or Password. StripeSecretKey ends in Key, so /api/option can return the real Stripe secret key to the root-admin browser.

Suggested fix:

  • Explicitly mark StripeSecretKey and StripeWebhookSecret as sensitive.
  • Never return secret values from a read API.
  • Return booleans such as stripe_secret_key_configured: true instead.
  • Treat an empty value from the settings UI as “keep the current secret”.
  • Rotate the key if it may already have been exposed.

Stripe secret keys must stay on the server: Stripe API keys.

2. Checkout can still be created when webhook settlement is not ready

The UI reports Stripe as enabled only when both secrets exist, but CreateStripeCheckout checks only StripeSecretKey. A direct API call can therefore create a real paid Checkout Session while the webhook secret is missing. The customer may pay, but quota cannot be credited.

Suggested fix:

Create one shared StripeReady() check and use it in both /api/status and CreateStripeCheckout. It should require:

  • Stripe secret key
  • Stripe webhook secret
  • A valid public HTTPS base URL in live mode

Also add a dedicated STRIPE_PUBLIC_BASE_URL. Do not build payment return URLs from the request Host or an untrusted X-Forwarded-Proto header.

3. The success and cancel URLs currently go to the 404 page

The backend redirects to /topup/success and /topup/cancel, but the React router only registers /topup. The TopUpPage code checks those paths, but the component is never mounted for them.

Suggested fix:

Use query parameters on the existing route, for example:

/topup?stripe=success&session_id={CHECKOUT_SESSION_ID}
/topup?stripe=cancel

Or register both child routes explicitly.

The success page should not trust the query string alone. It should call an authenticated order-status endpoint and show processing, credited, or failed based on server data. Stripe webhooks must remain the source of truth: Checkout fulfillment.

4. Settlement needs stronger database checks

The conditional pending -> paid update is a good start, but there are still two unsafe cases:

  • When RowsAffected == 0, the code assumes another worker completed payment. It should reload the order and check the real status.
  • The user quota update checks only Error, not RowsAffected. A missing or deleted user can result in paid without any quota being added.

Suggested fix:

  • Reload the order when the claim update affects zero rows.
  • Only treat the request as a duplicate when the latest status is really paid/credited.
  • Require the quota update to affect exactly one user row.
  • Separate provider payment status from internal fulfillment status, for example:
    • provider_status: paid
    • fulfillment_status: credited | manual_review
  • After the DB transaction commits, invalidate the user quota and user-object Redis caches.

The webhook should also verify the stored order against Stripe data: session ID, amount, currency, mode, live/test mode, and a stable local order ID in metadata.

5. Checkout creation is not idempotent

The code creates the Stripe Session first and saves the local order afterwards. A timeout, retry, or local database failure can create duplicate or orphaned Stripe Sessions.

Suggested fix:

  1. Create a local order first with a stable request_id/order UUID.
  2. Add a unique constraint such as (user_id, request_id).
  3. Use the order UUID as Stripe's Idempotency-Key.
  4. Save the Stripe Session ID and PaymentIntent ID into that order.
  5. Store processed Stripe event.id values with a unique constraint, because webhook events may be repeated or arrive out of order.

See Stripe idempotent requests and Stripe webhooks.

6. The payment history can show incorrect amounts

The UI reads generic top-up logs and converts quota to USD using the current QuotaPerUnit. This means:

  • Old payment amounts can change when the exchange rate changes.
  • Redemption codes and admin grants can be displayed as if they were USD payments.
  • The source type is guessed from free-text log content.

Also, the top-up log is written after the settlement transaction and its error is ignored, so a successful payment may be missing from history.

Suggested fix:

Add a payment-order history endpoint that returns the original immutable fields:

  • amount_cents
  • currency
  • provider status
  • fulfillment status
  • paid/refunded timestamps

Use structured source_type and source_id fields instead of parsing text. For stronger accounting, add an immutable quota ledger with a unique source ID. The payment order or ledger should be the source of truth; the generic log should only be a display/audit copy.

7. Important payment lifecycle events are missing

The code handles successful Checkout events only. It does not close or reconcile orders for:

  • checkout.session.expired
  • checkout.session.async_payment_failed
  • refunds
  • charge disputes
  • unknown or lost local orders

Unknown sessions currently become an error inside SettlePaidPaymentOrder, so the later order == nil branch cannot be reached and Stripe may keep retrying.

Suggested fix:

  • Add explicit states for expired, failed, refunded, disputed, and manual review.
  • Distinguish “order not found” from a database failure.
  • Save unknown valid Stripe events for manual review and return 2xx after safe persistence.
  • Process webhooks through a durable queue/outbox and return quickly.
  • Add a scheduled reconciliation job for old pending orders and undelivered events.
  • Put the Stripe webhook on a separate route group with a webhook-specific rate limit, rather than the normal user API limit.

8. Tests and CI need to cover the money path

The new tests only check a constant and the table name. They do not test payment safety. CI is also currently failing, including the modern frontend test job and the integration test workflow.

Please add at least these tests:

  • Many concurrent webhook calls credit quota exactly once.
  • A duplicate Stripe event is ignored safely.
  • Missing/deleted/disabled users enter a clear manual-review state.
  • A failed quota update rolls back the order transition.
  • Retrying Checkout creation creates only one order and one Session.
  • Missing webhook configuration blocks Checkout creation.
  • Fake Host/proxy headers cannot change return URLs.
  • Success and cancel return URLs render correctly.
  • Redis quota/user caches are refreshed after settlement.
  • Payment history always shows the original cents amount.
  • Expired, failed, refunded, and disputed events move to the correct state.
  • The behavior works on SQLite, MySQL, and PostgreSQL.

Recommended merge order

Please fix items 1–6 and make CI green before merging. Before enabling live Stripe keys, I also recommend completing the lifecycle/reconciliation work in item 7 and adding the money-path tests above.

I am happy with the general feature direction, but these changes are important to prevent secret leakage, duplicate charges/sessions, paid-without-credit cases, and incorrect payment history.

Address Laisky PR feedback for production-safe top-up:
- Hide Stripe secrets from OptionMap/GetOptions (Key/APIKey sensitive)
- StripeReady gate: secret + webhook + trusted public base URL
- STRIPE_PUBLIC_BASE_URL; no request Host for live return URLs
- Local order + request_id before Checkout; Stripe Idempotency-Key
- Success/cancel via /topup?stripe= query; order status poll API
- Settlement: reload on empty claim, require user RowsAffected,
  amount/currency check, event id dedupe, unknown session 2xx
- Payment order history with immutable amount_cents
- Unit tests for settle idempotency, amount mismatch, missing user
@paxaq

paxaq commented Jul 22, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review. Implemented the merge-blocker items in 482581ed (and production line b39694b9):

1. Secrets not returned to browser

  • isSensitiveOptionKey now covers StripeSecretKey, StripeWebhookSecret, *APIKey
  • InitOptionMap seeds those keys as empty strings (never from live secret values)

2. Checkout blocked unless settlement-ready

  • Shared StripeReady(): secret + webhook secret + trusted public base URL
  • Used by both /api/status (stripe_enabled) and CreateStripeCheckout

3. Success / cancel URLs

  • Return URLs are now /topup?stripe=success&session_id=… and /topup?stripe=cancel (existing route)
  • UI polls GET /api/user/topup/stripe/orders/:session_id for server fulfillment status

4. Stronger settlement

  • Empty claim reloads status instead of assuming paid
  • User quota update requires RowsAffected == 1 else manual_review
  • Verifies Stripe amount_total / currency against local order
  • Refreshes Redis user quota cache after credit

5. Checkout creation idempotency

  • Local order first with request_id (unique) + placeholder session
  • Stripe Idempotency-Key = request_id
  • Then bind real Checkout Session id

6. Payment history

  • GET /api/user/topup/stripe/orders returns immutable amount_cents / currency / status
  • Top-up UI displays cents, not reverse-engineered quota→USD

Also

  • Webhook event id dedupe (stripe_webhook_events)
  • Unknown session → 2xx (no infinite Stripe retry on missing local order)
  • expired / async_payment_failed mark terminal pending orders
  • STRIPE_PUBLIC_BASE_URL (live keys require https)
  • Unit tests: settle once, amount mismatch, missing user, event dedupe, StripeReady

CI / modern TopUp tests updated with MemoryRouter. Happy to iterate on lifecycle/refunds (item 7) as a follow-up if preferred.

@coderabbitai coderabbitai 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.

Caution

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

⚠️ Outside diff range comments (4)
web/modern/src/pages/topup/TopUpPage.tsx (1)

621-672: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Untranslated status text.

Unlike sourceLabel, entry.status (e.g. pending, paid, manual_review, failed) is rendered directly in both the mobile card view and the desktop table cell without going through tr(), bypassing i18n for this UI content.

As per coding guidelines, "All UI content must support internationalization."

♻️ Proposed fix
+  const statusLabel = (s: string): string => {
+    switch (s) {
+      case 'paid':
+        return tr('history.status.paid', 'Paid');
+      case 'pending':
+        return tr('history.status.pending', 'Pending');
+      case 'manual_review':
+        return tr('history.status.manual_review', 'Under review');
+      case 'failed':
+        return tr('history.status.failed', 'Failed');
+      default:
+        return s;
+    }
+  };

Then use statusLabel(entry.status) in place of the raw entry.status in both the mobile and desktop renderings.

🤖 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 `@web/modern/src/pages/topup/TopUpPage.tsx` around lines 621 - 672, Update the
history status rendering in the mobile cards and desktop table to use the
existing statusLabel helper instead of rendering entry.status directly. Ensure
both views pass each entry’s status through statusLabel while preserving the
surrounding layout and data-label behavior.

Source: Coding guidelines

controller/payment_stripe.go (2)

74-220: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Ignored error from GetUserEmail.

userEmail, _ := model.GetUserEmail(userID) at line 164 drops the error entirely — it's neither logged nor returned, so a DB failure here is invisible. As per coding guidelines, "**/*.{go,ts,tsx,js,jsx,py,java}: Handle errors as close to their source as possible; never ignore errors" and "Always return or log errors; do not silently ignore failures."

🩹 Proposed fix
-	userEmail, _ := model.GetUserEmail(userID)
+	userEmail, emailErr := model.GetUserEmail(userID)
+	if emailErr != nil {
+		logger.Warn("lookup user email for stripe receipt", zap.Error(emailErr), zap.Int("user_id", userID))
+	}
🤖 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 `@controller/payment_stripe.go` around lines 74 - 220, Handle the error
returned by model.GetUserEmail in CreateStripeCheckout instead of discarding it:
capture the error, log it with logger.Error, and return the existing failure
response before continuing when retrieval fails. Preserve the current trimming
and optional-email behavior for successful lookups.

Source: Coding guidelines


257-304: 🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

Event claimed before processing breaks Stripe retry semantics for transient failures.

model.ClaimStripeWebhookEvent inserts the dedup row before handleCheckoutPaid/handleCheckoutTerminal run. If the dispatched handler later fails (e.g. a transient DB error inside SettlePaidPaymentOrder, or an amount/currency mismatch), the handler returns a 5xx and Stripe retries with the same event id — but the retry hits !claimed and short-circuits to {"duplicate": true} with 200 OK, so settlement is never attempted again. The order is left pending forever even though Stripe has been paid, and Stripe stops retrying because it received a 200.

SettlePaidPaymentOrder already provides its own idempotency via the pending → paid status guard, so the event-level claim is not required to prevent double-crediting on legitimate concurrent duplicate deliveries — it only needs to prevent redundant side effects (audit log, cache refresh) on successful runs. Recommend claiming (or finalizing) the event only after the handler completes successfully, so a failed attempt can be retried by Stripe.

// Sketch: only persist the dedup row once processing has actually succeeded.
switch event.Type {
case "checkout.session.completed", "checkout.session.async_payment_succeeded":
    if !handleCheckoutPaid(c, event) {
        return // handler already wrote its own error response; event NOT claimed, safe to retry
    }
case "checkout.session.expired", "checkout.session.async_payment_failed":
    if !handleCheckoutTerminal(c, event) {
        return
    }
default:
}
if _, err := model.ClaimStripeWebhookEvent(ctx, event.ID, string(event.Type)); err != nil {
    logger.Warn("record processed stripe event", zap.Error(err), zap.String("event_id", event.ID))
}
c.JSON(http.StatusOK, gin.H{"received": true})

(Handlers would need to return a bool/error instead of writing their own terminal JSON in all paths, or the dispatch loop needs restructuring to know success vs failure before claiming.)

🤖 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 `@controller/payment_stripe.go` around lines 257 - 304, Move Stripe event
deduplication in StripeWebhook until after the dispatched handler completes
successfully, so failed handleCheckoutPaid and handleCheckoutTerminal attempts
leave the event unclaimed for retry. Update those handlers or the dispatch flow
to return/propagate success before recording the event with
ClaimStripeWebhookEvent; preserve duplicate short-circuiting for already
successfully processed events and keep existing error responses.
model/payment_order.go (1)

37-50: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

RequestID unique index is global, but lookups are scoped per-user.

RequestID carries a single-column uniqueIndex, yet it is client-supplied (createStripeCheckoutRequest.RequestID in controller/payment_stripe.go) and every lookup (GetPaymentOrderByRequestID) filters by user_id AND request_id. If two different users happen to submit the same request_id value, CreatePaymentOrder fails with a global unique-constraint violation for the second user, and the idempotent-retry fallback (GetPaymentOrderByRequestID(ctx, userID, requestID)) returns nil because the existing row belongs to a different user — so CreateStripeCheckout surfaces a generic failure to a legitimate user instead of creating their order. The uniqueness should be scoped to (user_id, request_id) rather than global.

🔧 Proposed fix: scope the unique index per user
 type PaymentOrder struct {
 	Id          int    `json:"id" gorm:"primaryKey"`
-	UserId      int    `json:"user_id" gorm:"index"`
+	UserId      int    `json:"user_id" gorm:"uniqueIndex:idx_payment_orders_user_request,priority:1"`
 	Provider    string `json:"provider" gorm:"type:varchar(32);index"`
-	RequestID   string `json:"request_id" gorm:"type:varchar(64);uniqueIndex"`
+	RequestID   string `json:"request_id" gorm:"type:varchar(64);uniqueIndex:idx_payment_orders_user_request,priority:2"`
 	SessionID   string `json:"session_id" gorm:"type:varchar(191);uniqueIndex"`
🤖 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 `@model/payment_order.go` around lines 37 - 50, Update the PaymentOrder model’s
RequestID uniqueness to be scoped per user: remove the standalone uniqueIndex
from RequestID and define a shared composite unique index on UserId and
RequestID. Preserve the existing GetPaymentOrderByRequestID user-scoped lookup
and ensure the ORM migration creates the composite constraint instead of the
global one.
🧹 Nitpick comments (5)
web/modern/src/pages/topup/TopUpPage.tsx (1)

463-483: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Add aria-pressed to preset chips.

Active state is only conveyed visually via className; screen-reader users get no indication which preset (if any) is currently selected.

♻️ Proposed fix
                           <button
                             type="button"
                             key={amount}
                             onClick={() => setPreset(amount)}
                             data-label={`$${amount}`}
+                            aria-pressed={active}
                             className={cn(
🤖 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 `@web/modern/src/pages/topup/TopUpPage.tsx` around lines 463 - 483, Add an
aria-pressed attribute to each preset button rendered in the presets.map
callback, using the existing active value so screen readers receive the same
selected-state information conveyed by the className.
web/modern/src/pages/topup/__tests__/TopUpPage.amount.test.tsx (1)

40-100: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Amount-specific coverage was dropped from TopUpPage.amount.test.tsx.

The file retains its "amount" name, but the two remaining tests only check router-mount safety and the stripe=cancel banner. No test exercises amount_usd min/max validation, preset-chip selection, or the Checkout redirect from onStripeSubmit — previously the presumed purpose of this suite. Given the PR's stated goal of improving payment-safety test coverage, consider restoring amount-boundary and submit-flow tests using the mocks already scaffolded here.

🤖 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 `@web/modern/src/pages/topup/__tests__/TopUpPage.amount.test.tsx` around lines
40 - 100, Restore amount-specific coverage in the TopUpPage test suite: add
tests for amount_usd minimum and maximum validation, preset-chip selection, and
the Checkout redirect triggered by onStripeSubmit. Reuse the existing beforeEach
API/localStorage mocks and renderPage helper, while preserving the current
router-safety and stripe=cancel tests.
model/payment_order.go (2)

220-309: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Amount/currency mismatch leaves the order permanently pending with no operator visibility.

When expectedAmountCents/expectedCurrency don't match, the transaction returns a bare error and the order stays pending forever (the caller's webhook event is already claimed by the time this runs — see the related webhook-ordering issue in controller/payment_stripe.go). Consider transitioning to PaymentStatusManualReview on validation mismatch, mirroring the existing missing-user handling a few lines below, so operators have a discoverable, terminal state to reconcile instead of a silently stuck pending row.

🤖 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 `@model/payment_order.go` around lines 220 - 309, The validation mismatch
branches in SettlePaidPaymentOrder currently return errors without changing the
order state; update the amount and currency checks to persist
PaymentStatusManualReview with the settlement timestamp before returning the
mismatch error. Mirror the existing missing-user manual-review update and keep
the transaction/error context intact so the order becomes discoverable and
terminal.

198-208: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use GORM’s duplicate-key sentinel here
gorm.ErrDuplicatedKey is available in gorm.io/gorm v1.31.2, but it only applies when TranslateError: true is set on the DB openers; otherwise this helper still depends on driver-specific error strings. Replace the substring check with errors.Is(err, gorm.ErrDuplicatedKey) and enable translation in the affected DB init paths.

🤖 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 `@model/payment_order.go` around lines 198 - 208, Update isDuplicateKeyError to
return errors.Is(err, gorm.ErrDuplicatedKey), retaining the nil guard and
removing driver-specific string matching. Enable GORM TranslateError in all
affected database opener/configuration paths so duplicate violations are
converted to gorm.ErrDuplicatedKey.
model/payment_order_test.go (1)

35-68: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a true concurrent test for the optimistic settlement claim.

This test calls SettlePaidPaymentOrder twice sequentially, which validates idempotency but not the actual concurrent race the Where("status = ?", PaymentStatusPending) guard is meant to protect against. A test that fires the same session through multiple goroutines and asserts exactly one credits quota would directly validate the fix for the earlier race-condition finding.

func TestSettlePaidPaymentOrderConcurrent(t *testing.T) {
	setupPaymentTestDB(t)
	require.NoError(t, DB.Create(&User{Id: 3, Username: "u3", Password: "x", DisplayName: "p", Role: 1, Status: 1, Quota: 0}).Error)
	require.NoError(t, DB.Create(&PaymentOrder{
		UserId: 3, Provider: PaymentProviderStripe, RequestID: "req-3", SessionID: "cs_3",
		AmountCents: 500, Currency: "usd", Quota: 50, Status: PaymentStatusPending,
	}).Error)

	var wg sync.WaitGroup
	transitions := make([]bool, 10)
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			tr, _, err := SettlePaidPaymentOrder(context.Background(), "cs_3", time.Now().UnixMilli(), 500, "usd")
			require.NoError(t, err)
			transitions[i] = tr
		}(i)
	}
	wg.Wait()

	count := 0
	for _, tr := range transitions {
		if tr {
			count++
		}
	}
	require.Equal(t, 1, count)

	var u User
	require.NoError(t, DB.First(&u, 3).Error)
	require.Equal(t, int64(50), u.Quota)
}
🤖 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 `@model/payment_order_test.go` around lines 35 - 68, Add a true concurrent
settlement test alongside TestSettlePaidPaymentOrderIdempotent, using multiple
goroutines and a shared WaitGroup to call SettlePaidPaymentOrder for the same
session. Record each transition result, assert exactly one call transitions the
order, and verify the user quota is credited only once; avoid calling testing
assertions unsafely from worker goroutines by reporting errors back to the test
goroutine.
🤖 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.

Outside diff comments:
In `@controller/payment_stripe.go`:
- Around line 74-220: Handle the error returned by model.GetUserEmail in
CreateStripeCheckout instead of discarding it: capture the error, log it with
logger.Error, and return the existing failure response before continuing when
retrieval fails. Preserve the current trimming and optional-email behavior for
successful lookups.
- Around line 257-304: Move Stripe event deduplication in StripeWebhook until
after the dispatched handler completes successfully, so failed
handleCheckoutPaid and handleCheckoutTerminal attempts leave the event unclaimed
for retry. Update those handlers or the dispatch flow to return/propagate
success before recording the event with ClaimStripeWebhookEvent; preserve
duplicate short-circuiting for already successfully processed events and keep
existing error responses.

In `@model/payment_order.go`:
- Around line 37-50: Update the PaymentOrder model’s RequestID uniqueness to be
scoped per user: remove the standalone uniqueIndex from RequestID and define a
shared composite unique index on UserId and RequestID. Preserve the existing
GetPaymentOrderByRequestID user-scoped lookup and ensure the ORM migration
creates the composite constraint instead of the global one.

In `@web/modern/src/pages/topup/TopUpPage.tsx`:
- Around line 621-672: Update the history status rendering in the mobile cards
and desktop table to use the existing statusLabel helper instead of rendering
entry.status directly. Ensure both views pass each entry’s status through
statusLabel while preserving the surrounding layout and data-label behavior.

---

Nitpick comments:
In `@model/payment_order_test.go`:
- Around line 35-68: Add a true concurrent settlement test alongside
TestSettlePaidPaymentOrderIdempotent, using multiple goroutines and a shared
WaitGroup to call SettlePaidPaymentOrder for the same session. Record each
transition result, assert exactly one call transitions the order, and verify the
user quota is credited only once; avoid calling testing assertions unsafely from
worker goroutines by reporting errors back to the test goroutine.

In `@model/payment_order.go`:
- Around line 220-309: The validation mismatch branches in
SettlePaidPaymentOrder currently return errors without changing the order state;
update the amount and currency checks to persist PaymentStatusManualReview with
the settlement timestamp before returning the mismatch error. Mirror the
existing missing-user manual-review update and keep the transaction/error
context intact so the order becomes discoverable and terminal.
- Around line 198-208: Update isDuplicateKeyError to return errors.Is(err,
gorm.ErrDuplicatedKey), retaining the nil guard and removing driver-specific
string matching. Enable GORM TranslateError in all affected database
opener/configuration paths so duplicate violations are converted to
gorm.ErrDuplicatedKey.

In `@web/modern/src/pages/topup/__tests__/TopUpPage.amount.test.tsx`:
- Around line 40-100: Restore amount-specific coverage in the TopUpPage test
suite: add tests for amount_usd minimum and maximum validation, preset-chip
selection, and the Checkout redirect triggered by onStripeSubmit. Reuse the
existing beforeEach API/localStorage mocks and renderPage helper, while
preserving the current router-safety and stripe=cancel tests.

In `@web/modern/src/pages/topup/TopUpPage.tsx`:
- Around line 463-483: Add an aria-pressed attribute to each preset button
rendered in the presets.map callback, using the existing active value so screen
readers receive the same selected-state information conveyed by the className.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a008faab-ac6a-4c4e-b6cd-27bd74a78e81

📥 Commits

Reviewing files that changed from the base of the PR and between 071caa6 and 482581e.

📒 Files selected for processing (12)
  • common/config/config.go
  • controller/misc.go
  • controller/option.go
  • controller/payment_stripe.go
  • controller/payment_stripe_ready_test.go
  • model/main.go
  • model/option.go
  • model/payment_order.go
  • model/payment_order_test.go
  • router/api.go
  • web/modern/src/pages/topup/TopUpPage.tsx
  • web/modern/src/pages/topup/__tests__/TopUpPage.amount.test.tsx
🚧 Files skipped from review as they are similar to previous changes (4)
  • controller/misc.go
  • model/main.go
  • common/config/config.go
  • model/option.go

paxaq added 2 commits July 21, 2026 22:44
… user

- Record webhook event ids only after handlers finish successfully so 5xx can retry
- Short-circuit already-processed events via HasStripeWebhookEvent
- Composite unique index on (user_id, request_id)
- Amount/currency mismatch → manual_review without crediting
- Log GetUserEmail failures; i18n status labels; aria-pressed presets
- Concurrent settle test
Resolve conflicts keeping both upstream Resend email support and Stripe
checkout hardening (sensitive options, StripeReady, payment orders).
@paxaq

paxaq commented Jul 22, 2026

Copy link
Copy Markdown
Author

Follow-up in 60910845 / merge 2121b382 (rebased onto current main via merge; PR should be mergeable again):

Critical: webhook claim order

  • Process the event first; claim event.id only after a successful 2xx path
  • 5xx leaves the event unclaimed so Stripe can retry
  • Already-processed events short-circuit via HasStripeWebhookEvent
  • Settlement remains idempotent via pending → paid

Major: request_id uniqueness

  • Composite unique index (user_id, request_id) instead of global unique

Other review nits

  • Amount/currency mismatch → manual_review (no credit)
  • Log GetUserEmail errors (non-fatal)
  • i18n for payment status labels; aria-pressed on amount presets
  • Concurrent settle unit test (with SQLite busy retry)

Happy to iterate further if anything else is needed for merge.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🧹 Nitpick comments (2)
controller/option.go (1)

76-77: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use the request-scoped logger.

logger.Logger drops request context and correlation fields. Initialize log := gmw.GetLogger(c) once in UpdateOption, then call log.Debug(...) here.

As per coding guidelines, “In request paths, use gmw.GetLogger(c) instead of a global logger, call it once per function, and store the result locally.”

🤖 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 `@controller/option.go` around lines 76 - 77, Update UpdateOption to initialize
a request-scoped logger once via gmw.GetLogger(c), store it as log, and replace
logger.Logger.Debug in the sensitive-option branch with log.Debug while
preserving the existing message and fields.

Source: Coding guidelines

web/modern/src/pages/topup/TopUpPage.tsx (1)

304-316: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Using codeForm.setError('root', { type: 'success', ... }) to render a success banner overloads the form-error channel for a non-error state, and reset() immediately preceding it can race with the error write. Consider a dedicated redeemSuccess state string instead; low priority.

🤖 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 `@web/modern/src/pages/topup/TopUpPage.tsx` around lines 304 - 316, Replace the
success-path use of codeForm.setError in the redemption flow with a dedicated
redeemSuccess state string, setting it after codeForm.reset and rendering the
success banner from that state. Keep the existing error-channel handling for
failed redemptions and clear redeemSuccess when starting or resetting a new
redemption.
🤖 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 `@model/payment_order_test.go`:
- Around line 150-160: Update the concurrent settlement test loop around
SettlePaidPaymentOrder to record when all 20 attempts fail, rather than
returning silently. Track exhausted retry loops with a synchronized failure
counter or equivalent, then after wg.Wait() assert that the counter is zero
while preserving the existing transition counting.

In `@model/payment_order.go`:
- Around line 266-287: In the amount-mismatch and currency-mismatch branches,
guard each manual_review update in the payment-order transaction with
Where("status = ?", PaymentStatusPending), then inspect RowsAffected. If no
pending row was claimed, reload the order’s current terminal status and return
that result instead of overwriting it; preserve the existing error handling and
in-memory updates only for successfully claimed rows.

In `@web/modern/src/pages/topup/TopUpPage.tsx`:
- Line 50: Update the TopUpPage test renders to wrap the component with
MemoryRouter or the project’s shared test router helper, ensuring
useSearchParams() has the required Router context while preserving the existing
mount and redeem test behavior.

---

Nitpick comments:
In `@controller/option.go`:
- Around line 76-77: Update UpdateOption to initialize a request-scoped logger
once via gmw.GetLogger(c), store it as log, and replace logger.Logger.Debug in
the sensitive-option branch with log.Debug while preserving the existing message
and fields.

In `@web/modern/src/pages/topup/TopUpPage.tsx`:
- Around line 304-316: Replace the success-path use of codeForm.setError in the
redemption flow with a dedicated redeemSuccess state string, setting it after
codeForm.reset and rendering the success banner from that state. Keep the
existing error-channel handling for failed redemptions and clear redeemSuccess
when starting or resetting a new redemption.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 877e2607-a947-4f53-806f-102a933baa29

📥 Commits

Reviewing files that changed from the base of the PR and between 482581e and 2121b38.

📒 Files selected for processing (8)
  • common/config/config.go
  • controller/misc.go
  • controller/option.go
  • controller/payment_stripe.go
  • model/option.go
  • model/payment_order.go
  • model/payment_order_test.go
  • web/modern/src/pages/topup/TopUpPage.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • model/option.go
  • controller/misc.go
  • controller/payment_stripe.go

Comment thread model/payment_order_test.go
Comment thread model/payment_order.go Outdated
Comment thread web/modern/src/pages/topup/TopUpPage.tsx
- Wrap TopUpPage tests in MemoryRouter for useSearchParams
- Guard amount/currency manual_review with pending status claim
- Fail concurrent settle test when retries are exhausted
@paxaq

paxaq commented Jul 22, 2026

Copy link
Copy Markdown
Author

Follow-up 1e6521e8 for the three open CodeRabbit items + modern CI:

  1. TopUpPage.test.tsx: wrapped in MemoryRouter (fixes useSearchParams / modern frontend CI)
  2. manual_review: amount/currency mismatch only transitions when still pending (markPendingManualReview + RowsAffected/reload)
  3. Concurrent settle test: asserts no worker exhausts retries (exhausted counter)

Also mocked Stripe order APIs in the older TopUp tests so mount/redeem stay stable.

@coderabbitai coderabbitai 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.

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 `@web/modern/src/pages/topup/__tests__/TopUpPage.amount.test.tsx`:
- Around line 90-107: Expand the TopUpPage amount tests beyond generic
balance/token text: restore cent-precision and minimum-amount boundary cases,
verify checkout initiation sends the expected request and redirect, and update
the successful-return test to wait for the mocked paid response before asserting
the specific refreshed balance or settlement status. Keep the Router and
cancellation coverage intact while ensuring assertions cannot pass from initial
page content.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: 282691b1-d2a0-41f7-982e-759183df60cb

📥 Commits

Reviewing files that changed from the base of the PR and between 2121b38 and 1e6521e.

📒 Files selected for processing (4)
  • model/payment_order.go
  • model/payment_order_test.go
  • web/modern/src/pages/topup/TopUpPage.test.tsx
  • web/modern/src/pages/topup/__tests__/TopUpPage.amount.test.tsx
🚧 Files skipped from review as they are similar to previous changes (2)
  • model/payment_order_test.go
  • model/payment_order.go

Comment on lines +90 to +107
it('renders inside a Router without useSearchParams crashes', async () => {
renderPage();

// Wait for the user payload to settle so userData is populated
await waitFor(() => expect(api.get).toHaveBeenCalled());

// Find and update amount
const amountInput = await screen.findByLabelText(/^amount$/i);
fireEvent.change(amountInput, { target: { value: '5' } });

const codeInput = screen.getByLabelText(/top-up code/i);
fireEvent.change(codeInput, { target: { value: 'PROMO5' } });

const calcBtn = screen.getByRole('button', { name: /^calculate$/i });
fireEvent.click(calcBtn);

await waitFor(() => {
expect(api.post).toHaveBeenCalledWith('/api/user/amount', {
amount: 5,
top_up_code: 'PROMO5',
});
});

// The result is shown via the data-testid
await screen.findByTestId('topup-amount-result');

// Spy on window.location.href assignment
const originalLocation = window.location;
const hrefSpy = vi.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: new Proxy(originalLocation, {
set(_target, prop, value) {
if (prop === 'href') {
hrefSpy(value);
return true;
}
(originalLocation as any)[prop as any] = value;
return true;
},
get(target, prop) {
return (target as any)[prop as any];
},
}) as any,
});

const rechargeBtn = screen.getByRole('button', { name: /^recharge$/i });
fireEvent.click(rechargeBtn);

await waitFor(() => expect(hrefSpy).toHaveBeenCalledTimes(1));
const navigatedTo: string = hrefSpy.mock.calls[0][0];
expect(navigatedTo).toContain('https://pay.example.com/checkout');
expect(navigatedTo).toContain('username=amountuser');
expect(navigatedTo).toContain('user_id=42');
expect(navigatedTo).toContain('transaction_id=');

// Restore window.location
Object.defineProperty(window, 'location', {
configurable: true,
value: originalLocation,
});
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/api/user/self'));
expect(document.body.textContent || '').toMatch(/balance|token/i);
});

it('shows error notification when backend returns failure with data field', async () => {
(api.post as any).mockResolvedValue({ data: { success: false, data: 'Invalid promo code' } });

renderPage();

await waitFor(() => expect(api.get).toHaveBeenCalled());

const amountInput = await screen.findByLabelText(/^amount$/i);
fireEvent.change(amountInput, { target: { value: '10' } });

const calcBtn = screen.getByRole('button', { name: /^calculate$/i });
fireEvent.click(calcBtn);

await waitFor(() => expect(api.post).toHaveBeenCalled());
it('shows cancel outcome from query string', async () => {
renderPage('/topup?stripe=cancel');
await waitFor(() => expect(api.get).toHaveBeenCalledWith('/api/user/self'));
expect(document.body.textContent || '').toMatch(/cancel|not been charged/i);
});

// Recharge should remain disabled because no amount was computed
const rechargeBtn = screen.getByRole('button', { name: /^recharge$/i });
expect(rechargeBtn).toBeDisabled();
it('polls order status after success return', async () => {
renderPage('/topup?stripe=success&session_id=cs_test');
await waitFor(() =>
expect(api.get).toHaveBeenCalledWith(expect.stringContaining('/topup/stripe/orders/cs_test'))
);
expect(document.body.textContent || '').toMatch(/balance|token|credit/i);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Restore payment-safety assertions.

The paid-order assertion can pass before the mocked paid response affects the UI, since the initial page already contains balance/token text. These replacements also remove amount/minimum-boundary and checkout initiation coverage. Re-add cent-precision and minimum-amount cases, assert the Checkout request/redirect, and verify a paid return produces the specific refreshed balance or settlement status.

🤖 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 `@web/modern/src/pages/topup/__tests__/TopUpPage.amount.test.tsx` around lines
90 - 107, Expand the TopUpPage amount tests beyond generic balance/token text:
restore cent-precision and minimum-amount boundary cases, verify checkout
initiation sends the expected request and redirect, and update the
successful-return test to wait for the mocked paid response before asserting the
specific refreshed balance or settlement status. Keep the Router and
cancellation coverage intact while ensuring assertions cannot pass from initial
page content.

@Laisky Laisky left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Maintainer review — Request changes

Reviewed the final patch at 1e6521e8c938d2df371dbad7bff760fe548767e9, including the checkout controller, payment models and migrations, runtime options, routing/middleware, top-up UI, tests, existing review threads, and current CI results.

The direction is good: the patch uses a dedicated Stripe client, verifies webhook signatures, caps the webhook body, stores immutable cents/quota on the order, scopes order reads to the authenticated user, and credits quota in the same database transaction as pending -> paid. Those are important improvements.

However, this is a money-moving path and the final head still has several correctness issues that can cause a successful charge to be credited to the wrong user or not credited at all. I do not consider the patch production-safe yet.

Overall score: 4.8/10 — Request changes.

P0 — A successful Stripe charge can be acknowledged without any quota credit

CreateStripeCheckout creates the remote Checkout Session and then treats BindPaymentOrderSession as best effort. If the database bind fails, the endpoint still returns the Checkout URL. Later, handleCheckoutPaid looks up only by session_id; when that lookup fails it returns HTTP 200 with unknown_session=true, which tells Stripe that delivery succeeded and stops automatic retries.

This creates a concrete paid-but-uncredited failure mode:

  1. Local placeholder order is created.
  2. Stripe Session creation succeeds.
  3. The local session_id bind fails or the process loses the response before the bind commits.
  4. The customer completes payment.
  5. The webhook cannot find the session locally and is acknowledged with 200.
  6. The customer is charged, quota is not credited, and no automatic retry repairs it.

Required changes:

  • Do not return a usable Checkout URL unless the local order-to-session association is durable.
  • Make the webhook recover the association from trusted Session metadata (order_id or, preferably, an externally safe order UUID), then verify provider, user, amount, currency, and request identity before settlement.
  • If recovery cannot be completed because of a transient storage failure, return 5xx so Stripe retries. Return 2xx for an unknown paid Session only after an orphan/manual-review record has been durably stored and alerted.
  • Add an integration test covering: Stripe Session succeeds -> local bind fails -> paid webhook is replayed -> exactly one quota credit occurs.

P0 — The Stripe idempotency key is not tenant-scoped and is directly controlled by the caller

The local uniqueness rule is (user_id, request_id), but params.SetIdempotencyKey(requestID) sends the raw caller-provided value to Stripe, where the key is scoped to the Stripe account rather than this application's user row.

Two users can submit the same request_id and the same amount. Stripe can return the same Checkout Session to both requests. The second local bind then fails on the unique session_id, but the controller currently ignores that bind error and returns the shared Checkout URL anyway. If the second user pays, the webhook settles the first user's order.

There are related retry problems:

  • Reusing an existing local order does not verify that amount, currency, quota, provider, and status match the original request.
  • Stripe parameters are built from the new request rather than the persisted order.
  • The modern UI does not send request_id, so browser/network retries create new orders and Sessions instead of exercising the advertised idempotency path.
  • A bound order still calls CheckoutSessions.New again and assumes Stripe will retain the old response indefinitely.

Required changes:

  • Generate and persist a provider idempotency key server-side, namespaced by provider and the immutable local order identity. Never pass an arbitrary client value directly to Stripe.
  • Scope database uniqueness by provider, for example (provider, user_id, request_id) and (provider, session_id).
  • On retry, compare the complete immutable request tuple. Return a conflict for the same request ID with different amount/currency/quota.
  • Build all Stripe parameters from the persisted order, not from the retry payload.
  • Bind with compare-and-swap semantics against the expected placeholder, not only id AND status=pending, so an already-bound Session cannot be overwritten.
  • Have the UI generate one request ID per payment intent and reuse it across transport retries.
  • Add cross-user collision, same-key/different-amount, concurrent creation, and already-bound retry tests.

P0 — The payment state machine depends on webhook delivery order

checkout.session.expired and checkout.session.async_payment_failed move a pending order to canceled or failed. SettlePaidPaymentOrder only claims pending -> paid. If a terminal event is delivered first and a genuine paid event is delivered later, settlement returns payment order not claimable, the handler returns 500, and the paid event can retry forever without crediting the account.

Stripe explicitly does not guarantee webhook delivery order, so event order cannot define the authoritative payment state.

Required changes:

  • Define an explicit transition table and make verified provider-paid state authoritative.
  • For a paid event received after a local terminal state, retrieve/verify the current Checkout Session and either transition safely to paid or persist a manual-review case; do not enter a permanent 500 loop.
  • Store reconciliation evidence: Stripe event ID, observed amount/currency/payment status, failure/review reason, and preferably PaymentIntent ID.
  • Add tests for terminal-before-paid, paid-before-terminal, duplicate events with different event IDs, and concurrent mixed event types.

P1 — Configuration precedence and validation are unsafe

STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, and MIN_TOPUP_USD are read from the environment, but Stripe keys are absent from envOverrideForOption. Persisted database options can therefore silently replace deployment-managed environment secrets during startup or periodic option synchronization. This is inconsistent with the existing Resend environment precedence and can switch accounts, disable settlement, or use a stale webhook secret.

MinTopUpUSD also ignores parse errors and clamps invalid persisted values to 1, which can silently lower the configured minimum.

Required changes:

  • Define and test one precedence rule. For deployment secrets, non-empty environment values should remain authoritative during startup, admin updates, and periodic synchronization.
  • Prefer a secret manager/environment-only model for Stripe live credentials. If database configuration remains supported, encrypt secrets at rest and expose only configured/not-configured status.
  • Strictly parse and range-check MinTopUpUSD before persistence; reject invalid values instead of coercing them.
  • Add startup, runtime update, clear, and periodic-sync tests for environment-vs-database precedence.

P1 — Money input should not use binary floating point

The public API accepts amount_usd as float64 and rounds with int64(amount * 100 + 0.5). It also accepts more than two decimal places even though the UI shows a cent step. This makes the charged amount depend on binary-float rounding and silently changes values such as fractional-cent input.

Required changes:

  • Accept integer amount_cents, or accept a decimal string and parse it with an exact money parser.
  • Reject more than two decimal places; validate min/max in cents.
  • Use checked integer arithmetic for quota calculation and define whether QuotaPerUnit must be integral. Do not silently truncate a fractional quota rate.
  • Require amount_total and currency in paid webhook processing; missing values must not disable validation. Prefer retrieving the Session as Stripe recommends for fulfillment.

P1 — The current test suite and CI do not establish payment safety

The model tests cover basic idempotent settlement, amount mismatch, missing user, event-ID dedupe, and concurrent paid settlement, which is useful. They do not cover the cross-system failure boundaries above. The frontend Stripe tests only verify mounting, cancel text, and that polling was invoked; they do not assert cent precision, minimum boundaries, the checkout request, stable idempotency identity, redirect URL, successful paid rendering, or refreshed balance/history.

At this head, the PR checks are also not fully green: the Run Tests job is failing, and the coverage-report job is failing. The existing unresolved review thread about weakened Stripe UI coverage remains valid.

Required acceptance tests:

  • Session-create success plus local-bind failure and recovery.
  • Unknown paid Session with recoverable metadata and with unrecoverable metadata.
  • Cross-user identical request IDs.
  • Same request ID with changed amount.
  • Concurrent checkout creation and concurrent mixed webhook events.
  • Terminal-before-paid event delivery.
  • Missing/invalid amount and currency fields.
  • Environment/DB option precedence and explicit secret clear behavior.
  • Frontend exact-cent/minimum validation, POST payload, stable request ID, redirect, paid state, and balance/history refresh.
  • At least one real Stripe test-mode or Stripe-mock end-to-end path, plus database-dialect coverage for supported production databases.

Additional maintainability and operational changes

  • Move checkout orchestration and settlement out of the Gin controller into an injectable payment service/gateway. The current controller is difficult to fault-inject and TopUpPage.tsx is too large; split data hooks and presentation components.
  • Return a dedicated payment-order DTO instead of the raw database model, and do not expose internal user_id/database IDs unnecessarily.
  • Do not return raw Stripe or database error strings to clients; log structured provider details and return stable public errors.
  • Parse the public base URL with net/url; require a valid host, reject userinfo/query/fragment where inappropriate, and permit HTTP only for explicit localhost development.
  • Exempt the signed webhook from the generic per-IP API limiter or give it a dedicated limiter/queue. Add metrics and alerts for unknown Sessions, manual review, settlement failures, and webhook lag.
  • Add a lifecycle/retention policy for abandoned placeholder orders and processed webhook-event rows.
  • Decide and implement refund/dispute behavior before calling this production-ready; at minimum store PaymentIntent/Charge identifiers and create an operator reconciliation path.
  • Run gofmt on router/api.go; the new webhook route is visibly mis-indented.

Acceptance checklist

  • No successful paid Session can be lost between Stripe and the local order store.
  • Provider idempotency keys are server-generated, tenant-safe, persisted, and payload-consistent.
  • Paid fulfillment is correct under duplicate, concurrent, delayed, and out-of-order events.
  • Environment-managed secrets cannot be overwritten by stale database options.
  • Money is represented and validated in exact integer cents.
  • Unknown/manual-review payments are durable, observable, and reconcilable.
  • Frontend and backend tests cover the failure matrix above.
  • All required CI checks pass.

Once these items are addressed, the existing transactional quota update and signed-webhook foundation should be a solid base for the feature.

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.

2 participants