Skip to content

Transactions rework + ledger playground#1220

Open
N2D4 wants to merge 23 commits intopayment-subscription-handling-reworkfrom
new-subscriptions
Open

Transactions rework + ledger playground#1220
N2D4 wants to merge 23 commits intopayment-subscription-handling-reworkfrom
new-subscriptions

Conversation

@N2D4
Copy link
Contributor

@N2D4 N2D4 commented Feb 24, 2026


Note

High Risk
Touches core payments accounting/refund logic and changes transaction-entry semantics plus DB schema, so regressions could impact billing state, entitlement calculations, and Stripe sync behavior.

Overview
Reworks payments to be ledger-driven: introduces lib/payments/ledger (transaction builder/pipeline + balance algorithm) and updates APIs to fetch transactions/owned products/item quantities from this shared ledger logic instead of bespoke per-table transaction builders.

Adds persistence for subscription lifecycle/default products: Subscription gains endedAt + billingCycleAnchor, and a new DefaultProductsSnapshot table is introduced and used to snapshot include-by-default products for correct historical calculations.

Adds internal-only tools: a hidden ledger-playground endpoint to run ledger functions over live/mock data with time-sliced snapshots, and a resync-subscriptions endpoint to re-pull subscription state from Stripe across projects. Also standardizes transaction entry type strings to kebab-case and updates cancellation/refund paths to set/use endedAt and delegate to refundTransaction.

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

Summary by CodeRabbit

  • New Features

    • Added ledger playground interface for visualizing and debugging payment transactions
    • Added subscription resync capability to update Stripe account data
    • Enhanced subscription lifecycle tracking with end dates and billing cycle anchors
    • Implemented default product snapshots for improved multi-tenant product management
    • Improved item quantity and ownership calculations for customers
  • Chores

    • Standardized transaction entry naming conventions
    • Refactored internal payment ledger computation system

@N2D4 N2D4 requested a review from nams1570 February 24, 2026 21:18
@vercel
Copy link

vercel bot commented Feb 24, 2026

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

Project Deployment Actions Updated (UTC)
stack-auth-hosted-components Error Error Mar 12, 2026 6:44pm
stack-backend Ready Ready Preview, Comment Mar 12, 2026 6:44pm
stack-dashboard Ready Ready Preview, Comment Mar 12, 2026 6:44pm
stack-demo Ready Ready Preview, Comment Mar 12, 2026 6:44pm
stack-docs Ready Ready Preview, Comment Mar 12, 2026 6:44pm

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 1729d8dc-ae4f-4133-b85d-3748c58c0b6c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch new-subscriptions
📝 Coding Plan
  • Generate coding plan for human review comments

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

❤️ Share

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

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Feb 24, 2026

Greptile Summary

This PR implements a major rework of the subscription and transaction ledger system. The changes introduce an event-driven transaction processor that maintains state for active subscriptions, purchases, and item grants, with support for recurring grants and expirations.

Key Changes:

  • New event-based transaction processor using priority queue for scheduling recurring events
  • Ledger balance computation algorithm using FIFO grant consumption
  • Database schema updates adding endedAt, billingCycleAnchor to subscriptions and DefaultProductsSnapshot table
  • Refund logic refactored with better Stripe integration
  • Ledger playground UI for testing and visualizing transactions (internal project only)
  • Transaction list API with pagination and filtering

Testing:

  • Extensive test coverage with ledger.test.tsx, playground.test.ts, algo.test.ts, and list.test.ts
  • Over 1800 lines of test code added

The implementation appears solid with good separation of concerns, though the stateful transaction processing logic is complex and will benefit from careful production monitoring.

Confidence Score: 4/5

  • Safe to merge with minor review of ledger logic complexity and testing coverage
  • Large refactor with extensive test coverage, but complex stateful transaction processing logic requires careful validation in production
  • Pay attention to apps/backend/src/lib/payments/ledger/transactions/processor.ts for complex state mutations and apps/backend/src/lib/payments/ledger/refund.ts for Stripe integration logic

Important Files Changed

Filename Overview
apps/backend/src/lib/payments/ledger/transactions/processor.ts New transaction event processor with complex state management for subscriptions, purchases, and item grants with repeat/expiry logic
apps/backend/src/lib/payments/ledger/algo.ts New ledger balance computation algorithm using timestamp-based aggregation of grants, expirations, and usage
apps/backend/src/lib/payments/ledger/refund.ts Refund logic for subscriptions and one-time purchases with Stripe integration and validation
apps/backend/src/app/api/latest/internal/payments/ledger-playground/route.ts Testing/debugging endpoint that runs ledger functions against live or mock data, restricted to internal project only
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/ledger-playground/page-client.tsx Large UI component (1100+ lines) for visualizing and testing ledger transactions with mock data generation

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Seed Events:<br/>Subscriptions, Purchases,<br/>Item Changes] --> B[Event Queue<br/>Priority Heap]
    B --> C[processEvent]
    C --> D{Event Type}
    D -->|subscription-start| E[Create Subscription TX<br/>+ Money Transfer<br/>+ Product Grant<br/>+ Item Grants]
    D -->|subscription-renewal| F[Create Renewal TX<br/>+ Money Transfer]
    D -->|subscription-refund| G[Refund Logic<br/>Stripe Integration]
    D -->|one-time-purchase| H[Create Purchase TX<br/>+ Money Transfer<br/>+ Product Grant]
    D -->|item-grant-repeat| I[Recurring Item Grants]
    D -->|default-products-change| J[Update Default Products<br/>Track Diffs]
    E --> K[Queue Repeat Events]
    H --> K
    I --> K
    J --> K
    K --> B
    E --> L[Transaction Output]
    F --> L
    G --> L
    H --> L
    I --> L
    J --> L
    L --> M[Ledger Balance Algorithm<br/>FIFO Grant Consumption]
    M --> N[Customer State:<br/>Products + Items]
Loading

Last reviewed commit: f42f78a

Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

61 files reviewed, no comments

Edit Code Review Agent Settings | Greptile

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Note

Due to the large number of review comments, Critical severity comments were prioritized as inline comments.

Caution

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

⚠️ Outside diff range comments (4)
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts (2)

134-171: ⚠️ Potential issue | 🟠 Major

Add a dedicated E2E test for the new transaction-type contract.

These changes only update existing snapshots; please add at least one new E2E test that explicitly validates filtering/behavior with the new kebab-case type strings (and ideally rejects legacy underscore values) so the API change is covered.
As per coding guidelines: **/apps/e2e/**/*.{ts,tsx}: ALWAYS add new E2E tests when you change the API or SDK interface. Generally, err on the side of creating too many tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts` around
lines 134 - 171, Add a new E2E test that exercises the transactions API contract
with kebab-case type strings: call the same internal transactions endpoint and
create or query transactions using a kebab-case type (e.g., "product-grant"),
then assert that response.body.transactions contains entries with type
"product-grant" and that requests using the legacy underscore form
"product_grant" are rejected or do not match the new filter behavior; place the
test alongside existing E2E transaction tests and follow the existing test
helpers/assertion patterns so it covers filtering/behavior for the new
transaction-type contract.

414-417: ⚠️ Potential issue | 🟡 Minor

Fix money transfer entry type from snake_case to kebab-case.

Line 416 asserts "money_transfer" but the backend schema and implementation define the type as "money-transfer". Update the assertion to match the actual backend value.

🛠️ Proposed fix
-  expect(renewalTransactions[0]?.entries?.[0]?.type).toBe("money_transfer");
+  expect(renewalTransactions[0]?.entries?.[0]?.type).toBe("money-transfer");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts` around
lines 414 - 417, The assertion in the test uses the wrong entry type string:
update the assertion that checks renewalTransactions[0]?.entries?.[0]?.type
(referencing renewalTransactions) to expect "money-transfer" instead of
"money_transfer" so it matches the backend schema and implementation.
apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts (1)

243-253: ⚠️ Potential issue | 🟡 Minor

Fix inconsistent transaction type casing: money_transfer should be money-transfer.

Line 243 correctly uses kebab-case "product-grant", but line 252 uses snake_case "money_transfer". The canonical transaction entry types are defined in packages/stack-shared/src/interface/crud/transactions.ts as "money-transfer" and "product-grant" (kebab-case). Update line 252 to use "money-transfer" for consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts`
around lines 243 - 253, The test data contains an entry with the transaction
type string using snake_case ("money_transfer") which conflicts with the
canonical kebab-case types; update the transaction entry in
transactions-refund.test.ts to use "money-transfer" (matching "product-grant"
and the types in packages/stack-shared/src/interface/crud/transactions.ts) so
the test uses the correct canonical transaction type.
apps/dashboard/src/components/data-table/transaction-table.tsx (1)

536-548: ⚠️ Potential issue | 🟠 Major

Remove as any casts and validate filter values instead.

The filter values from columnFilters are untyped and bypass validation. Add explicit guards to ensure values match expected types, failing fast with descriptive errors when they don't.

Suggested fix
+  const isTransactionType = (value: unknown): value is TransactionType =>
+    typeof value === 'string' && TRANSACTION_TYPES.includes(value as any);
+  const isCustomerType = (value: unknown): value is 'user' | 'team' | 'custom' =>
+    value === 'user' || value === 'team' || value === 'custom';
+
   const newFilters: { cursor?: string, limit?: number, type?: TransactionType, customerType?: 'user' | 'team' | 'custom' } = {
     cursor: options.cursor,
     limit: options.limit,
-    type: options.columnFilters.find(f => f.id === 'source_type')?.value as any,
-    customerType: options.columnFilters.find(f => f.id === 'customer')?.value as any,
+    type: (() => {
+      const raw = options.columnFilters.find(f => f.id === 'source_type')?.value;
+      if (raw == null) return undefined;
+      return isTransactionType(raw) ? raw : throwErr("Unexpected transaction type filter value");
+    })(),
+    customerType: (() => {
+      const raw = options.columnFilters.find(f => f.id === 'customer')?.value;
+      if (raw == null) return undefined;
+      return isCustomerType(raw) ? raw : throwErr("Unexpected customer type filter value");
+    })(),
   };

Per coding guidelines: "Do NOT use as/any/type casts" and "Code defensively. Prefer ?? throwErr(...) over non-null assertions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/dashboard/src/components/data-table/transaction-table.tsx` around lines
536 - 548, The current onUpdate callback builds newFilters using unsafe "as any"
casts from options.columnFilters; remove those casts and validate the extracted
values for the 'source_type' and 'customer' filters before assigning to
newFilters (use the columnFilters.find(...).value result, check it's a string
and belongs to the TransactionType union or to 'user'|'team'|'custom'
respectively). If a value is absent or invalid, throw a clear error (or use the
existing throwErr helper / "?? throwErr(...)") so the function fails fast;
update references in onUpdate and newFilters to only accept validated, typed
values and keep types TransactionType and 'user'|'team'|'custom'.
🟠 Major comments (17)
apps/backend/scripts/verify-data-integrity/payments-verifier.ts-56-56 (1)

56-56: ⚠️ Potential issue | 🟠 Major

Ensure a migration exists for Subscription.endedAt (CI shows schema/migration drift).
The pipeline reports Prisma migrations out of sync; since this verifier now relies on endedAt, please confirm the migration adding the column is committed (and regenerated if necessary).

Also applies to: 598-598

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/scripts/verify-data-integrity/payments-verifier.ts` at line 56,
The verifier now accesses Subscription.endedAt but your Prisma schema and
migrations lack the corresponding migration, causing CI drift; add a migration
that adds the endedAt column to the Subscription model (update the Prisma schema
to include "endedAt DateTime?" on Subscription if missing), run prisma migrate
dev (or generate a proper migration with prisma migrate deploy/ diff), verify
the generated SQL includes adding the endedAt column (nullable if intended), and
commit the migration files so CI and the payments-verifier.ts reference to
Subscription.endedAt are in sync.
apps/backend/src/lib/payments/ledger/transaction-helpers.ts-31-69 (1)

31-69: ⚠️ Potential issue | 🟠 Major

Validate amount format and decimal precision before BigInt conversion.

The function accepts malformed inputs silently. For example, an amount with extra decimal places (e.g., "10.125" when currency.decimals is 2) will be included in the BigInt calculation, producing incorrect monetary amounts. Also, inputs like "1.2.3" will partially parse without error. Add format validation and a decimal overflow check to fail early.

Suggested fix
 function multiplyMoneyAmount(amount: string, quantity: number, currency: Currency): string {
   if (!Number.isFinite(quantity) || Math.trunc(quantity) !== quantity) {
     throw new Error("Quantity must be an integer when multiplying money amounts");
   }
   if (quantity === 0) return "0";
+
+  if (!/^-?\d+(\.\d+)?$/.test(amount)) {
+    throw new Error(`Invalid money amount: ${amount}`);
+  }

   const multiplierNegative = quantity < 0;
   const safeQuantity = BigInt(Math.abs(quantity));
   const isNegative = amount.startsWith("-");
   const normalized = isNegative ? amount.slice(1) : amount;
   const [wholePart, fractionalPart = ""] = normalized.split(".");
+  if (fractionalPart.length > currency.decimals) {
+    throw new Error(`Too many decimal places for ${currency.code}`);
+  }
   const paddedFractional = fractionalPart.padEnd(currency.decimals, "0");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/transaction-helpers.ts` around lines 31
- 69, The multiplyMoneyAmount function currently accepts malformed amount
strings and silently handles fractional parts longer than currency.decimals; add
strict validation before any BigInt work: validate the amount matches the regex
for an optional leading "-" followed by digits and an optional single "." with
digits (no extra dots or non-digits), split into whole and fractional parts via
the same logic, and if fractionalPart.length > currency.decimals throw an error;
only then pad/truncate fractionalPart as needed and build the smallestUnit
BigInt from sanitized whole and padded fractional parts, preserving existing
sign handling in multiplyMoneyAmount and using currency.decimals as the
precision guard.
apps/backend/src/lib/payments/ledger/transaction-helpers.ts-72-81 (1)

72-81: ⚠️ Potential issue | 🟠 Major

Differentiate between missing currency keys and corrupted data values.

Currently, the code silently skips whenever a currency value is not a string—whether the key is missing (undefined) or has an unexpected type (number, object, etc.). This masks data integrity errors.

Suggested fix
   for (const currency of SUPPORTED_CURRENCIES) {
     const rawAmount = price[currency.code as keyof typeof price];
-    if (typeof rawAmount !== "string") continue;
+    if (rawAmount == null) continue;
+    if (typeof rawAmount !== "string") {
+      throw new Error(`Invalid ${currency.code} amount; expected string.`);
+    }
     const multiplied = multiplyMoneyAmount(rawAmount, quantity, currency);
     if (multiplied === "0") continue;
     result[currency.code] = multiplied;
   }

Undefined/missing keys are expected and should skip silently. But non-string values indicate a data corruption issue and should fail fast with a clear error message.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/transaction-helpers.ts` around lines 72
- 81, In buildChargedAmount, differentiate missing currency keys from corrupted
values: when iterating SUPPORTED_CURRENCIES, if rawAmount (from
price[currency.code]) is undefined, continue silently (missing key is allowed);
if rawAmount is present but typeof rawAmount !== "string", throw a clear error
(fail fast) naming the function buildChargedAmount and the offending
currency.code and including enough context to identify the price object; keep
the existing multiplyMoneyAmount call and skip-zero behavior unchanged.
apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx-45-48 (1)

45-48: ⚠️ Potential issue | 🟠 Major

as MoneyAmount type cast violates coding guidelines

entry.amount_usd is runtime-validated by moneyAmountSchema(USD_CURRENCY).defined(), but the as MoneyAmount cast suppresses a TypeScript type incompatibility rather than resolving it through the type system.

Per coding guidelines: "Do NOT use as/any/type casts or anything else to bypass the type system unless you specifically asked the user about it."

The retrieved learning about Prisma JsonValue casts from trusted sources does not apply here — this is a yup-inferred type mismatch, not a Prisma JsonValue cast.

The correct fix is to update moneyAmountSchema's TypeScript return type so it infers MoneyAmount directly, eliminating the cast.

💡 Suggested approach
-      refundEntries: body.refund_entries.map((entry) => ({
-        ...entry,
-        amount_usd: entry.amount_usd as MoneyAmount,
-      })),
+      refundEntries: body.refund_entries.map((entry) => ({
+        ...entry,
+        amount_usd: entry.amount_usd,  // fix: align moneyAmountSchema return type with MoneyAmount
+      })),

If removing the cast produces a type error, update the return type of moneyAmountSchema rather than re-adding the cast.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx`
around lines 45 - 48, The current cast "entry.amount_usd as MoneyAmount" in the
refundEntries mapping masks a typing mismatch; remove the cast and make
moneyAmountSchema(USD_CURRENCY) return a TypeScript type that matches
MoneyAmount so the mapped property is correctly inferred. Locate the
moneyAmountSchema function/definition and adjust its TypeScript return/generic
typing (or exported inferred type) so calling
moneyAmountSchema(USD_CURRENCY).defined() yields a type assignable to
MoneyAmount, then update the refundEntries mapping to use the validated value
without any "as" cast (referencing refundEntries, body.refund_entries,
moneyAmountSchema, USD_CURRENCY, MoneyAmount).
apps/backend/src/app/api/latest/internal/payments/resync-subscriptions/route.ts-53-86 (1)

53-86: ⚠️ Potential issue | 🟠 Major

Bulk resync has no concurrency limits or timeout safeguards — risk of request timeout and Stripe rate limiting.

This handler processes every project's every customer sequentially in a single HTTP request. For deployments with many projects/customers, this could easily exceed request timeout limits and hit Stripe API rate limits (25 req/s in test, 100 req/s in live mode per connected account).

Consider:

  1. Adding a per-project or per-customer concurrency limit (e.g., p-limit or batched Promise.all).
  2. Logging progress periodically so operators can monitor long-running syncs.
  3. Documenting in the endpoint description that this is a long-running admin operation.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/backend/src/app/api/latest/internal/payments/resync-subscriptions/route.ts`
around lines 53 - 86, The current resync loop in route.ts iterates all
projects/customers sequentially with no concurrency or timeout safeguards (see
getStripeForAccount and syncStripeSubscriptions usage), which risks request
timeouts and Stripe rate limiting; fix by adding a bounded concurrency executor
(e.g., p-limit) around per-customer syncs so you only run N concurrent
syncStripeSubscriptions calls per Stripe account, add per-call timeouts/retries
or wrap calls with a timeout helper to prevent long-hanging requests, process
customers in batches (chunk customer list and await each batch with the
limiter), emit periodic progress logs (e.g., after each batch or every X
customers) to track syncedProjects/customers and push partial results/errors
incrementally, and update the endpoint docs/handler comment to state this is a
long-running admin operation so callers expect asynchronous or long-lived
behavior.
apps/backend/src/app/api/latest/internal/payments/resync-subscriptions/route.ts-45-48 (1)

45-48: 🛠️ Refactor suggestion | 🟠 Major

Unjustified as any cast on Prisma filter.

The as any suppresses a type mismatch between the conditional filter shape and Prisma's generated types. Per coding guidelines, any usage must include a comment explaining why it's necessary and how you can be certain errors would still be flagged.

Suggested fix
     const projects = await globalPrismaClient.project.findMany({
-      where: projectFilter as any,
+      // `as any` needed because the conditional filter shape doesn't match Prisma's strict generated
+      // `where` type when `stripeAccountId: { not: null }` is combined with an optional `id` clause.
+      // Safety: the only possible shapes are `{ stripeAccountId: { not: null } }` and
+      // `{ id: string, stripeAccountId: { not: null } }`, both valid Prisma filters.
+      where: projectFilter as any,
       select: { id: true, stripeAccountId: true },
     });

As per coding guidelines: "Try to avoid the any type. Whenever you need to use any, leave a comment explaining why."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/backend/src/app/api/latest/internal/payments/resync-subscriptions/route.ts`
around lines 45 - 48, The code currently silences Prisma type-checking by
casting projectFilter to any in the call to globalPrismaClient.project.findMany;
replace that cast by ensuring projectFilter is typed to the Prisma-generated
type (e.g., Prisma.ProjectWhereInput) before the call and remove the as any, or
explicitly construct a new variable typed as Prisma.ProjectWhereInput (e.g.,
const prismaFilter: Prisma.ProjectWhereInput = /* build using conditional logic
from projectFilter */) and pass prismaFilter to
globalPrismaClient.project.findMany; if you truly cannot express the shape with
Prisma types, leave a concise inline comment next to any remaining cast
explaining why it’s necessary and how type-safety is still ensured (but prefer
the typed prismaFilter approach and remove the as any).
apps/backend/src/lib/payments/ledger/transactions/seed-events.ts-44-46 (1)

44-46: ⚠️ Potential issue | 🟠 Major

updatedAt is an unreliable timestamp for the cancel event.

subscription.updatedAt is used as the at timestamp for subscription-cancel-event, but updatedAt is automatically refreshed by Prisma on any update to the row — not just cancellation. If a subsequent sync, renewal, or field update touches the subscription after cancellation, the cancel event's timestamp will shift, causing incorrect ledger ordering.

Consider adding a dedicated canceledAt field to the Subscription model, or deriving the cancel timestamp from a more stable source (e.g., a Stripe event timestamp).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/transactions/seed-events.ts` around
lines 44 - 46, The cancel-event uses subscription.updatedAt which can change
later, so change the logic in seed-events.ts to use a stable cancel timestamp:
add a dedicated canceledAt (or canceled_at) field to the Subscription model in
Prisma (and run a migration), populate it when a subscription is cancelled (or
derive from the Stripe event timestamp when recording cancellations), then
update the events.push call that references subscription.updatedAt to use
subscription.canceledAt.getTime() (with a safe fallback to a canonical external
timestamp if present) and update any related types/usages (e.g., Subscription
type checks and tests) to ensure the cancel event timestamp remains immutable.
apps/backend/src/lib/payments/ledger/refund.ts-130-175 (1)

130-175: ⚠️ Potential issue | 🟠 Major

Stripe refund + DB update are not atomic — risk of inconsistent state.

The Stripe refund is issued (line 134) before the local DB is updated (lines 165–168 or 172–175). If the DB update fails (e.g., network error, constraint violation), the money is refunded in Stripe but the local refundedAt is never set, meaning a retry would attempt a second Stripe refund.

Consider:

  1. Using a Stripe idempotency key on stripe.refunds.create(...) to prevent double-refunds on retry.
  2. Wrapping the DB updates in a try-catch that captures the error but still marks the operation as partially complete (or at minimum logs the inconsistency).

This same pattern applies to refundOneTimePurchase at lines 199–207.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/refund.ts` around lines 130 - 175, The
Stripe refund is created by stripe.refunds.create before the local DB is
updated, risking double refunds if the subsequent prisma.subscription.update
fails; modify the refund flow in refundSubscription (and refundOneTimePurchase)
to supply a stable idempotency key to stripe.refunds.create (e.g., derived from
tenancy.id + subscriptionId + refundId) and wrap the prisma.subscription.update
calls in a try-catch that logs the failure and records a durable reconciliation
flag/record (or at minimum persists an inconsistent-state marker) so you can
detect and reconcile refunds where Stripe succeeded but the DB update failed;
update references: stripe.refunds.create,
refundSubscription/refundOneTimePurchase, and prisma.subscription.update.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments-resync/page-client.tsx-76-80 (1)

76-80: ⚠️ Potential issue | 🟠 Major

Unhandled promise rejections in async onClick handlers.

Both onClick handlers are async lambdas whose returned promises are silently discarded by React. If runResync() throws (e.g. network error), the rejection is unhandled. The buttons also lack a loading/disabled state during the request, allowing concurrent submissions.

As per coding guidelines: "NEVER void a promise … Use runAsynchronously or runAsynchronouslyWithAlert instead for error handling."

Proposed approach (for both buttons)

Wrap each handler with runAsynchronouslyWithAlert (or the project's equivalent), and add a loading state to disable the button during the request. For example:

+ const [loadingAll, setLoadingAll] = useState(false);
  ...
  <Button
+   disabled={loadingAll}
    onClick={() => {
-     setAllProjectsResult(null);
-     const result = await runResync();
-     setAllProjectsResult(result);
+     runAsynchronouslyWithAlert(async () => {
+       setLoadingAll(true);
+       try {
+         setAllProjectsResult(null);
+         const result = await runResync();
+         setAllProjectsResult(result);
+       } finally {
+         setLoadingAll(false);
+       }
+     });
    }}
  >

Apply the same pattern to the single-project button.

Also applies to: 108-112

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/payments-resync/page-client.tsx
around lines 76 - 80, The async onClick lambdas for the bulk and single-project
resync (the handler that calls runResync() and the similar handler at lines
108-112) can yield unhandled promise rejections and allow concurrent clicks;
wrap each handler with runAsynchronouslyWithAlert (or the project's
runAsynchronously helper) so rejections are handled, and add local loading state
booleans (e.g., isAllResyncing and isProjectResyncing) updated around the await
to disable the corresponding button while the request runs and prevent
concurrent submissions; ensure you still call
setAllProjectsResult/setProjectResult after the awaited runResync() inside the
wrapped/awaited handler.
apps/backend/src/lib/payments/ledger/transactions/processor.ts-296-296 (1)

296-296: ⚠️ Potential issue | 🟠 Major

Mutating typed entry via as any to attach item_quantity_change_indices bypasses type safety.

(entries[productGrantIndex] as any).item_quantity_change_indices = itemQuantityChangeIndices;

This adds a field that doesn't exist on TransactionEntry by casting to any. If TransactionEntry is later validated or serialized in a way that strips unknown fields, this data will be silently lost. Consider either:

  1. Adding item_quantity_change_indices to the product-grant entry type in the TransactionEntry discriminated union, or
  2. Including it in the entry object literal at construction time (Lines 269-281 / 342-354) so the cast to TransactionEntry covers it.

As per coding guidelines: "Do NOT use as/any/type casts or anything else to bypass the type system unless you specifically asked the user about it."

Also applies to: 369-369

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/transactions/processor.ts` at line 296,
The code mutates entries[productGrantIndex] using (entries[productGrantIndex] as
any).item_quantity_change_indices which bypasses type safety; instead either add
item_quantity_change_indices to the TransactionEntry variant for the
"product-grant" discriminant in the TransactionEntry union, or ensure the field
is present on the entry object when constructing the product-grant entry (the
object created where you push/cast the product-grant entry into entries and the
analogous spot later where product-grant entries are made); remove the as any
cast and update any consumers/serializers to use the typed field.
apps/backend/src/lib/payments/ledger/implementation.tsx-30-39 (1)

30-39: ⚠️ Potential issue | 🟠 Major

Snapshot comparison can thrash due to key-order differences.

JSON.stringify compares key order, so logically identical snapshots with different key ordering will insert new rows every call. Use deep equality instead.

Suggested fix
-import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
+import { deepPlainEquals, typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
@@
-  const latestSnapshotData = latestSnapshot?.snapshot as Record<string, unknown> | null;
-  const currentJson = JSON.stringify(currentDefaults);
-  const latestJson = latestSnapshotData ? JSON.stringify(latestSnapshotData) : null;
-
-  if (currentJson !== latestJson) {
+  const latestSnapshotData = (latestSnapshot?.snapshot as Record<string, unknown>) ?? {};
+  if (!deepPlainEquals(currentDefaults, latestSnapshotData, { ignoreUndefinedValues: true })) {
     await prisma.defaultProductsSnapshot.create({
       data: {
         tenancyId: tenancy.id,
         snapshot: currentDefaults as any,
       },
     });
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/implementation.tsx` around lines 30 -
39, The comparison using JSON.stringify (currentJson/latestJson) can
false-negative due to key-order differences; replace that check with a
deep-equality comparison between currentDefaults and latestSnapshotData (e.g.,
use a stable deepEqual/isEqual utility or a canonicalizing stringify) and only
call prisma.defaultProductsSnapshot.create when deep equality reports they
differ; update the condition around the prisma.defaultProductsSnapshot.create
call (where currentJson !== latestJson is used) to use the chosen deep-equality
function with currentDefaults and latestSnapshotData.
apps/backend/src/lib/payments/ledger/transactions/helpers-core.ts-58-75 (1)

58-75: ⚠️ Potential issue | 🟠 Major

Handle both included_items (snake_case) and includedItems (camelCase) in parseIncludedItems.

The function currently only checks product?.included_items. When a product with camelCase includedItems flows through, item grants silently drop without error. The codebase already normalizes both forms elsewhere (e.g., in ledger/implementation.tsx), showing this is a known concern. Additionally, the product: any parameter lacks documentation as required by the coding guidelines.

Suggested fix
-export function parseIncludedItems(product: any): Array<{
+export function parseIncludedItems(product: { included_items?: Record<string, unknown>, includedItems?: Record<string, unknown> }): Array<{
   itemId: string,
   quantity: number,
   expires: string,
   repeat: RepeatInterval | null,
 }> {
   const out: Array<{ itemId: string, quantity: number, expires: string, repeat: RepeatInterval | null }> = [];
-  for (const [itemId, config] of Object.entries(product?.included_items ?? {})) {
+  const includedItems = product?.included_items ?? product?.includedItems ?? {};
+  for (const [itemId, config] of Object.entries(includedItems)) {
     const quantity = Number((config as any)?.quantity ?? 0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/transactions/helpers-core.ts` around
lines 58 - 75, parseIncludedItems currently reads only product?.included_items
so camelCase includedItems is ignored; update parseIncludedItems to read from
product?.included_items ?? product?.includedItems (or normalize both into a
local const included = product?.included_items ?? product?.includedItems ?? {})
before iterating, preserving existing logic (quantity parsing, expires default,
normalizeRepeat calls) so item grants aren't dropped. Also add a short JSDoc on
the function signature describing the expected shape of the product parameter
(mention included_items/includedItems map and optional fields
quantity/expires/repeat) to satisfy documentation guidelines and help future
maintainers.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/ledger-playground/page-client.tsx-395-415 (1)

395-415: ⚠️ Potential issue | 🟠 Major

Move wheel listener setup into useEffect with proper cleanup.

The listener is being attached during render (lines 410-414), which risks duplicate handlers in Strict Mode and creates a leak on unmount. Event listeners must be set up in useEffect with a cleanup function. Add useEffect to the import statement, then wrap the listener attachment:

-import { useCallback, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
-  const wheelListenerAttached = useRef(false);
-  if (containerRef.current && wheelListenerAttached.current === false) {
-    const el = containerRef.current;
-    el.addEventListener("wheel", (e) => handleWheelRef.current?.(e), { passive: false });
-    wheelListenerAttached.current = true;
-  }
+  useEffect(() => {
+    const el = containerRef.current;
+    if (!el) return;
+    const handler = (e: WheelEvent) => handleWheelRef.current?.(e);
+    el.addEventListener("wheel", handler, { passive: false });
+    return () => el.removeEventListener("wheel", handler);
+  }, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/ledger-playground/page-client.tsx
around lines 395 - 415, The wheel event is being attached during render, causing
potential duplicate handlers and leaks; move the attachment into a useEffect
that depends on containerRef.current (or containerRef) and viewStart/viewEnd if
needed, and inside the effect add the listener using
el.addEventListener("wheel", handler, { passive: false }) where handler
delegates to handleWheelRef.current, then return a cleanup that removes the same
listener and resets wheelListenerAttached.current; ensure you import useEffect
and only attach when containerRef.current exists and
wheelListenerAttached.current is false, and keep existing logic that calls
setViewStart/setViewEnd via handleWheelRef.
apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/ledger-playground/page-client.tsx-25-34 (1)

25-34: ⚠️ Potential issue | 🟠 Major

Add explanatory comments for any types and the as cast.

The types at lines 25-34 use any payloads without the required comments explaining why. Per coding guidelines, whenever any is used, a comment must explain the rationale and how errors would still be flagged. Similarly, the useAdminApp() cast at line 956 should be documented.

If the flexibility is intentional because the backend returns loosely-typed structures, document that. Otherwise, introduce proper types for transactions and the product field to match the actual data shapes.

Example of missing documentation
type Snapshot = {
  at_millis: number,
  owned_products: Array<{ id: string | null, type: string, quantity: number, product: any, source_id: string }>,
  item_quantities: Record<string, number>,
};

type PlaygroundResult = {
  transactions: any[],
  snapshots: Snapshot[],
};

These should include comments explaining the use of any, or be replaced with proper types.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/dashboard/src/app/`(main)/(protected)/projects/[projectId]/ledger-playground/page-client.tsx
around lines 25 - 34, Update the type declarations to either replace the loose
any types with concrete shapes or add explanatory comments: for Snapshot,
document why product is typed as any (e.g., backend returns polymorphic/unknown
product payloads) and how runtime/validation or unit tests will catch schema
errors; for PlaygroundResult, explain why transactions is any[] or define its
proper Transaction type reflecting actual fields returned; also add a comment
next to the useAdminApp() cast explaining why the cast is safe (e.g., context
guarantees AdminApp shape) and what runtime checks protect against mismatch.
Reference the Snapshot and PlaygroundResult types, the transactions and product
fields, and the useAdminApp() cast when adding comments or replacing with
precise types.
apps/backend/src/lib/payments/ledger/playground.test.ts-7-37 (1)

7-37: ⚠️ Potential issue | 🟠 Major

Add comments justifying any usage or replace with typed interfaces.

The any types in _mockPrisma, sortDesc, filterByWhere, and setupMock lack the required explanatory comments per coding guidelines. Each use of any must include a comment explaining why it's necessary and how errors remain detectable. Alternatively, use generics or minimal typed interfaces—other test files in the same directory (e.g., transactions/list.test.ts) demonstrate achievable patterns with <T extends { ... }> constraints.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/playground.test.ts` around lines 7 - 37,
The test uses wide `any` in _mockPrisma, sortDesc, filterByWhere, and setupMock;
either replace them with narrow test types (e.g., define minimal
interfaces/generic type parameters that include createdAt, refundedAt, endedAt,
cancelAtPeriodEnd, isSubscriptionCreationInvoice, tenancyId, snapshot, etc.) or
add concise justifying comments for each `any` explaining why dynamic shapes are
required in tests and how type-safety is preserved (e.g., runtime
filtering/fixtures validate shapes). Update function signatures (sortDesc,
filterByWhere, setupMock) to use those generics/interfaces or annotate each
`any` with a one-line rationale comment, and reference the same pattern used in
transactions/list.test.ts to keep consistency with project guidelines.
apps/backend/src/lib/payments/ledger/implementation.tsx-124-166 (1)

124-166: ⚠️ Potential issue | 🟠 Major

Fix revocation lookup to account for entry index, not just transaction ID.

The revocation system stores both adjusted_transaction_id AND adjusted_entry_index for each revocation (processor.ts lines 445–446), but the lookup at line 149 uses only tx.id, discarding the entry index entirely. Similarly, paidGrantsByTx (line 287) uses only tx.id as a key, which overwrites on multiple product-grant entries in the same transaction.

If a transaction contains multiple product-grant entries, the revoked quantity is incorrectly applied equally to all grants. Either enforce a single-grant-per-transaction invariant with an explicit assertion, or rekey the revocation map to use {adjusted_transaction_id, adjusted_entry_index} pairs to match the system's design granularity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/implementation.tsx` around lines 124 -
166, Revocations are keyed only by transaction ID (revokedQuantities) while
revocations include adjusted_entry_index and paidGrantsByTx also uses tx.id, so
multiple product-grant entries in one transaction get incorrect shared
revocation; update the revocation map to use a composite key of
adjusted_transaction_id + adjusted_entry_index (e.g.
`${adjusted_transaction_id}:${adjusted_entry_index}`) when building
revokedQuantities (look at the loop that checks typedEntry.type ===
"product-revocation"), and when processing product-grant entries (the loop over
tx.entries that checks typedEntry.type === "product-grant") compute the same
composite key using tx.id and the grant's entry index to fetch the correct
revoked amount; likewise change paidGrantsByTx to use the composite key (or
alternatively add an explicit assertion in the product-grant loop that a
transaction contains only one grant to avoid rekeying) so revocations apply
per-entry rather than per-transaction.
apps/backend/src/lib/payments/ledger/implementation.tsx-283-286 (1)

283-286: ⚠️ Potential issue | 🟠 Major

Remove entry as any; TypeScript can narrow discriminated unions directly.

The TransactionEntry is a proper discriminated union where each variant has a distinct type field. TypeScript can automatically narrow the type by checking entry.type without any casting. Replace the cast with direct type checks:

if (entry.type === "product-grant") {
  // entry is now narrowed to the product-grant variant
  const quantity = entry.quantity;
  const product = entry.product;
  // ...
}

This applies to all similar patterns in this function and throughout the ledger code.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/implementation.tsx` around lines 283 -
286, The loop is using an unnecessary cast ("entry as any") to access
discriminated-union variants; remove the cast and rely on TypeScript's built-in
narrowing by checking entry.type (e.g., inside the loop over tx.entries, replace
checks using typedEntry.type with direct checks on entry.type) so that
TransactionEntry is properly narrowed and you can access variant-specific fields
(like quantity, product) without any any-cast; apply the same change to all
similar patterns in this file (implementation.tsx) and other ledger code where
typedEntry is used.
🟡 Minor comments (8)
packages/stack-shared/src/interface/crud/transactions.ts-125-135 (1)

125-135: ⚠️ Potential issue | 🟡 Minor

Add conditional validation to require product when change_type is "switch".

The schema allows a "switch" change_type without specifying product or product_id, but these fields are semantically required to know the target product. Add a .test() similar to the mutual-exclusivity check on product-grant (lines 76-87) to enforce that product is present when change_type === "switch".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/stack-shared/src/interface/crud/transactions.ts` around lines 125 -
135, Add a conditional validation to
transactionEntryActiveSubscriptionChangeSchema that enforces a target product
when change_type === "switch": add a .test() (modeled on the mutual-exclusivity
test used for "product-grant") that inspects the value and fails if change_type
is "switch" and both product (inlineProductSchema) and product_id are
null/undefined/empty; return a clear validation message like "product or
product_id is required when change_type is 'switch'". Ensure the test is
attached to transactionEntryActiveSubscriptionChangeSchema so it runs after the
field-level rules.
apps/backend/src/lib/payments/ledger/ledger.test.tsx-7-27 (1)

7-27: ⚠️ Potential issue | 🟡 Minor

Add comments explaining any usage in mocks or use typed alternatives.

This file uses any extensively in mock setup (lines 7, 14–28, 85–143) without explanatory comments. Per coding guidelines, every any must include a comment explaining why it's necessary and how errors would still be flagged. Either:

  • Add inline comments for each any usage, or
  • Replace with typed mocks using Partial<PrismaClient> and Partial<Tenancy>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/ledger.test.tsx` around lines 7 - 27,
The test file uses untyped `any` for `_currentMockPrisma` and the return type of
`createMockTenancy`; replace these with typed alternatives or add explanatory
comments: either change `_currentMockPrisma` to a typed variable (e.g., `let
_currentMockPrisma: Partial<PrismaClient> | null`) and update the vi.mock
factory to return that type, and change `createMockTenancy` to return
`Partial<Tenancy>` (or `Tenancy` built from `Partial<Tenancy>`) so tests retain
type-safety, or if keeping `any`, add short inline comments next to
`_currentMockPrisma` and the `as any` casts in `createMockTenancy` explaining
why `any` is required for the mock and how TS errors are still enforced
elsewhere; reference the `_currentMockPrisma` variable, the vi.mock call, and
the `createMockTenancy` function when making these changes.
apps/backend/src/lib/payments/ledger/transactions/types.ts-25-35 (1)

25-35: ⚠️ Potential issue | 🟡 Minor

Replace product: any with a concrete type or add a justifying comment.

The product field stores yup.InferType<typeof inlineProductSchema> data from validated sources. Either export inlineProductSchema and use its type, or keep any and add a comment explaining that this field always contains validated product data from controlled sources (subscription or one-time purchase).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/transactions/types.ts` around lines 25 -
35, The ActivePurchaseState.product is typed as any; replace it with a concrete
type or justify it: either export or import the inlineProductSchema and change
the product type to yup.InferType<typeof inlineProductSchema> (update the
ActivePurchaseState definition to use that type) or, if you intentionally accept
only validated product objects from controlled sources, keep any but add a clear
comment on the product field stating it always contains validated data from
inlineProductSchema and why any is safe. Ensure references to
inlineProductSchema and ActivePurchaseState are updated together so the type is
resolvable.
apps/backend/src/lib/stripe.tsx-175-176 (1)

175-176: ⚠️ Potential issue | 🟡 Minor

Remove the unnecessary truthiness guard on billing_cycle_anchor — it's always a required number in Stripe.Subscription

subscription.billing_cycle_anchor is typed as a required number (Unix timestamp in seconds), not number | null. The truthiness check subscription.billing_cycle_anchor ? is unnecessary and violates the coding guideline to prefer explicit null/undefinedness checks over boolean checks. If billing_cycle_anchor were ever 0 (Unix epoch), the conditional would incorrectly store null.

🔧 Proposed fix
-        billingCycleAnchor: subscription.billing_cycle_anchor ? new Date(subscription.billing_cycle_anchor * 1000) : null,
+        billingCycleAnchor: new Date(subscription.billing_cycle_anchor * 1000),

Apply the same change to line 193.

Note: subscription.ended_at is correctly typed as number | null, so its ternary is appropriate.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/stripe.tsx` around lines 175 - 176, The truthiness guard
on subscription.billing_cycle_anchor incorrectly treats a valid 0 timestamp as
falsy; remove the ternary boolean check and always convert
subscription.billing_cycle_anchor to a Date when setting billingCycleAnchor
(i.e., replace the conditional expression with new
Date(subscription.billing_cycle_anchor * 1000)); also make the same change in
the other occurrence around line 193 so billingCycleAnchor is always derived
explicitly from subscription.billing_cycle_anchor rather than via a truthy
check.
apps/backend/src/lib/payments/ledger/transactions/list.test.ts-52-54 (1)

52-54: ⚠️ Potential issue | 🟡 Minor

Fake timers are never restored — timer state leaks across tests.

Each test calls vi.useFakeTimers() / vi.setSystemTime() but never calls vi.useRealTimers() or uses an afterEach cleanup. This causes fake timer state to leak into subsequent tests in the suite (or in other files if the test runner shares state).

Proposed fix
 describe("transactions list builder", () => {
+  afterEach(() => {
+    vi.useRealTimers();
+  });
+
   it("uses product grant indices for subscription-end revocation and expiry", async () => {

You'll also need to import afterEach from vitest:

-import { describe, expect, it, vi } from "vitest";
+import { afterEach, describe, expect, it, vi } from "vitest";

Also applies to: 118-120, 169-171

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/transactions/list.test.ts` around lines
52 - 54, The tests call vi.useFakeTimers()/vi.setSystemTime() in the
"transactions list builder" suite but never restore timers, causing state
leakage; add a cleanup to restore real timers by importing afterEach from vitest
and adding an afterEach hook (e.g., afterEach(() => vi.useRealTimers())) in the
same describe block (or file) so any test that calls vi.useFakeTimers() (seen in
the tests starting around the "it uses product grant indices..." case and the
other occurrences around lines ~118 and ~169) will have timers reset after each
test.
apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts-122-128 (1)

122-128: ⚠️ Potential issue | 🟡 Minor

Use a single Date instance for consistency.

currentPeriodEnd (line 124) and endedAt (line 126) each call new Date() independently, resulting in slightly different timestamps. For audit-trail consistency and deterministic behavior, capture the timestamp once.

Proposed fix
       } else {
+        const now = new Date();
         await prisma.subscription.update({
           where: {
             tenancyId_id: {
               tenancyId: auth.tenancy.id,
               id: subscription.id,
             },
           },
           data: {
             status: SubscriptionStatus.canceled,
-            currentPeriodEnd: new Date(),
+            currentPeriodEnd: now,
             cancelAtPeriodEnd: true,
-            endedAt: new Date(),
+            endedAt: now,
           },
         });
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@apps/backend/src/app/api/latest/payments/products/`[customer_type]/[customer_id]/[product_id]/route.ts
around lines 122 - 128, The update is setting currentPeriodEnd and endedAt with
separate new Date() calls causing tiny timestamp drift; capture a single Date
once (e.g., const now = new Date()) before calling the update in the route
handler and use that same variable for both data.currentPeriodEnd and
data.endedAt so both fields are identical; locate the update that writes
SubscriptionStatus.canceled in route.ts and replace the two new Date() calls
with the single captured timestamp.
apps/backend/src/app/api/latest/internal/payments/ledger-playground/route.ts-160-170 (1)

160-170: ⚠️ Potential issue | 🟡 Minor

Unsafe as any cast on entry.product — potential runtime NPE.

for (const itemId of Object.keys((entry.product as any).included_items ?? {})) {

If entry.product is undefined or doesn't have included_items, Object.keys(undefined ?? {}) is safe, but (undefined as any).included_items throws a TypeError. The as any cast here hides a potential null-dereference if entry.product is ever missing.

Consider a guard:

Proposed fix
         if (entry.type === "product-grant" && 'product' in entry) {
-          for (const itemId of Object.keys((entry.product as any).included_items ?? {})) {
+          const product = (entry as { product?: Record<string, any> }).product;
+          for (const itemId of Object.keys(product?.included_items ?? {})) {
             allItemIds.add(itemId);
           }

As per coding guidelines: "Try to avoid the any type. Whenever you need to use any, leave a comment explaining why you're using it."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/app/api/latest/internal/payments/ledger-playground/route.ts`
around lines 160 - 170, The loop that collects item IDs uses an unsafe cast
"(entry.product as any).included_items" which can throw if entry.product is
undefined; update the product-grant branch in the for-loops (the code
referencing entry.product inside the for (const tx of allTransactions) / for
(const entry of tx.entries) block) to first guard that entry.product exists and
has included_items (e.g., const included = entry.product?.included_items ?? {};
or check typeof entry.product === 'object' && entry.product !== null) and then
call Object.keys(included), removing the as any cast; if you must use any, add a
brief comment explaining why.
apps/backend/src/lib/payments/ledger/transactions/list.ts-53-58 (1)

53-58: ⚠️ Potential issue | 🟡 Minor

Invalid cursor silently resets pagination to the beginning.

When opts.after is a non-empty string that doesn't match any transaction ID, findIndex returns -1, and Math.max(0, -1 + 1) evaluates to 0 — silently starting from the first page instead of signaling an error. Per coding guidelines: "Fail early, fail loud."

Additionally, the cache key JSON.stringify(opts.filter) is sensitive to property ordering — {a:1, b:2} and {b:2, a:1} produce different keys, causing cache misses for equivalent filters.

Proposed fix for cursor validation
-    const start = opts.after ? Math.max(0, all.findIndex((tx) => tx.id === opts.after) + 1) : 0;
+    let start = 0;
+    if (opts.after) {
+      const idx = all.findIndex((tx) => tx.id === opts.after);
+      if (idx === -1) {
+        // Cursor not found — start from beginning as fallback for deleted/expired transactions
+        start = 0;
+      } else {
+        start = idx + 1;
+      }
+    }

If a missing cursor is truly an expected scenario (e.g., the referenced transaction was removed between requests), document that clearly. Otherwise, throw an error.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/backend/src/lib/payments/ledger/transactions/list.ts` around lines 53 -
58, The code silently resets pagination when opts.after is provided but not
found and uses JSON.stringify(opts.filter) which is order-sensitive; update the
logic in the listTransactions flow (the block using filterKey,
this.cache.get/set, buildTransactions, allPromise, all, start, page and the
findIndex on tx.id) to (1) create a stable cache key by normalizing the filter
(e.g., deterministic key generation or sorting object keys before stringifying)
so equivalent filters map to the same cache entry, and (2) validate the cursor:
after computing idx = all.findIndex(tx => tx.id === opts.after), if opts.after
is non-empty and idx === -1 then throw a clear error (or explicitly document and
return an empty page if removal is expected) instead of falling back to start=0;
ensure the same variables (filterKey, allPromise, all, start, page) are used
after the change.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 763094e and f42f78a.

📒 Files selected for processing (61)
  • AGENTS.md
  • apps/backend/prisma/migrations/20260219224800_add_subscription_ended_at/migration.sql
  • apps/backend/prisma/migrations/20260219230000_add_billing_cycle_anchor/migration.sql
  • apps/backend/prisma/migrations/20260220010000_add_default_products_snapshot/migration.sql
  • apps/backend/prisma/schema.prisma
  • apps/backend/scripts/verify-data-integrity/payments-verifier.ts
  • apps/backend/scripts/verify-data-integrity/stripe-payout-integrity.ts
  • apps/backend/src/app/api/latest/internal/payments/ledger-playground/route.ts
  • apps/backend/src/app/api/latest/internal/payments/resync-subscriptions/route.ts
  • apps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsx
  • apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
  • apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
  • apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts
  • apps/backend/src/app/api/latest/payments/billing/[customer_type]/[customer_id]/route.ts
  • apps/backend/src/app/api/latest/payments/invoices/[customer_type]/[customer_id]/route.ts
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.ts
  • apps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.ts
  • apps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/set-default/route.ts
  • apps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/setup-intent/route.ts
  • apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts
  • apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.ts
  • apps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.ts
  • apps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.ts
  • apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
  • apps/backend/src/app/api/latest/payments/purchases/validate-code/route.ts
  • apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx
  • apps/backend/src/lib/payments.test.tsx
  • apps/backend/src/lib/payments/implementation.tsx
  • apps/backend/src/lib/payments/index.tsx
  • apps/backend/src/lib/payments/ledger/algo.test.ts
  • apps/backend/src/lib/payments/ledger/algo.ts
  • apps/backend/src/lib/payments/ledger/implementation.tsx
  • apps/backend/src/lib/payments/ledger/index.tsx
  • apps/backend/src/lib/payments/ledger/ledger.test.tsx
  • apps/backend/src/lib/payments/ledger/playground.test.ts
  • apps/backend/src/lib/payments/ledger/refund.ts
  • apps/backend/src/lib/payments/ledger/transaction-helpers.ts
  • apps/backend/src/lib/payments/ledger/transactions/helpers-core.ts
  • apps/backend/src/lib/payments/ledger/transactions/index.ts
  • apps/backend/src/lib/payments/ledger/transactions/list.test.ts
  • apps/backend/src/lib/payments/ledger/transactions/list.ts
  • apps/backend/src/lib/payments/ledger/transactions/processor.ts
  • apps/backend/src/lib/payments/ledger/transactions/queue.ts
  • apps/backend/src/lib/payments/ledger/transactions/seed-events.ts
  • apps/backend/src/lib/payments/ledger/transactions/state.ts
  • apps/backend/src/lib/payments/ledger/transactions/types.ts
  • apps/backend/src/lib/payments/payments.test.tsx
  • apps/backend/src/lib/stripe.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/ledger-playground/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/ledger-playground/page.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments-resync/page-client.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments-resync/page.tsx
  • apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/products/[productId]/page-client.tsx
  • apps/dashboard/src/components/data-table/transaction-table.tsx
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/internal/transactions.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/payments/before-offer-to-product-rename/outdated--validate-code.test.ts
  • apps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.ts
  • claude/CLAUDE-KNOWLEDGE.md
  • packages/stack-shared/src/interface/crud/transactions.ts
  • packages/stack-shared/src/schema-fields.ts
💤 Files with no reviewable changes (2)
  • apps/backend/src/lib/payments.test.tsx
  • apps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.ts

Copy link
Collaborator

@nams1570 nams1570 left a comment

Choose a reason for hiding this comment

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

This is a partial review. Flagged some bugs and things for discussion

return;
}

if (event.kind === "subscription-end-event") {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Bug: This is for an edge case. Lets say a user of ours sets things up so that if you cancel your subscription within the first 5 days you get a refund. Their customer does so. The endedAt would be set at the time the subscription is cancelled right? but if a refund takes longer to process then the refundedAt would be set to a later date than the endedAt (endedAt < refundedAt). If this were to happen, then the subscription-end-event would be processed first and state.activePurchases.delete(sourceKey); would be run. Then, when the subscription-refund-event runs this block of code would be hit:

 const source = state.activePurchases.get(sourceKey);
 if (!source) return;

In practice, this means the refund event would fail. In our codebase, refundedAt is set when this route /internal/payments/transactions/refund/route.tsx is hit, and the cancel and refund actions aren't coupled together enough to deal with this case. Not that coupling is a good idea, just pointing out that it doesn't seem like we handle this.

@N2D4 N2D4 changed the title Subscription transactions rework + ledger playground Transactions rework + ledger playground Feb 27, 2026
Copy link
Collaborator

@nams1570 nams1570 left a comment

Choose a reason for hiding this comment

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

Identified a few potential bugs. Broadly, we should think about what happens when a stripe call succeeds while a db call fails and vice versa. How do we keep them in sync?
Also, can we trust stripe calls to throw on duplicate requests always? For the refund.create call, I know that it throws if a refund has been asked for a charge which has already been refunded to the original extent for example, but I'm not sure about other stripe calls.
There's an interesting page on ensuring idempotency with stripe operations that may be worth taking a look at: https://docs.stripe.com/api/idempotent_requests?api-version=2024-11-20.acacia

if (conflicting.stripeSubscriptionId) {
const conflictingSubscriptions = purchaseContext.conflictingProducts.filter((p) => p.type === "subscription");
if (conflictingSubscriptions.length > 0) {
const conflicting = conflictingSubscriptions[0];
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why pick just the first conflicting subscription here?

Copy link
Collaborator

Choose a reason for hiding this comment

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

we should cancel all the conflicting subscriptions from the purchase context. Purchase context is "hey if i buy this, what would the conflicting subscriptions be?"

@@ -593,6 +345,7 @@ export function productToInlineProduct(product: ProductWithMetadata): yup.InferT
return {
Copy link
Collaborator

@nams1570 nams1570 Feb 27, 2026

Choose a reason for hiding this comment

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

[Re: line +296]

Are we sure this function is concurrent-safe? If two requests invoke this function at the same time, you can end up with two stripe.customers.create calls. I don't know if it throws on multiple calls with the same metadata.

See this comment inline on Graphite.

Copy link
Collaborator

Choose a reason for hiding this comment

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

try to find a fix if it isn't too hacky

}
if (subscription.refundedAt) {
events.push({ kind: "subscription-refund-event", at: subscription.refundedAt.getTime(), subscription });
}
Copy link

Choose a reason for hiding this comment

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

Refund silently lost when subscription ends before refund

High Severity

When endedAt < refundedAt on a subscription, fetchSeedEvents emits both a subscription-end-event and a subscription-refund-event. The end event fires first, calls state.activePurchases.delete(sourceKey), and then the refund handler finds no source and silently returns. This drops the refund's money-transfer reversal entry, meaning the financial ledger never records the refund.

Additional Locations (1)

Fix in Cursor Fix in Web

const includedItemCount = Object.keys(product.includedItems).length;
const subProductGrantIndex = 2;
const subTotalEntries = 3 + includedItemCount;
validateRefundEntries(refundEntries, subscription.quantity, subProductGrantIndex, subTotalEntries);
Copy link

Choose a reason for hiding this comment

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

Hardcoded product-grant index wrong without money transfer

Medium Severity

subProductGrantIndex is hardcoded to 2 and subTotalEntries to 3 + includedItemCount, assuming the money-transfer entry is always at index 1. In processor.ts, the money-transfer entry is conditionally added (skipped for test-mode or when charged amount is empty). When absent, the product-grant sits at index 1, not 2. The old code dynamically found the index via findIndex. This causes validateRefundEntries to reject valid refund entry indices or validate against the wrong entry.

Fix in Cursor Fix in Web

Copy link

@cursor cursor bot left a comment

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 3 potential issues.

Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

@nams1570 nams1570 force-pushed the payment-subscription-handling-rework branch from 72c2829 to d7081a8 Compare March 9, 2026 21:29
@nams1570
Copy link
Collaborator

Identified a few potential bugs. Broadly, we should think about what happens when a stripe call succeeds while a db call fails and vice versa. How do we keep them in sync? Also, can we trust stripe calls to throw on duplicate requests always? For the refund.create call, I know that it throws if a refund has been asked for a charge which has already been refunded to the original extent for example, but I'm not sure about other stripe calls. There's an interesting page on ensuring idempotency with stripe operations that may be worth taking a look at: https://docs.stripe.com/api/idempotent_requests?api-version=2024-11-20.acacia

Generally we should log a sentry error when stripe succeeds and db fails and vice versa, that way it can be manually fixed.

Purchases have to be either subscriptions or one time.
We want the subscription code to be able to handle the case where a product line has multiple default products. If the user has no other active product in that line, we should deterministically pick a product in that product line. Else, always pick the paid product.
schema enforces that product_revocation txn entry should have adj_txn_id and adj_txn_entry...
paidGrants, like revocation grants should be keyed by txn_id + txn_entry_idx both.
If not, multiple product grants for same txn overwrite each other and we skip them when counting.
We also get rid of typedEntry- it was just a way to break the type system
typedEntry is just a workaround the type system
Now if two txns or their entries have the same effective_at_millis, it is deterministic.
Also the previous expiresAtMillis check had wrong behavior when expiresAtMillis was set to 0
if you change schedule or items, then it is ok to reanchor. But we shouldn't reanchor if its a pure metadata change
if expires when repeated flips then it's effectively a schedule change.
When an invalid cursor is passed, it would have started from the beginning. Now we fail fast.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants