Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Conversation

@BilalG1
Copy link
Contributor

@BilalG1 BilalG1 commented Jan 8, 2026

Summary by CodeRabbit

  • New Features

    • Unified, cursor-based transactions listing with filtering (including per-customer) and richer transaction types.
  • Integrations

    • Stripe webhook handling now processes refunds.
  • Database

    • Added enums and tables to track refunds, product changes, and subscription changes with indexes.
  • API Changes

    • Transactions API and admin interface extended to accept customer_id and return has_more/hasMore.
  • UI

    • Transaction table and product-customer list updated to surface new transaction types and refund behavior.
  • Tests

    • Comprehensive end-to-end transaction tests added.

✏️ Tip: You can customize this high-level summary in your review settings.


Note

Modernizes payments accounting and listing across backend, shared libs, and dashboard.

  • Adds StripeRefund, ProductChange, SubscriptionChange tables (+ SubscriptionChangeType) with indexes in Prisma/migration; corresponding models in schema.prisma
  • New unified transaction engine in src/lib/new-transactions.ts with NEW_TRANSACTION_TYPES, builders, and merged cursor pagination; removes old transaction-builder
  • Replaces internal transactions endpoint to use listTransactions (adds has_more, customer_id filter, new entry schemas/types)
  • Extends Stripe webhook to process refund events (handleStripeRefund) and upsert into StripeRefund
  • Updates dashboard components to display new transaction types, refundability, and labels
  • Updates shared interfaces/schemas (TransactionEntry, TransactionType, charged/net amount shape) and paginated-lists merge/iteration logic
  • Adds/updates E2E tests for new transaction flows, refunds, filtering, and pagination

Written by Cursor Bugbot for commit 977836b. This will update automatically on new commits. Configure here.

@cmux-agent
Copy link

cmux-agent bot commented Jan 8, 2026

Older cmux preview screenshots (latest comment is below)

Preview Screenshots

Open Workspace (1 hr expiry) · Open Dev Browser (1 hr expiry) · Open Diff Heatmap

Screenshot capture was skipped.

No UI changes detected - screenshots skipped


Generated by cmux preview system

@vercel
Copy link

vercel bot commented Jan 8, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
stack-backend Ready Ready Preview, Comment Jan 13, 2026 6:21pm
stack-dashboard Ready Ready Preview, Comment Jan 13, 2026 6:21pm
stack-demo Ready Ready Preview, Comment Jan 13, 2026 6:21pm
stack-docs Ready Ready Preview, Comment Jan 13, 2026 6:21pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 8, 2026

📝 Walkthrough

Walkthrough

Adds DB enum and three tables (StripeRefund, ProductChange, SubscriptionChange), integrates Stripe refund handling into the webhook path, replaces the old transaction builder with a unified cursor-based transactions module and pagination, updates transaction-related schemas/clients, and adds extensive E2E tests for the new flows.

Changes

Cohort / File(s) Summary
DB Migration & Schema
apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql, apps/backend/prisma/schema.prisma
Add enum SubscriptionChangeType; create StripeRefund, ProductChange, SubscriptionChange tables/models with composite PK (tenancyId,id), indexes, and unique constraint on (tenancyId,stripeRefundId).
Stripe Webhook & Refund Handling
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx, apps/backend/src/lib/stripe.tsx
Webhook now detects refund events and calls new handleStripeRefund; handleStripeRefund resolves tenancy, validates customer metadata, optionally links one-time purchases/subscriptions, and upserts StripeRefund records.
Unified Transactions Module
apps/backend/src/lib/new-transactions.ts, apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
New unified transaction types, per-type paginated lists, cursor encode/decode, merging/listTransactions API; route replaced ad-hoc pagination with listTransactions and updated request/response schemas.
Removed Transaction Builder
apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts
Entire file removed — prior transaction-building utilities and exports deleted (functionality replaced by new-transactions).
E2E Tests
apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts, apps/e2e/tests/backend/endpoints/api/v1/internal/transactions*.test.ts
New comprehensive E2E tests for transactions flows (subscriptions, resubs, one-time, refunds, product/sub changes, pagination); updated snapshots and assertions to reflect new types/structure.
Frontend / Admin Surface
packages/stack-shared/src/interface/admin-interface.ts, packages/template/src/lib/stack-app/.../admin-app-impl.ts, packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
Add optional customerId param to listTransactions across interfaces/hooks and extend return shape with hasMore:boolean.
Shared Schemas & Types
packages/stack-shared/src/interface/crud/transactions.ts, packages/stack-shared/src/utils/paginated-lists.tsx
Expand TRANSACTION_TYPES and transactionEntry schemas (active_sub_start/change/stop, item_quantity_expire, etc.), switch charged/net amounts to string-based shapes; adjust merged cursor format and safety checks in paginated lists.
UI Adjustments
apps/dashboard/src/components/data-table/transaction-table.tsx, apps/dashboard/src/app/.../products/[productId]/page-client.tsx
Update refundability checks and display labels/types; broaden product-customer inclusion beyond purchase-only transactions.

Sequence Diagram(s)

sequenceDiagram
    participant StripeAPI as Stripe API
    participant Webhook as Webhook Handler
    participant StripeLib as Stripe Lib\n(handleStripeRefund)
    participant Tenancy as Tenancy Resolver
    participant DB as Database (Prisma)

    StripeAPI->>Webhook: POST charge.refunded (charge event)
    Webhook->>Webhook: validate signature & classify event
    Webhook->>StripeLib: handleStripeRefund(stripe, accountId, charge)

    StripeLib->>Tenancy: resolve tenancy for accountId
    Tenancy-->>StripeLib: tenancyId + prisma client

    StripeLib->>DB: lookup optional OneTimePurchase/Subscription by payment_intent_id
    DB-->>StripeLib: lookup results
    StripeLib->>DB: upsert StripeRefund rows (one per refund)
    DB-->>StripeLib: upsert confirmation

    StripeLib-->>Webhook: processed
    Webhook-->>StripeAPI: 200 OK
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • Payment transactions #990 — Overlapping payments/transactions overhaul (models, transaction listing, and builders replacement).
  • Store offer price id #900 — Related Stripe webhook and persistence changes touching refund and one-time purchase persistence.
  • Paginated list tests #1088 — Changes to paginated-lists (cursor shape and merge behavior) that overlap with merged-cursor logic here.

Poem

🐰 I hopped through tables, enum and rows in sight,

Refunds found a home beneath the tenancy light,
Webhooks whispered, tenancy pointed the way,
Cursors stitched pages so lists could play,
A joyful twitch — transactions snug tonight.

🚥 Pre-merge checks | ❌ 3
❌ Failed checks (2 warnings, 1 inconclusive)
Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description contains only an HTML comment referencing CONTRIBUTING.md guidelines with no substantive information about the changes, objectives, or scope of the pull request. Add a detailed description explaining the key changes, motivation, affected systems, and any breaking changes or migration steps required.
Docstring Coverage ⚠️ Warning Docstring coverage is 11.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'transactions rework' is vague and generic, using non-descriptive language that does not convey meaningful information about the actual changes. Replace with a more specific title that describes the main change, such as 'Refactor transaction system with new schema and unified listing API' or 'Add StripeRefund, ProductChange tables and unified transaction engine'.

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

✨ Finishing touches
  • 📝 Generate docstrings

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 and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In @apps/backend/src/lib/new-transactions.ts:
- Around line 309-317: The hardcoded adjusted_entry_index: 0 is unsafe; instead
locate the original transaction referenced by subscription.id, search its
entries array for the entry that corresponds to this subscription/item (match on
entry.item_id, entry.type and/or quantity), and set adjusted_entry_index to that
found entry's index before pushing into entries (fall back to a safe default and
log a warning if no match). Use the same identifiers shown (subscription.id,
adjusted_entry_index, itemId, entries.push) so you update the lookup where
entries are assembled and avoid assuming a fixed order.
- Around line 157-165: The decodeCursor currently splits on ":" which breaks ISO
timestamps; update decodeCursor to (1) return null for "first"/"last" as before,
(2) verify the cursor starts with the "cursor:" prefix, (3) remove that prefix
and locate the delimiter between date and id using the last colon (e.g.,
payload.lastIndexOf(':')) so the ISO date (which may contain colons) is
preserved, (4) extract dateStr = payload.slice(0, lastColon) and id =
payload.slice(lastColon + 1), validate them and return { createdAt: new
Date(dateStr), id } or null on failure; refer to the functions decodeCursor and
encodeCursor and the TransactionCursor type when making the change.

In @apps/backend/src/lib/stripe.tsx:
- Around line 203-218: subscriptionId is never set because the code only queries
oneTimePurchase; after the oneTimePurchase lookup add a corresponding
subscription lookup and assign subscriptionId. Specifically, after the
prisma.oneTimePurchase.findFirst block, if oneTimePurchase is not found call
prisma.subscription.findFirst({ where: { tenancyId: tenancy.id,
stripePaymentIntentId } }) (and optionally also check any charge-related fields
you use like stripeChargeId), then set subscriptionId = foundSubscription.id so
subscription-based refunds are linked (use the existing variables
stripePaymentIntentId and subscriptionId and the prisma.subscription model).
🧹 Nitpick comments (5)
apps/backend/src/lib/stripe.tsx (1)

184-196: Different handling for deleted customers compared to syncStripeSubscriptions.

syncStripeSubscriptions (line 71-73) silently returns when the customer is deleted, while this function throws a StackAssertionError. Consider aligning the behavior—either both should throw or both should return early—to maintain consistency.

apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts (1)

133-148: Duplicate sendStripeWebhook helper function.

This function duplicates sendStripeWebhook from apps/e2e/tests/backend/backend-helpers.ts (lines 1522-1549). The existing helper also supports invalidSignature, omitSignature, and custom secret options. Consider importing from backend-helpers.ts instead.

♻️ Suggested import
-import { Payments as PaymentsHelper, Project, Team, User, niceBackendFetch } from "../../../backend-helpers";
+import { Payments as PaymentsHelper, Project, Team, User, niceBackendFetch, sendStripeWebhook } from "../../../backend-helpers";

-const stripeWebhookSecret = process.env.STACK_STRIPE_WEBHOOK_SECRET ?? "mock_stripe_webhook_secret";
-
-async function sendStripeWebhook(payload: unknown) {
-  const timestamp = Math.floor(Date.now() / 1000);
-  const hmac = createHmac("sha256", stripeWebhookSecret);
-  hmac.update(`${timestamp}.${JSON.stringify(payload)}`);
-  const signature = hmac.digest("hex");
-  return await niceBackendFetch("/api/latest/integrations/stripe/webhooks", {
-    method: "POST",
-    headers: {
-      "content-type": "application/json",
-      "stripe-signature": `t=${timestamp},v1=${signature}`,
-    },
-    body: payload,
-  });
-}
apps/backend/prisma/schema.prisma (1)

1046-1048: Schema comment claims XOR constraint not enforced at DB level.

The comment states "exactly one of subscriptionId or oneTimePurchaseId must be set," but no database constraint enforces this. Currently, both can be null (for direct charges) or both could be set (incorrectly). If the XOR is a hard requirement, add a check constraint in the migration.

♻️ Example check constraint for migration
ALTER TABLE "StripeRefund" ADD CONSTRAINT "StripeRefund_source_xor_check" 
CHECK (
  ("subscriptionId" IS NOT NULL AND "oneTimePurchaseId" IS NULL) OR 
  ("subscriptionId" IS NULL AND "oneTimePurchaseId" IS NOT NULL) OR
  ("subscriptionId" IS NULL AND "oneTimePurchaseId" IS NULL)
);

Note: The third case allows neither to be set, which may be valid for standalone charges. Adjust based on business requirements.

apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql (1)

5-21: Consider adding foreign key constraints for data integrity.

The StripeRefund table references subscriptionId and oneTimePurchaseId but lacks foreign key constraints. While this provides flexibility (e.g., if referenced records are deleted), it may lead to orphaned references. Consider adding FK constraints with ON DELETE SET NULL if referential integrity is desired.

apps/backend/src/lib/new-transactions.ts (1)

175-199: Currency list in buildChargedAmount could be extracted as a constant.

The hardcoded currency codes array is repeated and could drift out of sync with actual supported currencies. Consider extracting to a shared constant.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e34f2ff and 16c3eba.

📒 Files selected for processing (6)
  • apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql
  • apps/backend/prisma/schema.prisma
  • apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/lib/stripe.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
🧰 Additional context used
📓 Path-based instructions (7)
**/*.{tsx,ts,jsx,js}

📄 CodeRabbit inference engine (AGENTS.md)

For blocking alerts and errors, never use toast; instead, use alerts as toasts are easily missed by the user

Files:

  • apps/backend/src/lib/stripe.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
**/*.{tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{tsx,css}: Keep hover/click animations snappy and fast; don't delay actions with pre-transitions (e.g., no fade-in on button hover) as it makes UI feel sluggish; instead apply transitions after the action like smooth fade-out when hover ends
When creating hover transitions, avoid hover-enter transitions and use only hover-exit transitions (e.g., transition-colors hover:transition-none)

Files:

  • apps/backend/src/lib/stripe.tsx
  • apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
**/*.{tsx,ts}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if avoidable; prefer using client components instead to keep pages static (e.g., use usePathname instead of await params)

Files:

  • apps/backend/src/lib/stripe.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: NEVER try-catch-all, NEVER void a promise, and NEVER use .catch(console.error) or similar; use loading indicators instead; if asynchronous handling is necessary, use runAsynchronously or runAsynchronouslyWithAlert instead
Use ES6 maps instead of records wherever possible

Files:

  • apps/backend/src/lib/stripe.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Code defensively; prefer ?? throwErr(...) over non-null assertions with good error messages explicitly stating violated assumptions
Avoid the any type; when necessary, leave a comment explaining why it's used, why the type system fails, and how errors would be caught at compile-, test-, or runtime

Files:

  • apps/backend/src/lib/stripe.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx
**/e2e/**/*.{test,spec}.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

ALWAYS add new E2E tests when changing the API or SDK interface; err on the side of creating too many tests due to the critical nature of the authentication industry

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
**/*.{test,spec}.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

When writing tests, prefer .toMatchInlineSnapshot() over other selectors if possible; check snapshot-serializer.ts to understand how snapshots are formatted and how non-deterministic values are handled

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
🧠 Learnings (3)
📓 Common learnings
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-07T00:55:19.856Z
Learning: The project uses PostgreSQL with Prisma ORM for database management; database models are located in `/apps/backend/src`
📚 Learning: 2026-01-07T00:55:19.856Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-07T00:55:19.856Z
Learning: The project uses PostgreSQL with Prisma ORM for database management; database models are located in `/apps/backend/src`

Applied to files:

  • apps/backend/prisma/schema.prisma
📚 Learning: 2026-01-07T00:55:19.856Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-07T00:55:19.856Z
Learning: Applies to **/e2e/**/*.{test,spec}.{ts,tsx,js,jsx} : ALWAYS add new E2E tests when changing the API or SDK interface; err on the side of creating too many tests due to the critical nature of the authentication industry

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
🧬 Code graph analysis (2)
apps/backend/src/lib/stripe.tsx (3)
apps/backend/src/prisma-client.tsx (1)
  • getPrismaClientForTenancy (67-69)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
packages/stack-shared/src/utils/arrays.tsx (1)
  • typedIncludes (3-5)
apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts (1)
apps/e2e/tests/backend/backend-helpers.ts (2)
  • niceBackendFetch (109-173)
  • sendStripeWebhook (1523-1550)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: restart-dev-and-test
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: docker
  • GitHub Check: all-good
  • GitHub Check: setup-tests
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
🔇 Additional comments (12)
apps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsx (2)

33-44: LGTM! Refund event handling follows established patterns.

The refundEvents constant and isRefundEvent type guard are well-structured, consistent with the existing subscriptionChangedEvents and isSubscriptionChangedEvent pattern.


116-125: LGTM! Refund processing block is consistent with other event handlers.

The refund handling correctly validates accountId, resolves the Stripe client, and delegates to handleStripeRefund. The error message is appropriately specific for debugging.

apps/backend/src/lib/stripe.tsx (1)

220-247: LGTM! Refund upsert logic is well-structured.

The loop correctly iterates over all refunds on the charge and upserts each with proper tenancy isolation using the composite unique key tenancyId_stripeRefundId.

apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts (2)

155-189: Comprehensive test scaffolding for new transaction types.

The skipped tests provide excellent coverage of expected transaction entry types. Ensure these are unskipped once the NEW_TRANSACTIONS_ENDPOINT route is implemented. Based on learnings, E2E tests are critical for authentication-related changes.


1117-1128: Good use of inline snapshots for edge cases.

The inline snapshot for the empty project case follows the coding guideline to prefer .toMatchInlineSnapshot() over other selectors.

apps/backend/prisma/schema.prisma (3)

1037-1066: StripeRefund model is well-structured.

Appropriate indices for tenancy-based queries on subscriptionId and oneTimePurchaseId. The unique constraint on (tenancyId, stripeRefundId) correctly prevents duplicate refund records.


1068-1099: ProductChange model LGTM.

Proper indices for subscription and customer queries. The nullable oldProduct/newProduct JSON fields allow flexibility for partial changes.


1101-1134: SubscriptionChange model LGTM.

The changeType enum provides good categorization. Note that subscriptionId is required here (non-nullable), which is appropriate since this table explicitly tracks subscription changes.

apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql (1)

1-81: Migration is well-structured and aligns with schema.

Tables, indices, and constraints correctly implement the Prisma schema. The composite primary keys ensure proper multi-tenant isolation.

apps/backend/src/lib/new-transactions.ts (3)

712-791: Well-designed abstract base class for database pagination.

The DatabasePaginatedList abstraction cleanly separates concerns: subclasses only implement data fetching and row-to-transaction mapping. The cursor handling and comparison logic is properly shared.


1591-1677: listTransactions is well-structured with proper pagination.

The function correctly merges multiple paginated lists, applies filters, and handles the "fetch one extra for hasMore" pattern. The cursor handling delegates properly to the merged list.


1443-1462: Move the customerId guard check before calling getSubscriptions.

The guard at line 1451 should precede the function call at line 1444. Currently, getSubscriptions is invoked with an empty string customerId before checking whether one exists, resulting in an unnecessary database query that returns no results anyway.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Jan 8, 2026

Greptile Summary

  • Implements a comprehensive transactions tracking system with new database models for Stripe refunds, product changes, and subscription modifications to provide unified financial audit trails
  • Adds extensive E2E test coverage for a new transactions API endpoint with pagination and filtering capabilities, though tests are currently skipped as the API is not yet implemented
  • Integrates Stripe refund webhook processing to automatically capture and store refund data linked to original subscriptions or one-time purchases

Important Files Changed

Filename Overview
apps/backend/src/lib/new-transactions.ts New transactions system with virtual transaction entries and paginated lists; contains hardcoded index assumptions and potential performance issues
apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts Comprehensive test suite for transactions API with 1,280+ lines of skipped tests awaiting API implementation
apps/backend/prisma/schema.prisma Added three new database models (StripeRefund, ProductChange, SubscriptionChange) for comprehensive transaction tracking
apps/backend/src/lib/stripe.tsx Added handleStripeRefund function to process Stripe refund events and link them to existing transactions

Confidence score: 2/5

  • This PR introduces significant complexity and potential issues that require careful review before merging
  • Score lowered due to hardcoded assumptions in transaction entry indexing, unimplemented API with extensive test coverage, currency conversion logic issues, and potential performance problems with renewal calculations
  • Pay close attention to apps/backend/src/lib/new-transactions.ts for hardcoded values and performance bottlenecks, and verify the database migration properly handles multi-tenant constraints

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

6 files reviewed, 8 comments

Edit Code Review Agent Settings | Greptile

@cmux-agent
Copy link

cmux-agent bot commented Jan 8, 2026

Older cmux preview screenshots (latest comment is below)

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In @apps/backend/src/lib/new-transactions.ts:
- Line 520: The current construction of net_amount sets USD to "0" whenever
refund.currency !== "USD", losing the refund value; change the object to
preserve the actual currency amount instead of hardcoding USD: use net_amount: {
[refund.currency]: negativeAmount } (or call a currency conversion helper like
convertToUSD(refund.amount) and assign to USD if you want normalized USD
values). Update the place where net_amount is built (the expression using
net_amount, negativeAmount and refund.currency) so non-USD refunds retain their
amount key or are converted to USD before assignment.
- Line 543: The code currently hardcodes adjusted_entry_index: 0 assuming the
product_grant entry is first; change this to compute the index by searching the
transaction's entries for the entry whose type or id matches the product_grant
(e.g., use entries.findIndex(e => e.type === 'product_grant' || e.resource ===
'product_grant')), assign that result to adjusted_entry_index, and add a safe
fallback or throw/log an error if findIndex returns -1 so you don't silently
apply the wrong index.
- Line 516: The code hardcodes adjusted_entry_index: 0 which breaks when the
original transaction's entries don't have money_transfer (e.g., test-mode
subscriptions). Instead, compute the index dynamically by finding the
money_transfer entry in the original transaction entries (e.g., const moneyIndex
= originalTransaction.entries.findIndex(e => e.type === 'money_transfer')) and
use adjusted_entry_index: moneyIndex; if moneyIndex === -1, omit
adjusted_entry_index (or set it to null/undefined) and ensure any downstream
logic that reads adjusted_entry_index handles the missing value safely.
- Around line 427-439: The current code uses a fragile incrementing entryIndex
to set adjusted_entry_index (entryIndex, entries array, includedItems loop,
subscription.id), assuming product_grant is at index 0; instead compute
adjusted_entry_index by locating the correct index in the original transaction's
entries array at runtime: find the entry whose type and item_id match the item
(use originalTransaction.entries.findIndex(...) or similar) and use that index
for adjusted_entry_index; remove the entryIndex counter and set quantity_expire
entries using the dynamically found index so added/removed entries in the source
transaction don't break the mapping.
- Line 421: The hardcoded adjusted_entry_index: 0 is fragile; replace it by
locating the index of the product_grant entry in the original transaction
entries array (e.g., findIndex over original_transaction.entries for an entry
whose type/name matches "product_grant" or matches the product id) and assign
that computed index to adjusted_entry_index; if not found, throw or handle the
error rather than defaulting to 0. Use the existing symbol adjusted_entry_index
and the product_grant entry detection inside the same function in
new-transactions.ts to implement this change.
- Line 624: The code currently hardcodes adjusted_entry_index: 0 based on the
assumption that active_sub_start is the first entry; replace this with a
computed index by locating the position of active_sub_start within the
transaction entries (e.g., use entries.findIndex(...) comparing the entry's
unique identifier or pointer to active_sub_start) and assign that result to
adjusted_entry_index, with a safe fallback (e.g., 0) if not found; update
references in the function that builds the transaction so adjusted_entry_index
is derived dynamically from the entries array rather than assumed.
- Line 235: The net_amount assignment currently hardcodes USD using
getOrUndefined(chargedAmount, "USD") which yields { USD: "0" } for non-USD
charges; change net_amount to use the actual currency key from the charge (e.g.,
read the transaction/captured currency field or derive the currency key from
chargedAmount) and set net_amount to { [currency]: getOrUndefined(chargedAmount,
currency) ?? "0" }; if the project requires a USD value instead, call the
existing conversion utility (or add one) to convert the charged amount to USD
and store both values or the converted USD under net_amount. Apply the same fix
to the other repeated occurrences (lines ~300 and ~365) that use
getOrUndefined(chargedAmount, "USD").
- Around line 588-601: The current logic uses a fragile counter (entryIndex)
starting at 1 to compute adjusted_entry_index for item_quantity_expire entries;
instead, derive adjusted_entry_index by locating the actual index of the
corresponding original entry in the source transaction rather than assuming
order. Instruct the fix to access the original transaction's entries (the array
that contains product_grant and item entries), find the index of the entry that
matches the current itemId (and/or type) and use that index as
adjusted_entry_index (or index + 1 if protocol expects 1-based), and remove the
manual entryIndex counter; update the code paths that reference entryIndex (the
block iterating includedItems that pushes into entries) to use the computed
index, keeping other fields (type:"item_quantity_expire",
adjusted_transaction_id: change.subscriptionId, item_id, quantity:
(config.quantity ?? 0) * change.oldQuantity) unchanged.
- Line 465: The hardcoded adjusted_entry_index: 0 is brittle; instead, compute
adjusted_entry_index by locating the index of the entry that corresponds to
active_sub_start in the original transaction entries array (e.g., search
transaction.entries or the local entries variable for the entry whose id/marker
equals active_sub_start or matches the same reference) and assign that index
(with a safe fallback like -1 or 0 if not found). Update the code around
adjusted_entry_index and any logic that uses it (references to
adjusted_entry_index, active_sub_start) to use this dynamically computed index
so the code no longer assumes entry order.
- Around line 1452-1453: The code passes an empty string for customerId when
building options.filter (customerId: options.filter.customerId ?? ""), which is
inconsistent with how getSubscriptions consumes customerId and with the early
missing-customerId check that returns before using results; change this to leave
customerId undefined when absent (customerId: options.filter.customerId ??
undefined) or, better, avoid calling getSubscriptions entirely when
options.filter.customerId is missing by short-circuiting before invoking
getSubscriptions; update call sites around the early-return check to only call
getSubscriptions when a real customerId exists.
🧹 Nitpick comments (1)
apps/backend/src/lib/new-transactions.ts (1)

185-186: Consider externalizing the currency code list.

The hardcoded array of 18 currency codes creates a maintenance burden. If new currencies need to be supported, this code must be updated. Consider moving the currency list to a configuration constant or deriving it from the price schema definition.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 16c3eba and ecd4333.

📒 Files selected for processing (1)
  • apps/backend/src/lib/new-transactions.ts
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{tsx,ts,jsx,js}

📄 CodeRabbit inference engine (AGENTS.md)

For blocking alerts and errors, never use toast; instead, use alerts as toasts are easily missed by the user

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{tsx,ts}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if avoidable; prefer using client components instead to keep pages static (e.g., use usePathname instead of await params)

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: NEVER try-catch-all, NEVER void a promise, and NEVER use .catch(console.error) or similar; use loading indicators instead; if asynchronous handling is necessary, use runAsynchronously or runAsynchronouslyWithAlert instead
Use ES6 maps instead of records wherever possible

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Code defensively; prefer ?? throwErr(...) over non-null assertions with good error messages explicitly stating violated assumptions
Avoid the any type; when necessary, leave a comment explaining why it's used, why the type system fails, and how errors would be caught at compile-, test-, or runtime

Files:

  • apps/backend/src/lib/new-transactions.ts
🧬 Code graph analysis (1)
apps/backend/src/lib/new-transactions.ts (7)
packages/stack-shared/src/schema-fields.ts (2)
  • inlineProductSchema (661-686)
  • productSchema (624-647)
packages/stack-shared/src/utils/strings.tsx (2)
  • typedToLowercase (15-18)
  • stringCompare (61-65)
packages/stack-shared/src/utils/objects.tsx (1)
  • getOrUndefined (545-548)
apps/backend/src/lib/payments.tsx (2)
  • productToInlineProduct (427-443)
  • getSubscriptions (276-357)
apps/backend/src/prisma-client.tsx (1)
  • PrismaClientTransaction (25-27)
apps/backend/src/lib/tenancies.tsx (1)
  • Tenancy (53-53)
packages/stack-shared/src/utils/dates.tsx (3)
  • FAR_FUTURE_DATE (201-201)
  • getIntervalsElapsed (211-231)
  • addInterval (197-199)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: all-good
  • GitHub Check: docker
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: build (22.x)
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: setup-tests
  • GitHub Check: restart-dev-and-test
  • GitHub Check: setup-tests-with-custom-base-port
🔇 Additional comments (2)
apps/backend/src/lib/new-transactions.ts (2)

509-511: Division by 100 assumes cent-based currencies.

This logic fails for currencies with different decimal place conventions (e.g., JPY has 0 decimal places, BHD has 3). The .toFixed(2) hardcodes 2 decimal places.

Based on past review comments flagging the same issue.

Likely an incorrect or invalid review comment.


1457-1467: Returning empty results when no customerId filter could hide renewal data.

Is this intentional behavior, or should renewals be computed for all customers when no filter is applied? This limits the usefulness of the renewal list when listing all transactions.

Based on past review comments flagging the same issue.

Likely an incorrect or invalid review comment.

@cmux-agent
Copy link

cmux-agent bot commented Jan 8, 2026

Older cmux preview screenshots (latest comment is below)

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 10

🤖 Fix all issues with AI agents
In @apps/backend/src/lib/new-transactions.ts:
- Around line 1452-1454: The code should not default customerId to an empty
string; remove the "customerId: options.filter.customerId ?? ''" fallback and
pass options.filter.customerId as-is (or omit the property when it's undefined)
so the existing early-return check that handles missing customerId remains
effective; update the surrounding typing (e.g., make customerId optional or
string | undefined) if required so Prisma receives either a valid id or no
filter rather than an empty string.
- Around line 543-544: The hardcoded adjusted_entry_index: 0 is brittle; locate
the entry index of the original transaction that has type "product_grant" (or
the matching identifier used in the original entries) and set
adjusted_entry_index to that found index instead of 0. Update the code around
the construction that uses adjusted_entry_index (the object with quantity:
change.oldQuantity) to search the original entries array (e.g., findIndex on
entries for entry.type === "product_grant" or the appropriate property) and use
that index, with a fallback/error handling if not found.
- Around line 161-165: The decodeCursor implementation incorrectly uses
cursor.split(":") which breaks ISO timestamps containing colons; update
decodeCursor to parse the cursor by finding the first and last colon indices
(e.g., const first = cursor.indexOf(":"), const last = cursor.lastIndexOf(":"))
then extract dateStr = cursor.slice(first + 1, last) and id = cursor.slice(last
+ 1), construct createdAt = new Date(dateStr), and return { createdAt, id };
also validate indexes and return null on malformed cursors.
- Around line 624-625: The hardcoded adjusted_entry_index: 0 is unsafe because
active_sub_start may not be the first entry; locate where adjusted_entry_index
is set (the object containing adjusted_entry_index and customer_type in
new-transactions.ts) and replace the constant with logic that finds the index of
the entry whose identifier/marker equals active_sub_start within the transaction
entries array (e.g., entries.findIndex(e => e.id === active_sub_start || e.start
=== active_sub_start)); set adjusted_entry_index to that found index (or
-1/throw if not found) so the index reflects the actual position rather than
assuming 0.
- Around line 516-517: The hardcoded adjusted_entry_index: 0 is brittle because
it assumes money_transfer is the first entry; instead compute the index
dynamically by locating the entry with type 'money_transfer' (e.g., use
entries.findIndex(e => e.type === 'money_transfer')) where adjusted_entry_index
is assigned, and set adjusted_entry_index to that found index (or null/undefined
/ omit the field) when no money_transfer exists (ensure downstream code handles
the absence). Update the code paths around adjusted_entry_index and any
consumers to handle a -1/null/undefined value safely (or skip related logic for
subscriptions in test mode) so you no longer rely on a fixed 0 index.
- Around line 300-301: The net_amount field is hardcoded to USD: net_amount: {
USD: getOrUndefined(chargedAmount, "USD") ?? "0" }, which loses the real
currency for non-USD charges; change the key passed to getOrUndefined to use the
actual currency variable used elsewhere (e.g., chargedCurrency) or derive the
currency from chargedAmount so the object becomes net_amount: {
[chargedCurrency]: getOrUndefined(chargedAmount, chargedCurrency) ?? "0" }
(ensure chargedCurrency is available in scope).
- Around line 1672-1673: The call sets limitPrecision: "exact" which mismatches
the expected value in DatabasePaginatedList._nextOrPrev; change the option to
limitPrecision: "approximate" (or remove the limitPrecision option entirely)
where limit: limit + 1 is passed so the argument matches the _nextOrPrev
signature and avoids runtime type errors.
- Around line 1680-1686: The cursor extraction is unsafe because accessing
result.items[limit - 1] can read out-of-bounds when limit is 0 or items is
empty; update the logic in the nextCursor calculation (symbols: ItemWithCursor,
lastItem, nextCursor, result.items, limit, hasMore) to first validate that limit
> 0 and result.items.length > 0, then safely read the last element (e.g., use
result.items[result.items.length - 1]) and narrow its shape before accessing
itemCursor or nextCursor; ensure when hasMore is true but no cursor is found you
return null or an explicit sentinel and avoid casting without runtime checks.
🧹 Nitpick comments (5)
apps/backend/src/lib/new-transactions.ts (5)

175-199: Use integer arithmetic for money calculations.

The function uses parseFloat and floating-point multiplication for money amounts, which can introduce rounding errors. Additionally, there's no validation that quantity is a positive integer.

💰 Proposed fix
 function buildChargedAmount(
   product: InferType<typeof productSchema>,
   priceId: string | null,
   quantity: number
 ): Record<string, string> {
+  if (quantity < 0 || !Number.isInteger(quantity)) {
+    throw new Error(`Invalid quantity: ${quantity}. Must be a non-negative integer.`);
+  }
   if (!priceId || product.prices === "include-by-default") return {};
   const price = getOrUndefined(product.prices, priceId);
   if (!price) return {};

   const result: Record<string, string> = {};
   const currencyCodes = ["USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CNY", "INR", "BRL", "MXN", "CHF", "SGD", "HKD", "KRW", "SEK", "NOK", "DKK", "NZD"];

   for (const code of currencyCodes) {
     const amount = price[code as keyof typeof price];
     if (typeof amount === "string" && amount !== "0") {
-      // Simple multiplication (assumes integer quantities for now)
-      const numValue = parseFloat(amount);
-      if (!isNaN(numValue)) {
-        result[code] = (numValue * quantity).toString();
-      }
+      // Use integer arithmetic to avoid floating-point errors
+      const decimalParts = amount.split('.');
+      const integerPart = parseInt(decimalParts[0] ?? '0', 10);
+      const fractionalPart = parseInt((decimalParts[1] ?? '0').padEnd(2, '0').slice(0, 2), 10);
+      const totalCents = (integerPart * 100 + fractionalPart) * quantity;
+      const resultInteger = Math.floor(totalCents / 100);
+      const resultFraction = totalCents % 100;
+      result[code] = `${resultInteger}.${resultFraction.toString().padStart(2, '0')}`;
     }
   }

   return result;
 }

844-844: Unsafe any type cast bypasses type checking.

The as any cast disables Prisma's type safety. While the comment acknowledges complexity, this could mask type errors.

Consider using a more specific type or Prisma's Prisma.SubscriptionWhereInput type:

where: where as Prisma.SubscriptionWhereInput,

206-207: Missing defensive validation for product schema.

The code directly casts subscription.product to InferType<typeof productSchema> without validation. If the database contains invalid data, this could cause runtime errors.

As per coding guidelines, prefer defensive coding with ?? throwErr(...).

const product = (subscription.product ?? throwErr("Missing product in subscription")) as InferType<typeof productSchema>;

618-619: Unsafe type casting without validation.

The code casts change.oldValue and change.newValue to specific shapes without validation. If the data doesn't match the expected shape, the code will silently use undefined values, potentially causing incorrect transaction entries.

Add validation or use the ?? throwErr(...) pattern from coding guidelines:

const oldValue = (change.oldValue ?? null) as { productId?: string, priceId?: string } | null;
const newValue = (change.newValue ?? null) as { productId?: string, priceId?: string } | null;

254-268: Consider extracting repeated included items logic.

The logic for iterating through includedItems and creating item_quantity_change entries is duplicated across multiple transaction builders (lines 254-268, 321-334, 383-398, 566-581). This violates the DRY principle.

Extract into a helper function:

function buildItemQuantityChangeEntries(
  product: InferType<typeof productSchema>,
  customerType: CustomerTypeValue,
  customerId: string,
  quantity: number
): ItemQuantityChangeEntry[] {
  const entries: ItemQuantityChangeEntry[] = [];
  const includedItems = getOrUndefined(product, "includedItems") ?? {};
  for (const [itemId, itemConfig] of Object.entries(includedItems)) {
    const itemQuantity = (itemConfig as { quantity?: number }).quantity ?? 0;
    if (itemQuantity > 0) {
      entries.push({
        type: "item_quantity_change",
        adjusted_transaction_id: null,
        adjusted_entry_index: null,
        customer_type: customerType,
        customer_id: customerId,
        item_id: itemId,
        quantity: itemQuantity * quantity,
      });
    }
  }
  return entries;
}
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between ecd4333 and 956c11f.

📒 Files selected for processing (1)
  • apps/backend/src/lib/new-transactions.ts
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{tsx,ts,jsx,js}

📄 CodeRabbit inference engine (AGENTS.md)

For blocking alerts and errors, never use toast; instead, use alerts as toasts are easily missed by the user

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{tsx,ts}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if avoidable; prefer using client components instead to keep pages static (e.g., use usePathname instead of await params)

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: NEVER try-catch-all, NEVER void a promise, and NEVER use .catch(console.error) or similar; use loading indicators instead; if asynchronous handling is necessary, use runAsynchronously or runAsynchronouslyWithAlert instead
Use ES6 maps instead of records wherever possible

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Code defensively; prefer ?? throwErr(...) over non-null assertions with good error messages explicitly stating violated assumptions
Avoid the any type; when necessary, leave a comment explaining why it's used, why the type system fails, and how errors would be caught at compile-, test-, or runtime

Files:

  • apps/backend/src/lib/new-transactions.ts
🧬 Code graph analysis (1)
apps/backend/src/lib/new-transactions.ts (7)
packages/stack-shared/src/schema-fields.ts (2)
  • inlineProductSchema (661-686)
  • productSchema (624-647)
packages/stack-shared/src/utils/strings.tsx (3)
  • typedToLowercase (15-18)
  • stringCompare (61-65)
  • typedToUppercase (30-33)
packages/stack-shared/src/utils/objects.tsx (1)
  • getOrUndefined (545-548)
apps/backend/src/lib/payments.tsx (2)
  • productToInlineProduct (427-443)
  • getSubscriptions (276-357)
apps/backend/src/prisma-client.tsx (1)
  • PrismaClientTransaction (25-27)
apps/backend/src/lib/tenancies.tsx (1)
  • Tenancy (53-53)
packages/stack-shared/src/utils/dates.tsx (3)
  • FAR_FUTURE_DATE (201-201)
  • getIntervalsElapsed (211-231)
  • addInterval (197-199)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: all-good
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: Vercel Agent Review
  • GitHub Check: restart-dev-and-test
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: docker
  • GitHub Check: setup-tests
🔇 Additional comments (7)
apps/backend/src/lib/new-transactions.ts (7)

520-521: net_amount defaults to "0" for non-USD refunds.

For refunds in currencies other than USD, net_amount is set to { USD: "0" }, losing refund amount information for financial tracking.

Likely an incorrect or invalid review comment.


421-422: Hardcoded adjusted_entry_index: 0 assumes entry order.

The assumption that product_grant is always at index 0 in the original transaction may not hold if the transaction structure changes.

Likely an incorrect or invalid review comment.


313-314: Hardcoded adjusted_entry_index: 0 may be incorrect.

The comment notes "Would need to be calculated properly." The hardcoded index assumes a specific entry order in the original transaction, which may not hold. This could cause incorrect transaction references.

Likely an incorrect or invalid review comment.


235-236: net_amount incorrectly defaults to USD.

The net_amount field always uses USD from chargedAmount, defaulting to "0" if USD is not present. For transactions in other currencies (EUR, GBP, etc.), this results in net_amount: { USD: "0" } even though the customer was charged in a different currency, causing incorrect financial reporting.

Likely an incorrect or invalid review comment.


465-466: Hardcoded adjusted_entry_index: 0 assumes entry order.

The assumption that active_sub_start is always at index 0 may not hold if the original transaction structure changes.

Likely an incorrect or invalid review comment.


509-511: Division by 100 assumes amounts are stored in cents.

This could fail for currencies with different decimal places (e.g., JPY has no decimal places, some currencies use 3 decimal places).

Likely an incorrect or invalid review comment.


365-366: net_amount incorrectly defaults to USD.

Same issue as lines 235 and 300: net_amount hardcodes USD.

Likely an incorrect or invalid review comment.

@cmux-agent
Copy link

cmux-agent bot commented Jan 8, 2026

Older cmux preview screenshots (latest comment is below)

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In
@apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql:
- Around line 5-21: The migration defines StripeRefund.amountCents as INTEGER;
change it to BIGINT to follow monetary best practices: update the CREATE TABLE
statement for "StripeRefund" replacing "amountCents" INTEGER NOT NULL with
"amountCents" BIGINT NOT NULL (and ensure any subsequent code or Prisma schema
uses BIGINT for StripeRefund.amountCents to keep types consistent).
🧹 Nitpick comments (2)
apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql (2)

36-37: Reconsider DEFAULT 1 for quantity fields.

Defaulting oldQuantity and newQuantity to 1 may misrepresent actual changes if callers omit these values. For example, a change from 0 to 5 items could be incorrectly recorded as 1 to 5, corrupting transaction history.

Consider making these fields nullable without defaults, requiring explicit values to ensure accuracy.

♻️ Suggested alternative
-    "oldQuantity" INTEGER NOT NULL DEFAULT 1,
-    "newQuantity" INTEGER NOT NULL DEFAULT 1,
+    "oldQuantity" INTEGER,
+    "newQuantity" INTEGER,

61-80: Consider additional indexes for common query patterns.

Current indexes cover foreign-key lookups well. Consider adding:

  • Index on SubscriptionChange.changeType if you'll filter by specific change types (e.g., "show all PRICE_CHANGE events")
  • Indexes on createdAt fields if you'll query by time ranges frequently

These can be added in a follow-up migration once query patterns are confirmed.

📊 Example additional indexes
-- If filtering by change type is common
CREATE INDEX "SubscriptionChange_tenancyId_changeType_idx" ON "SubscriptionChange"("tenancyId", "changeType");

-- If time-range queries are common
CREATE INDEX "StripeRefund_tenancyId_createdAt_idx" ON "StripeRefund"("tenancyId", "createdAt");
CREATE INDEX "ProductChange_tenancyId_createdAt_idx" ON "ProductChange"("tenancyId", "createdAt");
CREATE INDEX "SubscriptionChange_tenancyId_createdAt_idx" ON "SubscriptionChange"("tenancyId", "createdAt");
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 956c11f and 77c6637.

📒 Files selected for processing (1)
  • apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: setup-tests
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: all-good
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: docker
  • GitHub Check: restart-dev-and-test
🔇 Additional comments (2)
apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql (2)

1-2: LGTM: Well-defined enum for subscription change tracking.

The enum values comprehensively cover subscription lifecycle events with a sensible fallback (OTHER).


52-53: JSONB fields trade type safety for flexibility.

Using JSONB for oldValue and newValue allows storing arbitrary change data but prevents compile-time validation and complicates queries (e.g., filtering by specific field changes requires JSON path operators).

This is likely an acceptable tradeoff for an audit/history table where flexibility matters more than queryability.

@cmux-agent
Copy link

cmux-agent bot commented Jan 8, 2026

Older cmux preview screenshots (latest comment is below)

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In @apps/backend/src/lib/new-transactions.ts:
- Around line 213-287: The money_transfer entry in buildNewStripeSubTransaction
currently hardcodes net_amount as { USD: ... } which assumes USD; instead derive
net_amount from the actual chargedAmount keys (or make it optional) so the
currency matches the charged amount. Update the logic that builds the
money_transfer entry (where chargedAmount and net_amount are set) to: if
chargedAmount has a single currency key use that key/value for net_amount, if
multiple currencies decide on combining/choosing conversion or leave net_amount
undefined; ensure tests and callers expecting net_amount are adjusted
accordingly.
- Around line 1459-1491: The _nextOrPrev method currently returns an empty page
when processing item-quantity-renewal transactions if options.filter.customerId
is not provided; update the code and comments to make this limitation explicit:
replace the silent empty return with a thrown Error (e.g., throw new
Error("item-quantity-renewal requires customerId filter")) so callers see a
clear failure, add a TODO comment referencing a tracking issue ID above the
check to implement full customer iteration, and enhance the function JSDoc to
document the limitation for ItemQuantityRenewalData flows and how
getSubscriptions and options.filter.customerId are involved.

In @apps/backend/src/lib/stripe.tsx:
- Around line 196-211: The refund handling only sets oneTimePurchaseId but never
populates subscriptionId, causing buildStripeRefundTransaction to miss
subscription refunds; after the oneTimePurchase lookup that uses
stripePaymentIntentId, add a similar query to populate subscriptionId (e.g.,
call prisma.subscription.findFirst using stripePaymentIntentId or the
appropriate payment intent/invoice linkage in your schema) and assign
subscriptionId before building the refund transaction so adjusted_transaction_id
can use refund.subscriptionId when applicable.
🧹 Nitpick comments (3)
apps/backend/src/lib/new-transactions.ts (3)

183-207: Consider using dynamic currency handling and address decimal precision.

The function has a hardcoded list of 18 currency codes (line 193), which may be incomplete and requires maintenance when supporting new currencies. Additionally, the comment on line 198 indicates that decimal quantities are not properly handled, and using parseFloat with direct multiplication (lines 199-202) can introduce floating-point precision errors in financial calculations.

Consider:

  1. Iterating over all keys in the price object dynamically instead of maintaining a hardcoded currency list
  2. Using a decimal library or integer-based calculations (cents) to avoid floating-point precision issues
  3. Validating and documenting the expected behavior for decimal quantities
♻️ Suggested refactor
 function buildChargedAmount(
   product: InferType<typeof productSchema>,
   priceId: string | null,
   quantity: number
 ): Record<string, string> {
   if (!priceId || product.prices === "include-by-default") return {};
   const price = getOrUndefined(product.prices, priceId);
   if (!price) return {};
 
   const result: Record<string, string> = {};
-  const currencyCodes = ["USD", "EUR", "GBP", "CAD", "AUD", "JPY", "CNY", "INR", "BRL", "MXN", "CHF", "SGD", "HKD", "KRW", "SEK", "NOK", "DKK", "NZD"];
 
-  for (const code of currencyCodes) {
+  // Iterate over all currency keys in the price object
+  for (const code of Object.keys(price)) {
     const amount = price[code as keyof typeof price];
     if (typeof amount === "string" && amount !== "0") {
-      // Simple multiplication (assumes integer quantities for now)
       const numValue = parseFloat(amount);
       if (!isNaN(numValue)) {
+        // TODO: Consider using decimal library for precise money calculations
         result[code] = (numValue * quantity).toString();
       }
     }
   }
 
   return result;
 }

289-369: Optimize: avoid rebuilding the original transaction.

Line 296 calls buildNewStripeSubTransaction(subscription).entries just to extract the entries, which rebuilds the entire transaction unnecessarily. This is wasteful as it creates temporary objects that are immediately discarded.

♻️ Potential optimization

Consider extracting a helper function that returns just the entries for a subscription's included items, or pass the needed data directly rather than rebuilding the full transaction:

function getSubscriptionItemEntries(subscription: Subscription): NewTransactionEntry[] {
  const product = subscription.product as InferType<typeof productSchema>;
  const customerType = customerTypeToCrud(subscription.customerType);
  const entries: NewTransactionEntry[] = [];
  
  const includedItems = getOrUndefined(product, "includedItems") ?? {};
  for (const [itemId, itemConfig] of Object.entries(includedItems)) {
    const itemQuantity = (itemConfig as { quantity?: number }).quantity ?? 0;
    if (itemQuantity > 0) {
      entries.push({
        type: "item_quantity_change",
        adjusted_transaction_id: null,
        adjusted_entry_index: null,
        customer_type: customerType,
        customer_id: subscription.customerId,
        item_id: itemId,
        quantity: itemQuantity * subscription.quantity,
      });
    }
  }
  return entries;
}

1702-1710: Simplify cursor extraction logic.

The type union at line 1704 and the property checking at line 1708 are unnecessarily complex. According to the DatabasePaginatedList._nextOrPrev implementation (lines 802-811), all items have both itemCursor, prevCursor, and nextCursor properties set to the same value. The conditional check is therefore redundant.

♻️ Simplified implementation
   const hasMore = result.items.length > limit;
   const transactions = result.items.slice(0, limit).map((item) => item.item);
-  type ItemWithCursor = { itemCursor: string } | { nextCursor: string };
-  const lastItem = result.items[limit - 1] as ItemWithCursor | undefined;
+  const lastItem = result.items[limit - 1];
   const nextCursor = hasMore
-    ? (lastItem
-      ? ("itemCursor" in lastItem ? lastItem.itemCursor : lastItem.nextCursor)
-      : null)
+    ? (lastItem?.nextCursor ?? null)
     : null;
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 77c6637 and 73fec07.

📒 Files selected for processing (2)
  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/lib/stripe.tsx
🧰 Additional context used
📓 Path-based instructions (5)
**/*.{tsx,ts,jsx,js}

📄 CodeRabbit inference engine (AGENTS.md)

For blocking alerts and errors, never use toast; instead, use alerts as toasts are easily missed by the user

Files:

  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/lib/stripe.tsx
**/*.{tsx,ts}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if avoidable; prefer using client components instead to keep pages static (e.g., use usePathname instead of await params)

Files:

  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/lib/stripe.tsx
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: NEVER try-catch-all, NEVER void a promise, and NEVER use .catch(console.error) or similar; use loading indicators instead; if asynchronous handling is necessary, use runAsynchronously or runAsynchronouslyWithAlert instead
Use ES6 maps instead of records wherever possible

Files:

  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/lib/stripe.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Code defensively; prefer ?? throwErr(...) over non-null assertions with good error messages explicitly stating violated assumptions
Avoid the any type; when necessary, leave a comment explaining why it's used, why the type system fails, and how errors would be caught at compile-, test-, or runtime

Files:

  • apps/backend/src/lib/new-transactions.ts
  • apps/backend/src/lib/stripe.tsx
**/*.{tsx,css}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{tsx,css}: Keep hover/click animations snappy and fast; don't delay actions with pre-transitions (e.g., no fade-in on button hover) as it makes UI feel sluggish; instead apply transitions after the action like smooth fade-out when hover ends
When creating hover transitions, avoid hover-enter transitions and use only hover-exit transitions (e.g., transition-colors hover:transition-none)

Files:

  • apps/backend/src/lib/stripe.tsx
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: build (22.x)
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: docker
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: build (22.x)
  • GitHub Check: all-good
  • GitHub Check: setup-tests
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)

@cmux-agent
Copy link

cmux-agent bot commented Jan 8, 2026

Older cmux preview screenshots (latest comment is below)

Preview Screenshots

Open Workspace (1 hr expiry) · Open Dev Browser (1 hr expiry) · Open Diff Heatmap

Screenshot capture was skipped.

No UI changes detected - screenshots skipped


Generated by cmux preview system

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In @apps/backend/src/lib/new-transactions.ts:
- Around line 221-295: buildNewStripeSubTransaction currently uses an unsafe
assertion "as InferType<typeof productSchema>" on subscription.product which can
throw at runtime when properties like includedItems are accessed; validate
subscription.product against productSchema (e.g., call productSchema.isValidSync
or equivalent) before asserting or use a defensive check (ensure product is an
object and includedItems is present and an object) prior to calling
productToInlineProduct, buildChargedAmount, and iterating includedItems so you
either throw a clear error referencing subscription.id on invalid data or safely
skip/handle missing fields; apply the same defensive validation pattern used
here to other builder functions that assert productSchema.
- Around line 443-486: buildStripeExpireTransaction (and other builders using
adjusted_entry_index) relies on hard-coded indices (e.g., adjusted_entry_index:
0) which is fragile; change the transaction entry model to use stable
identifiers and references instead of numeric indices: add an entry_id field to
each BaseTransactionEntry when creating original transactions (e.g.,
product_grant entry_id like `${transaction.id}:product_grant`) and replace
adjusted_entry_index with adjusted_entry_id in all adjuster builders (e.g.,
buildStripeExpireTransaction, and the other builders flagged around lines
488-512, 566-645), setting adjusted_entry_id to the corresponding entry_id;
update the NewTransactionEntry/type definitions to include entry_id and
adjusted_entry_id and update all code that creates or looks up adjusted entries
to reference by ID (or implement a lookup helper that resolves entry_id from
entry type + context) so reordering cannot break references.
- Around line 538-564: The buildStripeRefundTransaction function incorrectly
sets net_amount to { USD: "0" } for non-USD refunds; update the logic in
buildStripeRefundTransaction to only include a USD key when refund.currency ===
"USD" (or perform/plug in real conversion if business requires USD amounts),
i.e., build net_amount as an object keyed by refund.currency with the
negativeAmount for native currency and omit USD for other currencies (or call
the currency conversion helper and populate USD appropriately), and ensure
charged_amount continues to use refund.currency and negativeAmount.
- Around line 328-341: The current fallback sets resolvedEntryIndex =
adjustedEntryIndex ?? 0 which can silently pick the wrong entry; change this to
fail fast by throwing a clear error when adjustedEntryIndex is null (e.g., if
adjustedEntryIndex === null) and include context (itemId, adjustedQuantity and a
reference to the parent transaction id or calling function like
buildNewStripeSubTransaction) so callers can diagnose the missing entry; update
any callers/tests to expect/handle the thrown error or alternatively store and
use explicit entry references when constructing originalEntries to avoid
index-based lookups.
- Around line 191-215: In buildChargedAmount, avoid parseFloat and
floating-point multiplication by parsing the price string into integer minor
units (cents) per currency (e.g., split on '.', pad/truncate to two decimals, or
detect if already cents), multiply that integer by quantity, then convert the
resulting cents back to a decimal string with exactly two fractional digits for
result[code]; update the logic around price[code] handling in buildChargedAmount
to perform integer arithmetic and handle malformed/absent decimals gracefully.
🧹 Nitpick comments (2)
apps/backend/src/lib/new-transactions.ts (2)

1492-1502: Incomplete implementation: Item quantity renewals require customerId filter.

The current implementation returns empty results when no customerId is provided (lines 1492-1502), with a comment noting this is incomplete. This limits the usefulness of the item-quantity-renewal transaction type for admin or aggregate queries.

This appears to be a known limitation. Do you want me to generate a solution that iterates through customers to support listing without the customerId filter, or would you prefer to track this as a follow-up issue?


1713-1721: Optional: Simplify cursor extraction logic.

The inline type definition and complex conditional (lines 1716-1720) for extracting nextCursor could be more readable.

♻️ Suggested simplification
   const hasMore = result.items.length > limit;
   const transactions = result.items.slice(0, limit).map((item) => item.item);
-  type ItemWithCursor = { itemCursor: string } | { nextCursor: string };
-  const lastItem = result.items[limit - 1] as ItemWithCursor | undefined;
-  const nextCursor = hasMore
-    ? (lastItem
-      ? ("itemCursor" in lastItem ? lastItem.itemCursor : lastItem.nextCursor)
-      : null)
-    : null;
+  const lastItem = result.items[limit - 1];
+  const nextCursor = hasMore && lastItem
+    ? (lastItem.itemCursor ?? lastItem.nextCursor)
+    : null;

This assumes both itemCursor and nextCursor can coexist as optional properties, which appears to be the case based on line 816.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 73fec07 and a91b8ff.

📒 Files selected for processing (1)
  • apps/backend/src/lib/new-transactions.ts
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{tsx,ts,jsx,js}

📄 CodeRabbit inference engine (AGENTS.md)

For blocking alerts and errors, never use toast; instead, use alerts as toasts are easily missed by the user

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{tsx,ts}

📄 CodeRabbit inference engine (AGENTS.md)

NEVER use Next.js dynamic functions if avoidable; prefer using client components instead to keep pages static (e.g., use usePathname instead of await params)

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: NEVER try-catch-all, NEVER void a promise, and NEVER use .catch(console.error) or similar; use loading indicators instead; if asynchronous handling is necessary, use runAsynchronously or runAsynchronouslyWithAlert instead
Use ES6 maps instead of records wherever possible

Files:

  • apps/backend/src/lib/new-transactions.ts
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: Code defensively; prefer ?? throwErr(...) over non-null assertions with good error messages explicitly stating violated assumptions
Avoid the any type; when necessary, leave a comment explaining why it's used, why the type system fails, and how errors would be caught at compile-, test-, or runtime

Files:

  • apps/backend/src/lib/new-transactions.ts
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (13)
  • GitHub Check: build (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: build (22.x)
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: setup-tests
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: restart-dev-and-test
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: all-good
  • GitHub Check: docker
  • GitHub Check: check_prisma_migrations (22.x)

@cmux-agent
Copy link

cmux-agent bot commented Jan 12, 2026

Older cmux preview screenshots (latest comment is below)

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In @apps/backend/prisma/schema.prisma:
- Around line 1049-1051: The StripeRefund model currently allows both or neither
of subscriptionId and oneTimePurchaseId despite the comment; add a
database-level CHECK constraint named like StripeRefund_source_xor_check that
enforces (subscriptionId IS NOT NULL) + (oneTimePurchaseId IS NOT NULL) == 1 in
the migration SQL, and update the Prisma schema to include relations from
StripeRefund to Subscription and OneTimePurchase (e.g., fields and @relation
attributes on subscription and oneTimePurchase with appropriate foreign key
names and onDelete behavior) so referential integrity and cascade rules match
other models like SubscriptionInvoice.
🧹 Nitpick comments (2)
apps/backend/prisma/schema.prisma (2)

1072-1102: Consider adding relations for referential integrity.

The ProductChange model lacks Prisma relations to Subscription and Tenancy. While this works, adding relations would provide:

  • Referential integrity with onDelete: Cascade
  • Easier querying via Prisma's relation loading
  • Protection against orphaned records

This is consistent with patterns like SubscriptionInvoice which relates to Subscription.


1114-1137: Add relation to Subscription for referential integrity.

SubscriptionChange has a required subscriptionId but no Prisma relation to Subscription. Without the relation:

  • Orphaned records can exist if a subscription is deleted
  • No cascade delete behavior
  • Manual joins required for queries
Suggested addition
   subscriptionId String       @db.Uuid
   customerId     String
   customerType   CustomerType
+
+  subscription Subscription @relation(fields: [tenancyId, subscriptionId], references: [tenancyId, id], onDelete: Cascade)

Note: This would also require adding a back-reference array in the Subscription model.

📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a91b8ff and 08b27cb.

📒 Files selected for processing (1)
  • apps/backend/prisma/schema.prisma
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-01-07T00:55:19.871Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-07T00:55:19.871Z
Learning: The project uses PostgreSQL with Prisma ORM for database management; database models are located in `/apps/backend/src`

Applied to files:

  • apps/backend/prisma/schema.prisma
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (14)
  • GitHub Check: Test migrations with main branch code
  • GitHub Check: Vercel Agent Review
  • GitHub Check: setup-tests
  • GitHub Check: all-good
  • GitHub Check: setup-tests-with-custom-base-port
  • GitHub Check: build (22.x)
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: docker
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
🔇 Additional comments (1)
apps/backend/prisma/schema.prisma (1)

1104-1111: LGTM!

The enum provides good coverage of subscription change types with an OTHER catch-all for edge cases.

@BilalG1 BilalG1 requested a review from N2D4 January 13, 2026 17:17
@BilalG1 BilalG1 removed their assignment Jan 13, 2026
@cmux-agent
Copy link

cmux-agent bot commented Jan 13, 2026

Preview Screenshots

Open Diff Heatmap

Preview screenshots are being captured...

Workspace and dev browser links will appear here once the preview environment is ready.


Generated by cmux preview system

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In @apps/backend/src/lib/new-transactions.ts:
- Around line 449-472: The code assumes fixed entry indices; instead compute the
correct adjusted_entry_index by locating the product_grant entry in the original
transaction rather than hardcoding 0 or starting included items at 1: find the
index of the entry with type "product_grant" (from the original transaction
built by buildNewStripeSubTransaction) and use that index for the
product_revocation's adjusted_entry_index, then set entryIndex =
productGrantIndex + 1 and increment for each included item; update references to
subscription.id and entries.push(...) accordingly so indices reflect the actual
entry ordering (handle the optional money_transfer/active_sub_start presence by
deriving the index at runtime).

In @packages/stack-shared/src/utils/paginated-lists.tsx:
- Around line 362-363: The JSON.parse(cursor) in _nextOrPrev is unsafe; wrap
parsing in a try/catch, verify the result is an array (Array.isArray), each
element is a string, and that its length matches the number of lists expected
(e.g., this.lists.length or the lists variable used by _nextOrPrev); if any
check fails throw a clear Error mentioning "invalid cursor" and what was
expected (malformed JSON, not an array, non-string elements, or wrong length).
Ensure subsequent code uses the validated 'cursors' array.
🧹 Nitpick comments (7)
apps/dashboard/src/components/data-table/transaction-table.tsx (1)

37-37: The RefundableTransactionType alias adds no value.

Since TransactionType doesn't include null, the union TransactionType | null is only useful if the function parameter can be null. However, the guard isRefundableTransactionType is meant to narrow from TransactionType | null to a subset of TransactionType, but the return type annotation says it narrows to TransactionType (the full union), which doesn't accurately describe the narrowing behavior.

Consider defining the type to represent the actual refundable subset:

-type RefundableTransactionType = TransactionType | null;
+type RefundableTransactionType = 'purchase' | 'new-stripe-sub' | 'stripe-one-time';

Then update the guard accordingly:

-function isRefundableTransactionType(transactionType: RefundableTransactionType): transactionType is TransactionType {
+function isRefundableTransactionType(transactionType: TransactionType | null): transactionType is RefundableTransactionType {
packages/stack-shared/src/interface/crud/transactions.ts (1)

14-18: The validation test is always true for non-empty objects.

The test Object.values(value).some((amount) => typeof amount === "string") will always pass for any non-empty object with string values, which is already guaranteed by the schema's value type (yupString().defined()). The test adds no additional validation beyond what the schema already enforces.

If the intent is to ensure the object is non-empty, consider:

Suggested improvement
 const chargedAmountSchema = yupRecord(yupString(), yupString().defined()).test(
   "at-least-one-currency",
   "charged_amount must include at least one currency amount",
-  (value) => Object.values(value).some((amount) => typeof amount === "string"),
+  (value) => value != null && Object.keys(value).length > 0,
 ).defined();
apps/backend/src/lib/new-transactions.ts (2)

200-212: Potential floating-point precision issues in financial calculations.

Using parseFloat and floating-point multiplication for currency amounts can introduce precision errors. For financial calculations, consider using a decimal library or integer-based cent calculations.

Additionally, the hardcoded currency list on line 201 may not cover all currencies configured in products.

Consider using integer arithmetic for precision
-  for (const code of currencyCodes) {
-    const amount = price[code as keyof typeof price];
-    if (typeof amount === "string" && amount !== "0") {
-      // Simple multiplication (assumes integer quantities for now)
-      const numValue = parseFloat(amount);
-      if (!isNaN(numValue)) {
-        result[code] = (numValue * quantity).toString();
-      }
-    }
-  }
+  for (const code of currencyCodes) {
+    const amount = price[code as keyof typeof price];
+    if (typeof amount === "string" && amount !== "0") {
+      // Use string-based multiplication to preserve precision
+      // Convert to cents, multiply, convert back
+      const parts = amount.split(".");
+      const cents = parseInt(parts[0], 10) * 100 + (parts[1] ? parseInt(parts[1].padEnd(2, "0").slice(0, 2), 10) : 0);
+      const totalCents = cents * quantity;
+      result[code] = (totalCents / 100).toFixed(2);
+    }
+  }

1484-1502: Potential issue with empty customerId passed to getSubscriptions.

When options.filter.customerId is undefined, an empty string "" is passed to getSubscriptions (line 1488). This call happens before the guard that returns early on lines 1492-1501. Depending on how getSubscriptions handles an empty customerId, this could cause unexpected behavior or errors.

Consider moving the guard check before the getSubscriptions call:

Move guard before database call
   override async _nextOrPrev(...) {
     const now = new Date();
     const renewals: ItemQuantityRenewalData[] = [];

+    // Need customerId for customer-specific renewals
+    if (!options.filter.customerId) {
+      return {
+        items: [],
+        isFirst: true,
+        isLast: true,
+        cursor: options.cursor,
+      };
+    }
+
     // Get all subscriptions for the tenancy
     const subscriptions = await getSubscriptions({
       prisma: this.prisma,
       tenancy: this.tenancy,
       customerType: options.filter.customerType ?? "user",
-      customerId: options.filter.customerId ?? "",
+      customerId: options.filter.customerId,
     });

-    // If filtering by customer, we need the customerId
-    if (!options.filter.customerId) {
-      // ...
-      return { ... };
-    }
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts (1)

250-253: Consider adding more specific assertions after refund.

The assertion on line 253 only checks that the original purchase transaction still exists. Consider also verifying that a stripe-refund transaction was created, which would provide better coverage of the refund flow.

// Verify refund transaction was created
const refundTx = transactionsAfterRefund.body.transactions.find((tx: any) => tx.type === "stripe-refund");
expect(refundTx).toBeDefined();
apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts (2)

134-147: Duplicated sendStripeWebhook helper.

This sendStripeWebhook function duplicates Payments.sendStripeWebhook from backend-helpers.ts (shown in relevant code snippets at lines 1524-1551). Consider using the existing helper to avoid duplication.

-const stripeWebhookSecret = process.env.STACK_STRIPE_WEBHOOK_SECRET ?? "mock_stripe_webhook_secret";
-
-async function sendStripeWebhook(payload: unknown) {
-  const timestamp = Math.floor(Date.now() / 1000);
-  const hmac = createHmac("sha256", stripeWebhookSecret);
-  hmac.update(`${timestamp}.${JSON.stringify(payload)}`);
-  const signature = hmac.digest("hex");
-  return await niceBackendFetch("/api/latest/integrations/stripe/webhooks", {
-    method: "POST",
-    headers: {
-      "content-type": "application/json",
-      "stripe-signature": `t=${timestamp},v1=${signature}`,
-    },
-    body: payload,
-  });
-}
+// Use PaymentsHelper.sendStripeWebhook instead of local implementation

Then update all calls to use PaymentsHelper.sendStripeWebhook(payload) instead of sendStripeWebhook(payload).


881-918: Redundant product-change empty list tests.

The three tests at lines 881-892, 894-905, and 907-918 are nearly identical - they all verify that an empty list is returned when no product changes exist. Consider consolidating into a single test to reduce redundancy.

-it("product-change: returns empty list when no product changes exist", async () => {
-  // ... identical assertions
-});
-
-it("product-change: remains empty without explicit product change records", async () => {
-  // ... identical assertions
-});
-
-it("product-change: stays empty when no product change entries are recorded", async () => {
-  // ... identical assertions
-});
+it("product-change: returns empty list when no product changes exist", async () => {
+  await setupProjectWithPaymentsConfig();
+
+  const response = await niceBackendFetch(NEW_TRANSACTIONS_ENDPOINT, {
+    accessType: "admin",
+    query: { type: "product-change" },
+  });
+  expect(response.status).toBe(200);
+  expect(response.body.transactions).toEqual([]);
+  expect(response.body.has_more).toBe(false);
+  expect(response.body.next_cursor).toBeNull();
+});
📜 Review details

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 08b27cb and 977836b.

📒 Files selected for processing (13)
  • apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
  • apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts
  • apps/backend/src/lib/new-transactions.ts
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/dashboard/src/components/data-table/transaction-table.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
  • packages/stack-shared/src/interface/admin-interface.ts
  • packages/stack-shared/src/interface/crud/transactions.ts
  • packages/stack-shared/src/utils/paginated-lists.tsx
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
💤 Files with no reviewable changes (2)
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts
🧰 Additional context used
📓 Path-based instructions (4)
**/*.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx,js,jsx}: For blocking alerts and errors, never use toast, as they are easily missed by the user. Instead, use alerts
Keep hover/click transitions snappy and fast without pre-transition delays (e.g., no fade-in when hovering a button). Apply transitions after the action, like smooth fade-out when hover ends
NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error). Use loading indicators for async operations. Use runAsynchronously or runAsynchronouslyWithAlert instead of general try-catch error handling
When creating hover transitions, avoid hover-enter transitions and use only hover-exit transitions (e.g., transition-colors hover:transition-none)
Don't use Date.now() for measuring elapsed (real) time; instead use performance.now()
Use ES6 maps instead of records wherever possible

Files:

  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
  • apps/dashboard/src/components/data-table/transaction-table.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • apps/backend/src/lib/new-transactions.ts
  • packages/stack-shared/src/interface/admin-interface.ts
  • apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
  • packages/stack-shared/src/interface/crud/transactions.ts
  • packages/stack-shared/src/utils/paginated-lists.tsx
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.{ts,tsx}: NEVER use Next.js dynamic functions if you can avoid them. Prefer using client components to keep pages static (e.g., use usePathname instead of await params)
Code defensively using ?? throwErr(...) instead of non-null assertions, with good error messages explicitly stating violated assumptions
Try to avoid the any type. When using any, leave a comment explaining why and how the type system fails or how errors would still be caught

Files:

  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
  • apps/dashboard/src/components/data-table/transaction-table.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • apps/backend/src/lib/new-transactions.ts
  • packages/stack-shared/src/interface/admin-interface.ts
  • apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
  • packages/stack-shared/src/interface/crud/transactions.ts
  • packages/stack-shared/src/utils/paginated-lists.tsx
{.env*,**/*.{ts,tsx,js,jsx}}

📄 CodeRabbit inference engine (AGENTS.md)

All environment variables should be prefixed with STACK_ (or NEXT_PUBLIC_STACK_ if public) to ensure Turborepo picks up changes and improve readability

Files:

  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
  • apps/dashboard/src/components/data-table/transaction-table.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
  • packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts
  • apps/backend/src/lib/new-transactions.ts
  • packages/stack-shared/src/interface/admin-interface.ts
  • apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
  • packages/stack-shared/src/interface/crud/transactions.ts
  • packages/stack-shared/src/utils/paginated-lists.tsx
**/*.test.{ts,tsx,js,jsx}

📄 CodeRabbit inference engine (AGENTS.md)

**/*.test.{ts,tsx,js,jsx}: Always add new E2E tests when changing the API or SDK interface, erring on the side of creating too many tests due to the critical nature of the industry
Use .toMatchInlineSnapshot over other selectors in tests when possible, and check/modify snapshot-serializer.ts to understand how snapshots are formatted

Files:

  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
🧠 Learnings (4)
📚 Learning: 2026-01-13T18:14:29.974Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-13T18:14:29.974Z
Learning: Applies to packages/stack-shared/src/config/schema.ts : Whenever making backwards-incompatible changes to the config schema, update the migration functions in `packages/stack-shared/src/config/schema.ts`

Applied to files:

  • packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts
  • packages/stack-shared/src/interface/crud/transactions.ts
📚 Learning: 2026-01-13T18:14:29.974Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-13T18:14:29.974Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Always add new E2E tests when changing the API or SDK interface, erring on the side of creating too many tests due to the critical nature of the industry

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts
📚 Learning: 2026-01-13T18:14:29.974Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-13T18:14:29.974Z
Learning: Applies to **/*.test.{ts,tsx,js,jsx} : Use `.toMatchInlineSnapshot` over other selectors in tests when possible, and check/modify snapshot-serializer.ts to understand how snapshots are formatted

Applied to files:

  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
📚 Learning: 2026-01-13T18:14:29.974Z
Learnt from: CR
Repo: stack-auth/stack-auth PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-01-13T18:14:29.974Z
Learning: Applies to **/*.{ts,tsx} : Code defensively using `?? throwErr(...)` instead of non-null assertions, with good error messages explicitly stating violated assumptions

Applied to files:

  • apps/backend/src/lib/new-transactions.ts
🧬 Code graph analysis (7)
packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)
packages/stack-shared/src/interface/crud/transactions.ts (1)
  • Transaction (164-164)
apps/dashboard/src/components/data-table/transaction-table.tsx (1)
packages/stack-shared/src/interface/crud/transactions.ts (2)
  • TransactionType (147-147)
  • Transaction (164-164)
apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts (2)
apps/e2e/tests/backend/backend-helpers.ts (2)
  • niceBackendFetch (109-173)
  • sendStripeWebhook (1525-1552)
apps/e2e/tests/helpers.ts (1)
  • it (12-12)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (2)
packages/template/src/lib/stack-app/apps/implementations/common.ts (2)
  • createCache (29-34)
  • useAsyncCache (163-214)
packages/stack-shared/src/interface/crud/transactions.ts (2)
  • TransactionType (147-147)
  • Transaction (164-164)
packages/stack-shared/src/interface/admin-interface.ts (1)
packages/stack-shared/src/interface/crud/transactions.ts (2)
  • TransactionType (147-147)
  • Transaction (164-164)
packages/stack-shared/src/interface/crud/transactions.ts (1)
packages/stack-shared/src/schema-fields.ts (6)
  • yupRecord (283-322)
  • yupString (187-190)
  • yupObject (247-251)
  • yupNumber (191-194)
  • customerTypeSchema (602-602)
  • yupUnion (257-281)
packages/stack-shared/src/utils/paginated-lists.tsx (1)
packages/stack-shared/src/utils/errors.tsx (1)
  • StackAssertionError (69-85)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (15)
  • GitHub Check: Vercel Agent Review
  • GitHub Check: Test migrations with main branch code
  • GitHub Check: Cursor Bugbot
  • GitHub Check: restart-dev-and-test-with-custom-base-port
  • GitHub Check: build (22.x)
  • GitHub Check: restart-dev-and-test
  • GitHub Check: docker
  • GitHub Check: check_prisma_migrations (22.x)
  • GitHub Check: setup-tests
  • GitHub Check: lint_and_build (latest)
  • GitHub Check: all-good
  • GitHub Check: E2E Tests (Node 22.x, Freestyle mock)
  • GitHub Check: build (22.x)
  • GitHub Check: E2E Tests (Node 22.x, Freestyle prod)
  • GitHub Check: setup-tests-with-custom-base-port
🔇 Additional comments (25)
packages/stack-shared/src/utils/paginated-lists.tsx (3)

102-102: LGTM!

The refactored condition is clearer and equivalent to the previous logic.


110-122: LGTM!

Good defensive guard to prevent infinite loops from faulty _nextOrPrev implementations. The diagnostic data included in the error will help debugging.


373-397: LGTM. The composite cursor construction logic correctly handles both 'next' and 'prev' directions by setting appropriate start cursors based on direction and building up composite cursors as items are processed in sorted order.

packages/template/src/lib/stack-app/apps/interfaces/admin-app.ts (1)

51-62: LGTM! Clean interface extension for per-customer filtering and improved pagination.

The addition of customerId parameter and hasMore return field aligns well with the backend changes for the unified transaction listing API.

apps/dashboard/src/components/data-table/transaction-table.tsx (3)

173-181: LGTM! The type cast aligns with the updated schema.

The charged_amount schema now uses yupRecord(yupString(), yupString().defined()), which produces Record<string, string>. The cast ensures type safety in the display logic.


86-109: LGTM! Comprehensive labeling for new transaction types.

The new transaction type cases are well-structured with appropriate labels and icons that clearly communicate the nature of each transaction.


55-73: LGTM! Refund eligibility logic is correctly implemented.

The isRefundableTransactionType guard and getRefundTarget function work together to properly determine which transactions can be refunded based on the transaction type and associated product grant entries.

packages/stack-shared/src/interface/admin-interface.ts (1)

597-611: LGTM! Clean extension of the listTransactions API.

The method correctly:

  • Adds customerId parameter with proper snake_case query parameter mapping (customer_id)
  • Extracts the new has_more field from the response
  • Maintains consistent camelCase naming in the return type (hasMore, nextCursor)
packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts (3)

76-78: LGTM! Cache key correctly extended with customerId.

Including customerId in the cache key tuple ensures that transaction listings for different customers are properly isolated in the cache.


608-611: LGTM! Method signature and implementation correctly extended.

The listTransactions method properly passes the customerId parameter to the cache and returns the hasMore flag from the cached result.


855-858: LGTM! Hook correctly mirrors the async method signature.

The useTransactions hook properly includes customerId in both the parameters and the cache dependency array.

packages/stack-shared/src/interface/crud/transactions.ts (5)

20-22: Verify USD-only net_amount is intentional.

The netAmountSchema now only supports USD. If multi-currency support is needed in the future, this schema would need to be updated. Confirm this aligns with the intended scope of the transactions rework.


69-100: LGTM! Well-structured active subscription lifecycle schemas.

The schema design correctly reflects the lifecycle:

  • active_sub_start: Nullable adjusted fields (new subscriptions don't adjust prior transactions)
  • active_sub_change/active_sub_stop: Required adjusted fields (these always reference prior subscription start transactions)

109-115: LGTM! Item quantity expiration schema is correctly defined.

The addition of item_id allows tracking which specific item's quantity is expiring, and the required adjusted fields correctly reference the original grant transaction.


117-126: LGTM! Transaction entry union comprehensively updated.

The union correctly includes all new entry schemas alongside the existing ones.


130-145: LGTM! Comprehensive transaction type expansion.

The new transaction types cover the full Stripe subscription lifecycle (creation, renewal, cancellation, refund) and item quantity renewal. The naming convention is consistent with existing types.

apps/backend/src/lib/new-transactions.ts (2)

1636-1722: LGTM - Well-structured unified transaction listing.

The listTransactions function properly:

  • Creates paginated lists only for requested transaction types
  • Handles empty list case gracefully
  • Merges multiple sources with proper ordering
  • Returns consistent pagination response structure

170-181: The cursor decoding logic is correct and properly handles edge cases.

The decodeCursor function works as designed. encodeCursor always produces cursors in the strict format cursor:${createdAt.toISOString()}:${id}, where toISOString() generates full ISO 8601 timestamps like 2024-01-15T10:30:45.123Z. This format uses hyphens in the date portion, not colons—colons only appear in the time portion. The lastIndexOf(":") correctly identifies the final colon separating the ISO date string from the ID. Edge cases like empty dateStr or invalid dates are properly caught by the Number.isNaN(createdAt.getTime()) check. The function is defensive and correct.

Likely an incorrect or invalid review comment.

apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts (1)

191-239: LGTM - Snapshot properly updated for new transaction structure.

The inline snapshot correctly includes:

  • has_more field in response
  • money_transfer entry before product_grant
  • Updated type from "purchase" to "stripe-one-time"

Good use of .toMatchInlineSnapshot as per coding guidelines.

apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts (2)

142-151: LGTM - New active_sub_start entry correctly added.

The snapshot now properly includes the active_sub_start entry that precedes product_grant in new-stripe-sub transactions, matching the implementation in buildNewStripeSubTransaction.


521-533: Good defensive filtering for entries without customer_type.

The .filter(e => "customer_type" in e) pattern correctly handles entries like product_revocation that don't have a customer_type field, preventing false negatives in the customer type filtering tests.

apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx (1)

58-86: LGTM - Clean route handler refactor.

The handler now properly:

  • Validates transaction type against NEW_TRANSACTION_TYPES
  • Clamps limit to reasonable range (1-200)
  • Delegates to unified listTransactions function
  • Returns consistent response shape with has_more
apps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.ts (3)

1-43: Excellent comprehensive E2E test documentation.

The file header clearly documents all transaction types and their entry types, which serves as good documentation for the new transaction system. This follows the coding guideline to err on the side of creating too many tests.


1029-1066: LGTM - Comprehensive pagination test.

The test properly verifies:

  • Correct page sizes
  • has_more flag behavior
  • next_cursor presence
  • No duplicate IDs across pages

1154-1225: LGTM - Good filter test coverage.

The filter tests properly verify:

  • Transaction type filtering
  • Customer type filtering across sources
  • Customer ID filtering

The defensive .filter(e => "customer_type" in e) pattern correctly handles entries without the field.

Comment on lines +449 to +472
// product-revocation entry (adjusts the original product grant)
entries.push({
type: "product_revocation",
adjusted_transaction_id: subscription.id,
adjusted_entry_index: 0, // Index of product_grant in original transaction
quantity: subscription.quantity,
});

// item-quant-expire entries for included items
const includedItems = getOrUndefined(product, "includedItems") ?? {};
let entryIndex = 1; // Start after product_grant
for (const [itemId, itemConfig] of Object.entries(includedItems)) {
const config = itemConfig as { quantity?: number, expires?: string };
if (config.expires === "when-purchase-expires" && (config.quantity ?? 0) > 0) {
entries.push({
type: "item_quantity_expire",
adjusted_transaction_id: subscription.id,
adjusted_entry_index: entryIndex,
item_id: itemId,
quantity: (config.quantity ?? 0) * subscription.quantity,
});
entryIndex++;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Hardcoded entry indices are fragile.

The adjusted_entry_index: 0 on line 453 assumes product_grant is always the first entry in the original transaction, but buildNewStripeSubTransaction puts active_sub_start first (line 231), then optionally money_transfer, then product_grant. This means the index could be 1 or 2 depending on whether money_transfer was included.

Similarly, entryIndex starting at 1 on line 459 assumes a fixed entry structure.

Consider dynamic index lookup instead of hardcoded values
   // product-revocation entry (adjusts the original product grant)
+  // Find the actual index of product_grant in the original transaction
+  const originalEntries = buildNewStripeSubTransaction(subscription).entries;
+  const productGrantIndex = originalEntries.findIndex(e => e.type === "product_grant");
   entries.push({
     type: "product_revocation",
     adjusted_transaction_id: subscription.id,
-    adjusted_entry_index: 0, // Index of product_grant in original transaction
+    adjusted_entry_index: productGrantIndex !== -1 ? productGrantIndex : 0,
     quantity: subscription.quantity,
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// product-revocation entry (adjusts the original product grant)
entries.push({
type: "product_revocation",
adjusted_transaction_id: subscription.id,
adjusted_entry_index: 0, // Index of product_grant in original transaction
quantity: subscription.quantity,
});
// item-quant-expire entries for included items
const includedItems = getOrUndefined(product, "includedItems") ?? {};
let entryIndex = 1; // Start after product_grant
for (const [itemId, itemConfig] of Object.entries(includedItems)) {
const config = itemConfig as { quantity?: number, expires?: string };
if (config.expires === "when-purchase-expires" && (config.quantity ?? 0) > 0) {
entries.push({
type: "item_quantity_expire",
adjusted_transaction_id: subscription.id,
adjusted_entry_index: entryIndex,
item_id: itemId,
quantity: (config.quantity ?? 0) * subscription.quantity,
});
entryIndex++;
}
}
// product-revocation entry (adjusts the original product grant)
// Find the actual index of product_grant in the original transaction
const originalEntries = buildNewStripeSubTransaction(subscription).entries;
const productGrantIndex = originalEntries.findIndex(e => e.type === "product_grant");
entries.push({
type: "product_revocation",
adjusted_transaction_id: subscription.id,
adjusted_entry_index: productGrantIndex !== -1 ? productGrantIndex : 0,
quantity: subscription.quantity,
});
// item-quant-expire entries for included items
const includedItems = getOrUndefined(product, "includedItems") ?? {};
let entryIndex = 1; // Start after product_grant
for (const [itemId, itemConfig] of Object.entries(includedItems)) {
const config = itemConfig as { quantity?: number, expires?: string };
if (config.expires === "when-purchase-expires" && (config.quantity ?? 0) > 0) {
entries.push({
type: "item_quantity_expire",
adjusted_transaction_id: subscription.id,
adjusted_entry_index: entryIndex,
item_id: itemId,
quantity: (config.quantity ?? 0) * subscription.quantity,
});
entryIndex++;
}
}
🤖 Prompt for AI Agents
In @apps/backend/src/lib/new-transactions.ts around lines 449 - 472, The code
assumes fixed entry indices; instead compute the correct adjusted_entry_index by
locating the product_grant entry in the original transaction rather than
hardcoding 0 or starting included items at 1: find the index of the entry with
type "product_grant" (from the original transaction built by
buildNewStripeSubTransaction) and use that index for the product_revocation's
adjusted_entry_index, then set entryIndex = productGrantIndex + 1 and increment
for each included item; update references to subscription.id and
entries.push(...) accordingly so indices reflect the actual entry ordering
(handle the optional money_transfer/active_sub_start presence by deriving the
index at runtime).

Comment on lines +545 to +553
const entries: NewTransactionEntry[] = [{
type: "money_transfer",
adjusted_transaction_id: refund.subscriptionId ?? refund.oneTimePurchaseId ?? null,
adjusted_entry_index: 0, // Assumes money_transfer is first entry in original
customer_type: customerType,
customer_id: refund.customerId,
charged_amount: { [refund.currency]: negativeAmount },
net_amount: { USD: refund.currency === "USD" ? negativeAmount : "0" },
}];
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Refund transaction assumes money_transfer is first entry.

The comment on line 548 acknowledges this assumption, but buildStripeOneTimeTransaction only includes money_transfer as the first entry when !testMode (line 389). If the original purchase was test mode, this index would be incorrect.

Comment on lines +362 to +363
override async _nextOrPrev(type: 'next' | 'prev', { limit, filter, orderBy, cursor }: ImplQueryOptions<'next' | 'prev', string, Filter, OrderBy>) {
const cursors = JSON.parse(cursor) as string[];
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add defensive validation for parsed cursor.

The JSON.parse(cursor) as string[] is unsafe. If the cursor is malformed or the array length doesn't match the number of lists, the error will be obscure. Per coding guidelines, code defensively with good error messages.

🛡️ Proposed defensive parsing
       override async _nextOrPrev(type: 'next' | 'prev', { limit, filter, orderBy, cursor }: ImplQueryOptions<'next' | 'prev', string, Filter, OrderBy>) {
-        const cursors = JSON.parse(cursor) as string[];
+        let cursors: string[];
+        try {
+          const parsed = JSON.parse(cursor);
+          if (!Array.isArray(parsed) || parsed.length !== lists.length) {
+            throw new StackAssertionError("Invalid merged cursor: expected array with length matching lists count", {
+              cursor,
+              parsedLength: Array.isArray(parsed) ? parsed.length : undefined,
+              expectedLength: lists.length,
+            });
+          }
+          cursors = parsed as string[];
+        } catch (e) {
+          if (e instanceof StackAssertionError) throw e;
+          throw new StackAssertionError("Invalid merged cursor: failed to parse as JSON array", { cursor, cause: e });
+        }
         const fetchedLists = await Promise.all(lists.map(async (list, i) => {
🤖 Prompt for AI Agents
In @packages/stack-shared/src/utils/paginated-lists.tsx around lines 362 - 363,
The JSON.parse(cursor) in _nextOrPrev is unsafe; wrap parsing in a try/catch,
verify the result is an array (Array.isArray), each element is a string, and
that its length matches the number of lists expected (e.g., this.lists.length or
the lists variable used by _nextOrPrev); if any check fails throw a clear Error
mentioning "invalid cursor" and what was expected (malformed JSON, not an array,
non-string elements, or wrong length). Ensure subsequent code uses the validated
'cursors' array.

amountCents: refund.amount,
currency: refund.currency.toUpperCase(),
reason: refund.reason ?? null,
},
Copy link

Choose a reason for hiding this comment

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

Subscription refunds missing source linkage to subscription

High Severity

The handleStripeRefund function only looks up and sets oneTimePurchaseId but never attempts to look up or set subscriptionId. The schema comment on StripeRefund explicitly states "exactly one of subscriptionId or oneTimePurchaseId must be set", but for subscription refunds, neither field will be populated. This causes buildStripeRefundTransaction to set adjusted_transaction_id: null instead of referencing the subscription, breaking the transaction adjustment chain for subscription refunds.

Fix in Cursor Fix in Web

adjusted_transaction_id: subscription.id,
adjusted_entry_index: 0, // Index of product_grant in original transaction
quantity: subscription.quantity,
});
Copy link

Choose a reason for hiding this comment

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

Incorrect adjusted_entry_index references wrong original entry

Medium Severity

Several transaction builders use adjusted_entry_index: 0 with comments claiming it references product_grant or money_transfer, but in buildNewStripeSubTransaction the entry order is: index 0 = active_sub_start, index 1 = money_transfer (if present), index 2+ = product_grant. The hardcoded index 0 incorrectly points to active_sub_start instead of the intended entry type, causing incorrect adjustment references in expire, refund, and product-change transactions.

Additional Locations (2)

Fix in Cursor Fix in Web

const createdAt = new Date(dateStr);
if (!id || Number.isNaN(createdAt.getTime())) return null;
return { createdAt, id };
}
Copy link

Choose a reason for hiding this comment

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

Cursor encoding fails for IDs containing colons

High Severity

The encodeCursor and decodeCursor functions use : as a delimiter between the ISO date and ID, but decodeCursor uses lastIndexOf(":") to find this delimiter. Several transaction types generate IDs containing colons: stripe-expire uses ${id}:expire, stripe-sub-cancel uses ${id}:cancel, and item-quantity-renewal uses ${id}:renewal:${itemId}:${i}. When these cursors are decoded, lastIndexOf finds the wrong colon, causing the date string to include part of the ID. This makes new Date() return an invalid date, causing decodeCursor to return null, which breaks pagination by restarting from the beginning and causing duplicate results.

Additional Locations (2)

Fix in Cursor Fix in Web

Comment on lines +1487 to +1488
customerType: options.filter.customerType ?? "user",
customerId: options.filter.customerId ?? "",
Copy link

Choose a reason for hiding this comment

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

When options.filter.customerId is undefined, an empty string is passed to getSubscriptions, which will not find the intended subscriptions and return no results.

View Details
📝 Patch Details
diff --git a/apps/backend/src/lib/new-transactions.ts b/apps/backend/src/lib/new-transactions.ts
index 2057c796..d81631c3 100644
--- a/apps/backend/src/lib/new-transactions.ts
+++ b/apps/backend/src/lib/new-transactions.ts
@@ -1480,14 +1480,6 @@ class ItemQuantityRenewalPaginatedList extends PaginatedList<
     const now = new Date();
     const renewals: ItemQuantityRenewalData[] = [];
 
-    // Get all subscriptions for the tenancy
-    const subscriptions = await getSubscriptions({
-      prisma: this.prisma,
-      tenancy: this.tenancy,
-      customerType: options.filter.customerType ?? "user",
-      customerId: options.filter.customerId ?? "",
-    });
-
     // If filtering by customer, we need the customerId
     if (!options.filter.customerId) {
       // Need to get all customers with subscriptions that have repeating items
@@ -1501,6 +1493,14 @@ class ItemQuantityRenewalPaginatedList extends PaginatedList<
       };
     }
 
+    // Get all subscriptions for the tenancy
+    const subscriptions = await getSubscriptions({
+      prisma: this.prisma,
+      tenancy: this.tenancy,
+      customerType: options.filter.customerType ?? "user",
+      customerId: options.filter.customerId,
+    });
+
     for (const subscription of subscriptions) {
       if (!subscription.id) continue; // Skip default subscriptions
 

Analysis

Unnecessary empty string passed to getSubscriptions in ItemQuantityRenewalPaginatedList

What fails: ItemQuantityRenewalPaginatedList._nextOrPrev() passes an empty string as customerId to getSubscriptions (line 1488) when options.filter.customerId is undefined, causing an unnecessary database query for subscriptions with customerId = "" that will always return no results.

How to reproduce:

  1. Call listTransactions() with filter type "item-quantity-renewal" and no customerId filter
  2. Observe that getSubscriptions performs a database query searching for subscriptions with customerId = ""
  3. The function immediately returns empty anyway due to the guard at line 1492

Result: Wasted database query and application resources when customerId is not provided

Expected: Check if customerId is provided BEFORE calling getSubscriptions, avoiding the unnecessary query. This matches the pattern used in other PaginatedList implementations like NewStripeSubPaginatedList which conditionally add the customerId filter only when provided.

Fix: Move the customerId guard check before the getSubscriptions call and pass the customerId directly without the empty string fallback (since we've already verified it exists at that point).

@BilalG1 BilalG1 closed this Jan 13, 2026
const amount = price[code as keyof typeof price];
if (typeof amount === "string" && amount !== "0") {
// Simple multiplication (assumes integer quantities for now)
const numValue = parseFloat(amount);
Copy link
Contributor

Choose a reason for hiding this comment

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

i don't think we should ever parse currency values as floats. currencies should always be integers or strings (can you also fix this everywhere else with payments code?)

Comment on lines +164 to +181
type TransactionCursor = `cursor:${string}:${string}` | "first" | "last";

function encodeCursor(createdAt: Date, id: string): TransactionCursor {
return `cursor:${createdAt.toISOString()}:${id}`;
}

function decodeCursor(cursor: TransactionCursor): { createdAt: Date, id: string } | null {
if (cursor === "first" || cursor === "last") return null;
if (!cursor.startsWith("cursor:")) return null;
const payload = cursor.slice("cursor:".length);
const lastColon = payload.lastIndexOf(":");
if (lastColon <= 0 || lastColon >= payload.length - 1) return null;
const dateStr = payload.slice(0, lastColon);
const id = payload.slice(lastColon + 1);
const createdAt = new Date(dateStr);
if (!id || Number.isNaN(createdAt.getTime())) return null;
return { createdAt, id };
}
Copy link
Contributor

Choose a reason for hiding this comment

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

better to make this cursor a JSON array (see the .zip function of PaginatedList for an example)

Copy link
Contributor

Choose a reason for hiding this comment

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

this one will break in weird ways if id contains a colon for whatever reason

);
return looseIndex !== -1 ? looseIndex : null;
})();
const resolvedEntryIndex = adjustedEntryIndex ?? 0;
Copy link
Contributor

Choose a reason for hiding this comment

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

what is the purpose of the fallback here?

Comment on lines +325 to +327
const config = itemConfig as { quantity?: number, expires?: string };
if (config.expires === "when-purchase-expires" && (config.quantity ?? 0) > 0) {
const adjustedQuantity = (config.quantity ?? 0) * subscription.quantity;
Copy link
Contributor

Choose a reason for hiding this comment

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

why the cast here? is quantity really optional? why does it default to 0?

Comment on lines +461 to +462
const config = itemConfig as { quantity?: number, expires?: string };
if (config.expires === "when-purchase-expires" && (config.quantity ?? 0) > 0) {
Copy link
Contributor

Choose a reason for hiding this comment

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

as above (this repeats everywhere else)

Copy link
Contributor

Choose a reason for hiding this comment

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

there's a lot of duplicated code here, for example all of the paginated list handlers have a lot of duplication. you can probably just ask an LLM to clean it up and deduplicate the code

@github-actions github-actions bot assigned BilalG1 and unassigned N2D4 Jan 13, 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.

3 participants