-
Notifications
You must be signed in to change notification settings - Fork 498
transactions rework #1089
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
transactions rework #1089
Conversation
Older cmux preview screenshots (latest comment is below)Preview ScreenshotsOpen Workspace (1 hr expiry) · Open Dev Browser (1 hr expiry) · Open Diff Heatmap Screenshot capture was skipped.
Generated by cmux preview system |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughAdds 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
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
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ❌ 3❌ Failed checks (2 warnings, 1 inconclusive)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
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. Comment |
There was a problem hiding this 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 aStackAssertionError. 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: DuplicatesendStripeWebhookhelper function.This function duplicates
sendStripeWebhookfromapps/e2e/tests/backend/backend-helpers.ts(lines 1522-1549). The existing helper also supportsinvalidSignature,omitSignature, and customsecretoptions. Consider importing frombackend-helpers.tsinstead.♻️ 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
StripeRefundtable referencessubscriptionIdandoneTimePurchaseIdbut 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 withON DELETE SET NULLif referential integrity is desired.apps/backend/src/lib/new-transactions.ts (1)
175-199: Currency list inbuildChargedAmountcould 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
📒 Files selected for processing (6)
apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sqlapps/backend/prisma/schema.prismaapps/backend/src/app/api/latest/integrations/stripe/webhooks/route.tsxapps/backend/src/lib/new-transactions.tsapps/backend/src/lib/stripe.tsxapps/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.tsxapps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.tsapps/backend/src/lib/new-transactions.tsapps/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.tsxapps/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
usePathnameinstead ofawait params)
Files:
apps/backend/src/lib/stripe.tsxapps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.tsapps/backend/src/lib/new-transactions.tsapps/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, userunAsynchronouslyorrunAsynchronouslyWithAlertinstead
Use ES6 maps instead of records wherever possible
Files:
apps/backend/src/lib/stripe.tsxapps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.tsapps/backend/src/lib/new-transactions.tsapps/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 theanytype; 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.tsxapps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.tsapps/backend/src/lib/new-transactions.tsapps/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
refundEventsconstant andisRefundEventtype guard are well-structured, consistent with the existingsubscriptionChangedEventsandisSubscriptionChangedEventpattern.
116-125: LGTM! Refund processing block is consistent with other event handlers.The refund handling correctly validates
accountId, resolves the Stripe client, and delegates tohandleStripeRefund. 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_ENDPOINTroute 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/newProductJSON fields allow flexibility for partial changes.
1101-1134: SubscriptionChange model LGTM.The
changeTypeenum provides good categorization. Note thatsubscriptionIdis 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
DatabasePaginatedListabstraction cleanly separates concerns: subclasses only implement data fetching and row-to-transaction mapping. The cursor handling and comparison logic is properly shared.
1591-1677:listTransactionsis 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 thecustomerIdguard check before callinggetSubscriptions.The guard at line 1451 should precede the function call at line 1444. Currently,
getSubscriptionsis invoked with an empty stringcustomerIdbefore checking whether one exists, resulting in an unnecessary database query that returns no results anyway.
Greptile Summary
Important Files Changed
Confidence score: 2/5
|
There was a problem hiding this 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
apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql
Show resolved
Hide resolved
Older cmux preview screenshots (latest comment is below)Preview Screenshots⏳ Preview screenshots are being captured... Workspace and dev browser links will appear here once the preview environment is ready. Generated by cmux preview system |
There was a problem hiding this 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
📒 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
usePathnameinstead ofawait 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, userunAsynchronouslyorrunAsynchronouslyWithAlertinstead
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 theanytype; 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 nocustomerIdfilter 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.
Older cmux preview screenshots (latest comment is below)Preview Screenshots⏳ Preview screenshots are being captured... Workspace and dev browser links will appear here once the preview environment is ready. Generated by cmux preview system |
There was a problem hiding this 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
parseFloatand floating-point multiplication for money amounts, which can introduce rounding errors. Additionally, there's no validation thatquantityis 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: Unsafeanytype cast bypasses type checking.The
as anycast 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.SubscriptionWhereInputtype:where: where as Prisma.SubscriptionWhereInput,
206-207: Missing defensive validation for product schema.The code directly casts
subscription.producttoInferType<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.oldValueandchange.newValueto specific shapes without validation. If the data doesn't match the expected shape, the code will silently useundefinedvalues, 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
includedItemsand creatingitem_quantity_changeentries 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
📒 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
usePathnameinstead ofawait 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, userunAsynchronouslyorrunAsynchronouslyWithAlertinstead
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 theanytype; 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_amountdefaults to "0" for non-USD refunds.For refunds in currencies other than USD,
net_amountis set to{ USD: "0" }, losing refund amount information for financial tracking.Likely an incorrect or invalid review comment.
421-422: Hardcodedadjusted_entry_index: 0assumes entry order.The assumption that
product_grantis 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: Hardcodedadjusted_entry_index: 0may 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_amountincorrectly defaults to USD.The
net_amountfield always uses USD fromchargedAmount, defaulting to "0" if USD is not present. For transactions in other currencies (EUR, GBP, etc.), this results innet_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: Hardcodedadjusted_entry_index: 0assumes entry order.The assumption that
active_sub_startis 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_amountincorrectly defaults to USD.Same issue as lines 235 and 300:
net_amounthardcodes USD.Likely an incorrect or invalid review comment.
Older cmux preview screenshots (latest comment is below)Preview Screenshots⏳ Preview screenshots are being captured... Workspace and dev browser links will appear here once the preview environment is ready. Generated by cmux preview system |
There was a problem hiding this 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
oldQuantityandnewQuantityto 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.changeTypeif you'll filter by specific change types (e.g., "show all PRICE_CHANGE events")- Indexes on
createdAtfields if you'll query by time ranges frequentlyThese 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
📒 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
JSONBforoldValueandnewValueallows 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.
apps/backend/prisma/migrations/20260107160000_add_transaction_tables/migration.sql
Show resolved
Hide resolved
Older cmux preview screenshots (latest comment is below)Preview Screenshots⏳ Preview screenshots are being captured... Workspace and dev browser links will appear here once the preview environment is ready. Generated by cmux preview system |
There was a problem hiding this 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
parseFloatwith direct multiplication (lines 199-202) can introduce floating-point precision errors in financial calculations.Consider:
- Iterating over all keys in the price object dynamically instead of maintaining a hardcoded currency list
- Using a decimal library or integer-based calculations (cents) to avoid floating-point precision issues
- 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).entriesjust 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._nextOrPrevimplementation (lines 802-811), all items have bothitemCursor,prevCursor, andnextCursorproperties 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
📒 Files selected for processing (2)
apps/backend/src/lib/new-transactions.tsapps/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.tsapps/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
usePathnameinstead ofawait params)
Files:
apps/backend/src/lib/new-transactions.tsapps/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, userunAsynchronouslyorrunAsynchronouslyWithAlertinstead
Use ES6 maps instead of records wherever possible
Files:
apps/backend/src/lib/new-transactions.tsapps/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 theanytype; 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.tsapps/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)
Older cmux preview screenshots (latest comment is below)Preview ScreenshotsOpen Workspace (1 hr expiry) · Open Dev Browser (1 hr expiry) · Open Diff Heatmap Screenshot capture was skipped.
Generated by cmux preview system |
There was a problem hiding this 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
customerIdis provided (lines 1492-1502), with a comment noting this is incomplete. This limits the usefulness of theitem-quantity-renewaltransaction 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
nextCursorcould 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
itemCursorandnextCursorcan coexist as optional properties, which appears to be the case based on line 816.
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 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
usePathnameinstead ofawait 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, userunAsynchronouslyorrunAsynchronouslyWithAlertinstead
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 theanytype; 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)
Older cmux preview screenshots (latest comment is below)Preview Screenshots⏳ Preview screenshots are being captured... Workspace and dev browser links will appear here once the preview environment is ready. Generated by cmux preview system |
There was a problem hiding this 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
ProductChangemodel lacks Prisma relations toSubscriptionandTenancy. 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
SubscriptionInvoicewhich relates toSubscription.
1114-1137: Add relation toSubscriptionfor referential integrity.
SubscriptionChangehas a requiredsubscriptionIdbut no Prisma relation toSubscription. 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
Subscriptionmodel.
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
📒 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
OTHERcatch-all for edge cases.
<!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md -->
Preview Screenshots⏳ Preview screenshots are being captured... Workspace and dev browser links will appear here once the preview environment is ready. Generated by cmux preview system |
There was a problem hiding this 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: TheRefundableTransactionTypealias adds no value.Since
TransactionTypedoesn't includenull, the unionTransactionType | nullis only useful if the function parameter can benull. However, the guardisRefundableTransactionTypeis meant to narrow fromTransactionType | nullto a subset ofTransactionType, but the return type annotation says it narrows toTransactionType(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
parseFloatand 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.customerIdis undefined, an empty string""is passed togetSubscriptions(line 1488). This call happens before the guard that returns early on lines 1492-1501. Depending on howgetSubscriptionshandles an emptycustomerId, this could cause unexpected behavior or errors.Consider moving the guard check before the
getSubscriptionscall: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-refundtransaction 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
sendStripeWebhookfunction duplicatesPayments.sendStripeWebhookfrombackend-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 implementationThen update all calls to use
PaymentsHelper.sendStripeWebhook(payload)instead ofsendStripeWebhook(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
📒 Files selected for processing (13)
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsxapps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.tsapps/backend/src/lib/new-transactions.tsapps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsxapps/dashboard/src/components/data-table/transaction-table.tsxapps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.tsapps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.tsapps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.tspackages/stack-shared/src/interface/admin-interface.tspackages/stack-shared/src/interface/crud/transactions.tspackages/stack-shared/src/utils/paginated-lists.tsxpackages/template/src/lib/stack-app/apps/implementations/admin-app-impl.tspackages/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 usetoast, 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. UserunAsynchronouslyorrunAsynchronouslyWithAlertinstead 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 useDate.now()for measuring elapsed (real) time; instead useperformance.now()
Use ES6 maps instead of records wherever possible
Files:
packages/template/src/lib/stack-app/apps/interfaces/admin-app.tsapps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.tsapps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.tsapps/dashboard/src/components/data-table/transaction-table.tsxapps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.tspackages/template/src/lib/stack-app/apps/implementations/admin-app-impl.tsapps/backend/src/lib/new-transactions.tspackages/stack-shared/src/interface/admin-interface.tsapps/backend/src/app/api/latest/internal/payments/transactions/route.tsxpackages/stack-shared/src/interface/crud/transactions.tspackages/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., useusePathnameinstead ofawait params)
Code defensively using?? throwErr(...)instead of non-null assertions, with good error messages explicitly stating violated assumptions
Try to avoid theanytype. When usingany, 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.tsapps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.tsapps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.tsapps/dashboard/src/components/data-table/transaction-table.tsxapps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.tspackages/template/src/lib/stack-app/apps/implementations/admin-app-impl.tsapps/backend/src/lib/new-transactions.tspackages/stack-shared/src/interface/admin-interface.tsapps/backend/src/app/api/latest/internal/payments/transactions/route.tsxpackages/stack-shared/src/interface/crud/transactions.tspackages/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_(orNEXT_PUBLIC_STACK_if public) to ensure Turborepo picks up changes and improve readability
Files:
packages/template/src/lib/stack-app/apps/interfaces/admin-app.tsapps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.tsapps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.tsapps/dashboard/src/components/data-table/transaction-table.tsxapps/e2e/tests/backend/endpoints/api/v1/new-transactions.test.tspackages/template/src/lib/stack-app/apps/implementations/admin-app-impl.tsapps/backend/src/lib/new-transactions.tspackages/stack-shared/src/interface/admin-interface.tsapps/backend/src/app/api/latest/internal/payments/transactions/route.tsxpackages/stack-shared/src/interface/crud/transactions.tspackages/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.toMatchInlineSnapshotover 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.tsapps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.tsapps/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.tspackages/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.tsapps/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.tsapps/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
_nextOrPrevimplementations. 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
customerIdparameter andhasMorereturn 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_amountschema now usesyupRecord(yupString(), yupString().defined()), which producesRecord<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
isRefundableTransactionTypeguard andgetRefundTargetfunction 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
customerIdparameter with proper snake_case query parameter mapping (customer_id)- Extracts the new
has_morefield 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
customerIdin 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
listTransactionsmethod properly passes thecustomerIdparameter to the cache and returns thehasMoreflag from the cached result.
855-858: LGTM! Hook correctly mirrors the async method signature.The
useTransactionshook properly includescustomerIdin 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
netAmountSchemanow 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_idallows 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
listTransactionsfunction 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
decodeCursorfunction works as designed.encodeCursoralways produces cursors in the strict formatcursor:${createdAt.toISOString()}:${id}, wheretoISOString()generates full ISO 8601 timestamps like2024-01-15T10:30:45.123Z. This format uses hyphens in the date portion, not colons—colons only appear in the time portion. ThelastIndexOf(":")correctly identifies the final colon separating the ISO date string from the ID. Edge cases like emptydateStror invalid dates are properly caught by theNumber.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_morefield in responsemoney_transferentry beforeproduct_grant- Updated type from
"purchase"to"stripe-one-time"Good use of
.toMatchInlineSnapshotas 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_startentry that precedesproduct_grantin new-stripe-sub transactions, matching the implementation inbuildNewStripeSubTransaction.
521-533: Good defensive filtering for entries without customer_type.The
.filter(e => "customer_type" in e)pattern correctly handles entries likeproduct_revocationthat don't have acustomer_typefield, 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
listTransactionsfunction- Returns consistent response shape with
has_moreapps/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_moreflag behaviornext_cursorpresence- 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.
| // 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++; | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| // 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).
| 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" }, | ||
| }]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
| override async _nextOrPrev(type: 'next' | 'prev', { limit, filter, orderBy, cursor }: ImplQueryOptions<'next' | 'prev', string, Filter, OrderBy>) { | ||
| const cursors = JSON.parse(cursor) as string[]; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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, | ||
| }, |
There was a problem hiding this comment.
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.
| adjusted_transaction_id: subscription.id, | ||
| adjusted_entry_index: 0, // Index of product_grant in original transaction | ||
| quantity: subscription.quantity, | ||
| }); |
There was a problem hiding this comment.
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)
| const createdAt = new Date(dateStr); | ||
| if (!id || Number.isNaN(createdAt.getTime())) return null; | ||
| return { createdAt, id }; | ||
| } |
There was a problem hiding this comment.
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)
| customerType: options.filter.customerType ?? "user", | ||
| customerId: options.filter.customerId ?? "", |
There was a problem hiding this comment.
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:
- Call listTransactions() with filter type "item-quantity-renewal" and no customerId filter
- Observe that getSubscriptions performs a database query searching for subscriptions with customerId = ""
- 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).
| 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); |
There was a problem hiding this comment.
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?)
| 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 }; | ||
| } |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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?
| 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; |
There was a problem hiding this comment.
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?
| const config = itemConfig as { quantity?: number, expires?: string }; | ||
| if (config.expires === "when-purchase-expires" && (config.quantity ?? 0) > 0) { |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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
Summary by CodeRabbit
New Features
Integrations
Database
API Changes
UI
Tests
✏️ Tip: You can customize this high-level summary in your review settings.
Note
Modernizes payments accounting and listing across backend, shared libs, and dashboard.
StripeRefund,ProductChange,SubscriptionChangetables (+SubscriptionChangeType) with indexes in Prisma/migration; corresponding models inschema.prismasrc/lib/new-transactions.tswithNEW_TRANSACTION_TYPES, builders, and merged cursor pagination; removes oldtransaction-builderlistTransactions(addshas_more,customer_idfilter, new entry schemas/types)handleStripeRefund) and upsert intoStripeRefundTransactionEntry,TransactionType, charged/net amount shape) andpaginated-listsmerge/iteration logicWritten by Cursor Bugbot for commit 977836b. This will update automatically on new commits. Configure here.