Skip to content

fix: changed generation of multiple checkout link to one, so that the user doesnt accidently pays multiple times - #48

Closed
0xanshu wants to merge 1 commit into
ScrawnDotDev:mainfrom
0xanshu:fix/multiple-checkout-links
Closed

fix: changed generation of multiple checkout link to one, so that the user doesnt accidently pays multiple times#48
0xanshu wants to merge 1 commit into
ScrawnDotDev:mainfrom
0xanshu:fix/multiple-checkout-links

Conversation

@0xanshu

@0xanshu 0xanshu commented May 20, 2026

Copy link
Copy Markdown
Contributor

This pull request introduces a new checkout_sessions table to manage user checkout flows, enforces idempotency in event insertions, and improves error handling for usage events. It also includes minor dependency and formatting updates. The most important changes are summarized below:

Checkout Session Management:

  • Added a new checkout_sessions table with a unique index to ensure only one active (not completed) session per user. This is reflected in both the SQL migration (drizzle/0001_add_checkout_sessions.sql) and the Drizzle schema (checkoutSessionsTable in src/storage/db/postgres/schema.ts). [1] [2]
  • Updated the gRPC createCheckoutLink handler to check for an existing active session within 24 hours and reuse it, or create/update one atomically, ensuring only one active session per user. [1] [2]
  • Updated the HTTP webhook handler to mark a user's checkout session as completed after successful payment processing.

Summary by CodeRabbit

  • New Features

    • Checkout links are now reused within 24 hours and only one active checkout is allowed per user.
    • Checkout sessions are persisted and marked completed on successful payment.
  • Bug Fixes

    • Improved error reporting to include underlying causes.
    • Strengthened idempotency for usage tracking to prevent duplicate records.
  • Chores

    • Added a development dependency for improved logging.

Review Change Stack

@0xanshu

0xanshu commented May 20, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
✅ Actions performed

Full review triggered.

@coderabbitai

coderabbitai Bot commented May 20, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces a checkout session management feature that reuses existing sessions within 24 hours to avoid repeated link generation, tracks session completion through payment webhooks, makes storage handlers idempotent for resilience, and improves error context propagation.

Changes

Checkout Session Management

Layer / File(s) Summary
Checkout Sessions Database Schema
drizzle/0001_add_checkout_sessions.sql, src/storage/db/postgres/schema.ts
Adds checkout_sessions table with user FK, link, timestamps, and completion flag, plus partial unique index enforcing at most one active session per user.
Checkout Link Generation with Session Reuse
src/routes/gRPC/payment/createCheckoutLink.ts
gRPC endpoint now queries for existing unfinished 24-hour sessions and returns cached links; otherwise generates and upserts new link with ON CONFLICT refresh logic.
Payment Webhook Session Completion
src/routes/http/createdCheckout.ts
Webhook transaction now marks checkout sessions as completed (is_completed = true) when payment is processed, alongside existing billed timestamp and payment recording updates.
Idempotent Storage Handlers
src/storage/adapter/postgres/handlers/addAiTokenUsage.ts, src/storage/adapter/postgres/handlers/addBasicUsage.ts
Both handlers switch to onConflictDoNothing upsert keyed by idempotencyKey; derive result id from inserted row or fallback select by idempotency key when conflicts occur.
Error Context and Dependencies
src/errors/storage.ts, package.json
StorageError now propagates underlying error as cause field; adds pino-pretty to dev dependencies; updates script manifest formatting.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant createCheckoutLink as gRPC Endpoint
  participant db as Postgres DB
  
  Client->>createCheckoutLink: Request checkout link
  createCheckoutLink->>db: Query existing session (24h, is_completed=false)
  alt Found active session
    db-->>createCheckoutLink: Return existing link
    createCheckoutLink-->>Client: Return cached link
  else No active session
    createCheckoutLink->>createCheckoutLink: Generate link & proxyUrl
    createCheckoutLink->>db: Upsert checkout_sessions (ON CONFLICT)
    db-->>createCheckoutLink: Confirm
    createCheckoutLink-->>Client: Return new link
  end
Loading
sequenceDiagram
  participant Webhook as Payment Webhook
  participant Transaction
  participant db as Postgres DB
  
  Webhook->>Transaction: Begin transaction
  Transaction->>db: Update checkout_sessions.is_completed = true (WHERE user_id=? AND is_completed=false)
  db-->>Transaction: Confirm
  Transaction->>db: Update user billed_at
  db-->>Transaction: Confirm
  Transaction->>db: Record payment via handleAddPayment
  db-->>Transaction: Confirm
  Transaction-->>Webhook: Commit
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • ScrawnDotDev/Scrawn#45: Related idempotency changes to Postgres handlers (addAiTokenUsage and addBasicUsage) that also use idempotency_key and conflict-safe inserts.

Poem

🐰 Sessions persist through moonlit nights,
Reusing links for twenty-four delights,
When payments come, they mark the end—
No duplicate requests to offend!
Idempotent handlers stand so strong,
Error causes flow along.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly addresses the main objective: preventing multiple checkout links by implementing session management to ensure users only have one active checkout at a time.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


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

@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: 4

🤖 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/routes/gRPC/payment/createCheckoutLink.ts`:
- Line 76: The TTL math mixes DB now() and app time; in createCheckoutLink.ts
update the upsert/update path to set checkoutSessionsTable.created_at using the
database current time (DB now()) instead of DateTime.utc() so both reads and
writes use the same clock; locate the upsert/update that writes created_at
(references to checkoutSessionsTable and createdAt/created_at in the
createCheckoutLink function) and replace the app-side DateTime.utc() assignment
with a DB-side now()/CURRENT_TIMESTAMP expression so the 24-hour comparison
(sql`${checkoutSessionsTable.createdAt} > now() - interval '24 hours'`) is
consistent.
- Around line 69-85: The current existence check using checkoutSessionsTable and
validatedData.userId happens outside the provider call and can race; wrap the
flow in a Drizzle transaction and acquire a user-scoped advisory lock (or
otherwise serialize by userId) before creating the provider session: inside the
transaction/lock re-run the same select on checkoutSessionsTable for
validatedData.userId to short-circuit if a payable session now exists, otherwise
either insert a reserved row (status=reserved) to claim the slot before calling
the external provider or create the provider session then upsert within the same
transaction; additionally catch and handle unique constraint violations on the
checkoutSessionsTable upsert to return the existing link via
CreateCheckoutLinkResponse and callback when conflicts occur.

In `@src/storage/adapter/postgres/handlers/addAiTokenUsage.ts`:
- Around line 160-201: The batch INSERT uses onConflictDoNothing which can
silently drop rows that share idempotencyKey across different models; update
addAiTokenUsage handler to explicitly detect and handle unique-constraint
conflicts by (1) replacing the blind success check on inserted (variables:
inserted, aiTokenUsageValues, aiTokenUsageEventsTable, txn) with a verification
that inserted.length === aiTokenUsageValues.length, (2) if lengths differ, query
the DB for all idempotency keys in the batch (use txn.select(...)
.where(in(...)) on aiTokenUsageEventsTable.idempotencyKey) to reconcile which
rows already exist, and (3) either perform an upsert (onConflictDoUpdate) keyed
on the correct unique constraint that includes model/user as needed or return a
clear StorageError listing the missing/duplicated idempotencyKeys; ensure you
reference buildAiTokenInsertValues output to map returned/existing rows back to
the original events instead of only checking the first idempotencyKey.

In `@src/storage/adapter/postgres/handlers/addBasicUsage.ts`:
- Around line 58-69: The current fallback accepts any row with the same
idempotencyKey as an idempotent success; instead, when the txn.select(...) on
basicUsageEventsTable returns existing, explicitly validate that that existing
row's critical fields (e.g., event type/enum, owner_id, usage metrics,
timestamp/period) match the incoming event_data values before treating it as
success; if they match, set resultId = existing.id, but if they differ, throw a
clear conflict StorageError (do not silently drop the new record) so callers
know an idempotency key was reused with different payloads; ensure this logic is
applied where existing is read and references variables basicUsageEventsTable,
event_data, existing, resultId, and follow unique constraint handling guidance
in the surrounding transaction.
🪄 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 Plus

Run ID: c532d883-d24a-4021-889f-e04c4bac06cb

📥 Commits

Reviewing files that changed from the base of the PR and between 0d6ba95 and 80de007.

⛔ Files ignored due to path filters (2)
  • bun.lock is excluded by !**/*.lock
  • src/gen/event/v1/event_pb.js is excluded by !**/gen/**
📒 Files selected for processing (8)
  • drizzle/0001_add_checkout_sessions.sql
  • package.json
  • src/errors/storage.ts
  • src/routes/gRPC/payment/createCheckoutLink.ts
  • src/routes/http/createdCheckout.ts
  • src/storage/adapter/postgres/handlers/addAiTokenUsage.ts
  • src/storage/adapter/postgres/handlers/addBasicUsage.ts
  • src/storage/db/postgres/schema.ts

Comment on lines +69 to +85
const [existing] = await db
.select()
.from(checkoutSessionsTable)
.where(
and(
eq(checkoutSessionsTable.userId, validatedData.userId),
eq(checkoutSessionsTable.isCompleted, false),
sql`${checkoutSessionsTable.createdAt} > now() - interval '24 hours'`
)
)
.limit(1);

if (existing) {
const response = new CreateCheckoutLinkResponse();
response.setCheckoutlink(existing.link);
return callback?.(null, response);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Non-atomic reuse flow can still create multiple payable checkout sessions.

Line 69-85 does the existence check before the provider call, but Line 95-123 creates the provider session and upserts afterward. Concurrent requests for the same user can both miss the row and both create provider checkout sessions, so duplicate payable links are still possible.

Please serialize this path per user (transaction + user-scoped lock/advisory lock, then re-check inside lock before provider creation), or reserve state before the external call.

As per coding guidelines, "Use Drizzle ORM with transactions; validate all inputs before DB operations; handle unique constraint violations explicitly."

Also applies to: 95-123

🤖 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/routes/gRPC/payment/createCheckoutLink.ts` around lines 69 - 85, The
current existence check using checkoutSessionsTable and validatedData.userId
happens outside the provider call and can race; wrap the flow in a Drizzle
transaction and acquire a user-scoped advisory lock (or otherwise serialize by
userId) before creating the provider session: inside the transaction/lock re-run
the same select on checkoutSessionsTable for validatedData.userId to
short-circuit if a payable session now exists, otherwise either insert a
reserved row (status=reserved) to claim the slot before calling the external
provider or create the provider session then upsert within the same transaction;
additionally catch and handle unique constraint violations on the
checkoutSessionsTable upsert to return the existing link via
CreateCheckoutLinkResponse and callback when conflicts occur.

and(
eq(checkoutSessionsTable.userId, validatedData.userId),
eq(checkoutSessionsTable.isCompleted, false),
sql`${checkoutSessionsTable.createdAt} > now() - interval '24 hours'`

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use a single clock source for checkout-session TTL math.

Line 76 compares against DB now(), while Line 122 updates created_at from app time (DateTime.utc()). Clock skew between app and DB can make the 24-hour reuse window inconsistent.

Prefer setting created_at via DB now() in the upsert update too.

Also applies to: 122-122

🤖 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/routes/gRPC/payment/createCheckoutLink.ts` at line 76, The TTL math mixes
DB now() and app time; in createCheckoutLink.ts update the upsert/update path to
set checkoutSessionsTable.created_at using the database current time (DB now())
instead of DateTime.utc() so both reads and writes use the same clock; locate
the upsert/update that writes created_at (references to checkoutSessionsTable
and createdAt/created_at in the createCheckoutLink function) and replace the
app-side DateTime.utc() assignment with a DB-side now()/CURRENT_TIMESTAMP
expression so the 24-hour comparison (sql`${checkoutSessionsTable.createdAt} >
now() - interval '24 hours'`) is consistent.

Comment on lines +160 to 201
const aiTokenUsageValues = buildAiTokenInsertValues(
aggregatedEvents,
auth
);

const inserted = await txn
.insert(aiTokenUsageEventsTable)
.values(aiTokenUsageValues)
.onConflictDoNothing({
target: aiTokenUsageEventsTable.idempotencyKey,
})
.returning({ id: aiTokenUsageEventsTable.id });

if (!inserted[0] || !inserted[0].id) {
throw StorageError.insertFailed(
"Missing or invalid ID for the first inserted event",
new Error(`Invalid first event ID: ${JSON.stringify(inserted[0])}`)
);
const firstIdempotencyKey = aiTokenUsageValues[0]?.idempotencyKey;
if (!firstIdempotencyKey) {
throw StorageError.insertFailed(
"Missing idempotency key in the first event",
new Error("First event idempotency key is undefined")
);
}

const [existing] = await txn
.select({ id: aiTokenUsageEventsTable.id })
.from(aiTokenUsageEventsTable)
.where(
eq(aiTokenUsageEventsTable.idempotencyKey, firstIdempotencyKey)
)
.limit(1);

if (!existing) {
throw StorageError.insertFailed(
"Missing or invalid ID for the first inserted event, and no existing record found",
new Error(
`Invalid first event ID: ${JSON.stringify(inserted[0])}`
)
);
}
return { id: existing.id };
}

return { id: inserted[0].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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In PostgreSQL / Drizzle ORM, when a multi-row INSERT ... ON CONFLICT DO NOTHING RETURNINGcontains duplicate values for the conflict target, are the conflicting rows omitted fromRETURNING, leaving only successfully inserted rows?

💡 Result:

Yes. In PostgreSQL, with INSERT ... ON CONFLICT DO NOTHING RETURNING ..., only rows that are actually inserted (or updated) are returned; rows for which the DO NOTHING alternative action is taken produce no RETURNING output [1]. Therefore, if your multi-row VALUES list contains duplicates for the conflict target, the rows that conflict (including duplicates that would conflict with an already-inserted row in the same statement) are omitted from RETURNING, leaving only the rows that successfully insert. Drizzle ORM’s onConflictDoNothing().returning(...) ultimately uses PostgreSQL’s semantics, so the returned array corresponds to PostgreSQL’s RETURNING result (empty for conflicts) [2][3].

Citations:


🏁 Script executed:

# Find the aggregateAiTokenEvents function and check its grouping logic
rg -A 20 "function aggregateAiTokenEvents|const aggregateAiTokenEvents" src/storage/adapter/postgres/handlers/addAiTokenUsage.ts

Repository: ScrawnDotDev/Scrawn

Length of output: 1078


🏁 Script executed:

# Also check what the buildAiTokenInsertValues function does
rg -B 5 -A 15 "function buildAiTokenInsertValues|const buildAiTokenInsertValues" src/storage/adapter/postgres/handlers/addAiTokenUsage.ts

Repository: ScrawnDotDev/Scrawn

Length of output: 630


🏁 Script executed:

# Check the table schema to understand the unique constraint
rg -B 5 -A 5 "aiTokenUsageEventsTable" src/ --type ts | grep -A 10 "idempotencyKey"

Repository: ScrawnDotDev/Scrawn

Length of output: 3202


onConflictDoNothing silently drops rows with duplicate idempotencyKey within the same batch, causing data loss.

The aggregation at line 69 groups by ${userId}:${model}:${idempotencyKey}, so multiple rows can share the same idempotencyKey when they have different models. When the batch insert uses ON CONFLICT DO NOTHING (lines 168–170), PostgreSQL silently omits those conflicting rows from the RETURNING result, leaving the handler with an incomplete insert and a false success response. The fallback logic (lines 176–201) only checks the first idempotencyKey and does not validate that all expected rows were inserted.

This violates the coding guideline: "Use Drizzle ORM with transactions; validate all inputs before DB operations; handle unique constraint violations explicitly."

Proposed guard until aggregation and uniqueness are aligned
         const aiTokenUsageValues = buildAiTokenInsertValues(
           aggregatedEvents,
           auth
         );
+
+        const seenIdempotencyKeys = new Set<string>();
+        for (const value of aiTokenUsageValues) {
+          if (seenIdempotencyKeys.has(value.idempotencyKey)) {
+            throw StorageError.constraintViolation(
+              `Duplicate AI token usage idempotencyKey in batch: ${value.idempotencyKey}`
+            );
+          }
+          seenIdempotencyKeys.add(value.idempotencyKey);
+        }

         const inserted = await txn
           .insert(aiTokenUsageEventsTable)
🤖 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/storage/adapter/postgres/handlers/addAiTokenUsage.ts` around lines 160 -
201, The batch INSERT uses onConflictDoNothing which can silently drop rows that
share idempotencyKey across different models; update addAiTokenUsage handler to
explicitly detect and handle unique-constraint conflicts by (1) replacing the
blind success check on inserted (variables: inserted, aiTokenUsageValues,
aiTokenUsageEventsTable, txn) with a verification that inserted.length ===
aiTokenUsageValues.length, (2) if lengths differ, query the DB for all
idempotency keys in the batch (use txn.select(...) .where(in(...)) on
aiTokenUsageEventsTable.idempotencyKey) to reconcile which rows already exist,
and (3) either perform an upsert (onConflictDoUpdate) keyed on the correct
unique constraint that includes model/user as needed or return a clear
StorageError listing the missing/duplicated idempotencyKeys; ensure you
reference buildAiTokenInsertValues output to map returned/existing rows back to
the original events instead of only checking the first idempotencyKey.

Comment on lines +58 to +69
const [existing] = await txn
.select({ id: basicUsageEventsTable.id })
.from(basicUsageEventsTable)
.where(eq(basicUsageEventsTable.idempotencyKey, event_data.idempotencyKey))
.limit(1);

if (!existing) {
throw StorageError.emptyResult(
"Basic usage event insert returned no ID and no existing record found"
);
}
resultId = existing.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.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate the conflicting row before treating it as idempotent success.

Lines 58-69 accept any existing row with the same idempotencyKey. If a different BASIC_USAGE event ever reuses that key, this path silently drops the new usage record and returns success. For billing data, that should be a hard mismatch, not an idempotent replay.

Proposed mismatch check on the fallback path
           const [existing] = await txn
-            .select({ id: basicUsageEventsTable.id })
+            .select({
+              id: basicUsageEventsTable.id,
+              eventId: basicUsageEventsTable.eventId,
+              userId: basicUsageEventsTable.userId,
+              type: basicUsageEventsTable.type,
+              debitAmount: basicUsageEventsTable.debitAmount,
+            })
             .from(basicUsageEventsTable)
             .where(eq(basicUsageEventsTable.idempotencyKey, event_data.idempotencyKey))
             .limit(1);

           if (!existing) {
             throw StorageError.emptyResult(
               "Basic usage event insert returned no ID and no existing record found"
             );
           }
+
+          if (
+            existing.userId !== event_data.userId ||
+            existing.eventId !== event_data.eventId ||
+            existing.type !== event_data.data.basicUsageType ||
+            existing.debitAmount !== event_data.data.debitAmount
+          ) {
+            throw StorageError.constraintViolation(
+              `basic usage idempotency mismatch for key ${event_data.idempotencyKey}`
+            );
+          }
           resultId = existing.id;

As per coding guidelines, "Use Drizzle ORM with transactions; validate all inputs before DB operations; handle unique constraint violations explicitly".

🤖 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/storage/adapter/postgres/handlers/addBasicUsage.ts` around lines 58 - 69,
The current fallback accepts any row with the same idempotencyKey as an
idempotent success; instead, when the txn.select(...) on basicUsageEventsTable
returns existing, explicitly validate that that existing row's critical fields
(e.g., event type/enum, owner_id, usage metrics, timestamp/period) match the
incoming event_data values before treating it as success; if they match, set
resultId = existing.id, but if they differ, throw a clear conflict StorageError
(do not silently drop the new record) so callers know an idempotency key was
reused with different payloads; ensure this logic is applied where existing is
read and references variables basicUsageEventsTable, event_data, existing,
resultId, and follow unique constraint handling guidance in the surrounding
transaction.

@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

♻️ Duplicate comments (2)
src/storage/adapter/postgres/handlers/addAiTokenUsage.ts (1)

165-198: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Reconcile every conflicted key, not just the first one.

ON CONFLICT DO NOTHING can return a partial inserted array here. When that happens, this fallback only loads aiTokenUsageValues[0].idempotencyKey, so later rows in the batch can be dropped silently, and a reused key with different aggregate data would still be treated as success. Compare inserted.length with aiTokenUsageValues.length, load all conflicted keys when they differ, and validate the existing rows match the incoming aggregates before returning success.

As per coding guidelines, "Use Drizzle ORM with transactions; validate all inputs before DB operations; handle unique constraint violations explicitly".

🤖 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/storage/adapter/postgres/handlers/addAiTokenUsage.ts` around lines 165 -
198, The current fallback after .onConflictDoNothing only checks the first
aiTokenUsageValues item and may silently drop later conflicted rows; update the
logic in addAiTokenUsage (referencing aiTokenUsageValues, inserted,
aiTokenUsageEventsTable, and txn) to compare inserted.length to
aiTokenUsageValues.length, and if they differ collect all idempotency keys from
aiTokenUsageValues that were not inserted, query the DB for those keys, ensure
you retrieve a matching row for each missing key, and validate that each
existing row's aggregate data matches the corresponding incoming
aiTokenUsageValues; if any key is missing or data mismatches, throw
StorageError.insertFailed with a clear message (keep using
StorageError.insertFailed for failures) otherwise return the set of
existing/inserted ids.
src/storage/adapter/postgres/handlers/addBasicUsage.ts (1)

53-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject idempotency-key mismatches on the fallback path.

If a different BASIC_USAGE payload reuses the same idempotencyKey, this branch still returns the existing row id and drops the new billing event. Fetch the fields that define idempotent equivalence and compare them to event_data before treating this as a replay; otherwise throw StorageError.constraintViolation(...).

As per coding guidelines, "Use Drizzle ORM with transactions; validate all inputs before DB operations; handle unique constraint violations explicitly".

🤖 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/storage/adapter/postgres/handlers/addBasicUsage.ts` around lines 53 - 69,
The fallback branch that finds an existing row by idempotencyKey must verify the
row is actually the same event: query the same set of columns used to determine
idempotent equivalence (the columns you include when inserting a BASIC_USAGE
event — e.g., the org/project/feature/timestamp/amount fields or whatever
columns are used for equivalence) from basicUsageEventsTable via txn (use the
same where eq(basicUsageEventsTable.idempotencyKey, event_data.idempotencyKey)),
compare each fetched column to the corresponding value on event_data, and only
treat it as a replay and set resultId = existing.id when all fields match; if
any field differs, throw StorageError.constraintViolation(...) with a clear
message; also validate event_data fields before DB ops per guidelines. Ensure
you reference basicUsageEventsTable, event_data, txn, and
StorageError.constraintViolation in the change.
🤖 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/storage/db/postgres/schema.ts`:
- Around line 254-265: The schema stores only a mutable link so webhooks cannot
target a specific provider checkout — add a stable provider identifier column
(e.g. providerCheckoutId or sessionId) to this table and make it either a string
column or a foreign key referencing sessionsTable.id; update the table
definition alongside existing fields (id, userId, link, createdAt, isCompleted)
and add the corresponding migration to populate/backfill values, then change
webhook/completion logic to mark isCompleted on the row matching the
providerCheckoutId (or sessionsTable FK) rather than matching by userId or link
so the exact checkout session is completed.
- Around line 251-271: The checkout_sessions schema currently only scopes active
uniqueness by userId; add a new column "mode" to checkoutSessionsTable (e.g.,
text or enum as appropriate) and make it non-null (and default if desired), then
update the uniqueActiveCheckout uniqueIndex ("unique_active_checkout_per_user")
to include table.mode in its .on(...) clause so the partial unique constraint
becomes userId+mode where isCompleted = false; update any references to
checkoutSessionsTable or uniqueActiveCheckout accordingly.

---

Duplicate comments:
In `@src/storage/adapter/postgres/handlers/addAiTokenUsage.ts`:
- Around line 165-198: The current fallback after .onConflictDoNothing only
checks the first aiTokenUsageValues item and may silently drop later conflicted
rows; update the logic in addAiTokenUsage (referencing aiTokenUsageValues,
inserted, aiTokenUsageEventsTable, and txn) to compare inserted.length to
aiTokenUsageValues.length, and if they differ collect all idempotency keys from
aiTokenUsageValues that were not inserted, query the DB for those keys, ensure
you retrieve a matching row for each missing key, and validate that each
existing row's aggregate data matches the corresponding incoming
aiTokenUsageValues; if any key is missing or data mismatches, throw
StorageError.insertFailed with a clear message (keep using
StorageError.insertFailed for failures) otherwise return the set of
existing/inserted ids.

In `@src/storage/adapter/postgres/handlers/addBasicUsage.ts`:
- Around line 53-69: The fallback branch that finds an existing row by
idempotencyKey must verify the row is actually the same event: query the same
set of columns used to determine idempotent equivalence (the columns you include
when inserting a BASIC_USAGE event — e.g., the
org/project/feature/timestamp/amount fields or whatever columns are used for
equivalence) from basicUsageEventsTable via txn (use the same where
eq(basicUsageEventsTable.idempotencyKey, event_data.idempotencyKey)), compare
each fetched column to the corresponding value on event_data, and only treat it
as a replay and set resultId = existing.id when all fields match; if any field
differs, throw StorageError.constraintViolation(...) with a clear message; also
validate event_data fields before DB ops per guidelines. Ensure you reference
basicUsageEventsTable, event_data, txn, and StorageError.constraintViolation in
the change.
🪄 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 Plus

Run ID: df339ea5-ed2e-4a2f-90e7-3083c1aec1bb

📥 Commits

Reviewing files that changed from the base of the PR and between 0d6ba95 and 80de007.

⛔ Files ignored due to path filters (2)
  • bun.lock is excluded by !**/*.lock
  • src/gen/event/v1/event_pb.js is excluded by !**/gen/**
📒 Files selected for processing (8)
  • drizzle/0001_add_checkout_sessions.sql
  • package.json
  • src/errors/storage.ts
  • src/routes/gRPC/payment/createCheckoutLink.ts
  • src/routes/http/createdCheckout.ts
  • src/storage/adapter/postgres/handlers/addAiTokenUsage.ts
  • src/storage/adapter/postgres/handlers/addBasicUsage.ts
  • src/storage/db/postgres/schema.ts

Comment on lines +251 to +271
export const checkoutSessionsTable = pgTable(
"checkout_sessions",
{
id: uuid("id").primaryKey().defaultRandom(),
userId: USER_ID_CONFIG.dbType("user_id")
.references(() => usersTable.id)
.notNull(),
link: text("link").notNull(),
createdAt: timestamp("created_at", {
withTimezone: true,
mode: "string",
})
.defaultNow()
.notNull(),
isCompleted: boolean("is_completed").notNull().default(false),
},
(table) => ({
uniqueActiveCheckout: uniqueIndex("unique_active_checkout_per_user")
.on(table.userId)
.where(sql`${table.isCompleted} = false`),
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Scope active checkout uniqueness by mode.

checkout_sessions is keyed only by userId, so the reuse/completion flow cannot distinguish test from production. A test checkout for a user can be reused by a production request for the same user, and a successful payment in one mode can retire the other mode’s active row. Add mode to this table and make the partial unique index user+mode scoped instead of user-only.

🤖 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/storage/db/postgres/schema.ts` around lines 251 - 271, The
checkout_sessions schema currently only scopes active uniqueness by userId; add
a new column "mode" to checkoutSessionsTable (e.g., text or enum as appropriate)
and make it non-null (and default if desired), then update the
uniqueActiveCheckout uniqueIndex ("unique_active_checkout_per_user") to include
table.mode in its .on(...) clause so the partial unique constraint becomes
userId+mode where isCompleted = false; update any references to
checkoutSessionsTable or uniqueActiveCheckout accordingly.

Comment on lines +254 to +265
id: uuid("id").primaryKey().defaultRandom(),
userId: USER_ID_CONFIG.dbType("user_id")
.references(() => usersTable.id)
.notNull(),
link: text("link").notNull(),
createdAt: timestamp("created_at", {
withTimezone: true,
mode: "string",
})
.defaultNow()
.notNull(),
isCompleted: boolean("is_completed").notNull().default(false),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Persist the paid checkout’s identity, not just its link.

This row has no stable reference to the provider checkout/session it represents. Once the row is refreshed with a newer link, the webhook can only complete by userId, so paying an older provider session will mark the current row completed and reopen link generation while the newer link is still payable. Store the provider checkout session id, or a foreign key to sessionsTable, and complete that exact record instead.

🤖 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/storage/db/postgres/schema.ts` around lines 254 - 265, The schema stores
only a mutable link so webhooks cannot target a specific provider checkout — add
a stable provider identifier column (e.g. providerCheckoutId or sessionId) to
this table and make it either a string column or a foreign key referencing
sessionsTable.id; update the table definition alongside existing fields (id,
userId, link, createdAt, isCompleted) and add the corresponding migration to
populate/backfill values, then change webhook/completion logic to mark
isCompleted on the row matching the providerCheckoutId (or sessionsTable FK)
rather than matching by userId or link so the exact checkout session is
completed.

@0xanshu 0xanshu closed this May 20, 2026
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