Transactions rework + ledger playground#1220
Transactions rework + ledger playground#1220N2D4 wants to merge 23 commits intopayment-subscription-handling-reworkfrom
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
📝 Coding Plan
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 |
Greptile SummaryThis 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:
Testing:
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
Important Files Changed
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]
Last reviewed commit: f42f78a |
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
Outdated
Show resolved
Hide resolved
...end/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts
Show resolved
Hide resolved
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
Show resolved
Hide resolved
apps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsx
Show resolved
Hide resolved
There was a problem hiding this comment.
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 | 🟠 MajorAdd 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 | 🟡 MinorFix 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 | 🟡 MinorFix inconsistent transaction type casing:
money_transfershould bemoney-transfer.Line 243 correctly uses kebab-case
"product-grant", but line 252 uses snake_case"money_transfer". The canonical transaction entry types are defined inpackages/stack-shared/src/interface/crud/transactions.tsas"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 | 🟠 MajorRemove
as anycasts and validate filter values instead.The filter values from
columnFiltersare 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 | 🟠 MajorEnsure a migration exists for
Subscription.endedAt(CI shows schema/migration drift).
The pipeline reports Prisma migrations out of sync; since this verifier now relies onendedAt, 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 | 🟠 MajorValidate 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 | 🟠 MajorDifferentiate 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 MoneyAmounttype cast violates coding guidelines
entry.amount_usdis runtime-validated bymoneyAmountSchema(USD_CURRENCY).defined(), but theas MoneyAmountcast 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
JsonValuecasts 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 infersMoneyAmountdirectly, 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
moneyAmountSchemarather 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 | 🟠 MajorBulk 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:
- Adding a per-project or per-customer concurrency limit (e.g.,
p-limitor batched Promise.all).- Logging progress periodically so operators can monitor long-running syncs.
- 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 | 🟠 MajorUnjustified
as anycast on Prisma filter.The
as anysuppresses a type mismatch between the conditional filter shape and Prisma's generated types. Per coding guidelines,anyusage 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
anytype. Whenever you need to useany, 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
updatedAtis an unreliable timestamp for the cancel event.
subscription.updatedAtis used as theattimestamp forsubscription-cancel-event, butupdatedAtis 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
canceledAtfield to theSubscriptionmodel, 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 | 🟠 MajorStripe 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
refundedAtis never set, meaning a retry would attempt a second Stripe refund.Consider:
- Using a Stripe idempotency key on
stripe.refunds.create(...)to prevent double-refunds on retry.- 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
refundOneTimePurchaseat 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 | 🟠 MajorUnhandled promise rejections in async
onClickhandlers.Both
onClickhandlers are async lambdas whose returned promises are silently discarded by React. IfrunResync()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
runAsynchronouslyorrunAsynchronouslyWithAlertinstead 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 | 🟠 MajorMutating typed entry via
as anyto attachitem_quantity_change_indicesbypasses type safety.(entries[productGrantIndex] as any).item_quantity_change_indices = itemQuantityChangeIndices;This adds a field that doesn't exist on
TransactionEntryby casting toany. IfTransactionEntryis later validated or serialized in a way that strips unknown fields, this data will be silently lost. Consider either:
- Adding
item_quantity_change_indicesto theproduct-grantentry type in theTransactionEntrydiscriminated union, or- Including it in the entry object literal at construction time (Lines 269-281 / 342-354) so the cast to
TransactionEntrycovers 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 | 🟠 MajorSnapshot comparison can thrash due to key-order differences.
JSON.stringifycompares 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 | 🟠 MajorHandle both
included_items(snake_case) andincludedItems(camelCase) inparseIncludedItems.The function currently only checks
product?.included_items. When a product with camelCaseincludedItemsflows through, item grants silently drop without error. The codebase already normalizes both forms elsewhere (e.g., inledger/implementation.tsx), showing this is a known concern. Additionally, theproduct: anyparameter 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 | 🟠 MajorMove wheel listener setup into
useEffectwith 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
useEffectwith a cleanup function. AdduseEffectto 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 | 🟠 MajorAdd explanatory comments for
anytypes and theascast.The types at lines 25-34 use
anypayloads without the required comments explaining why. Per coding guidelines, wheneveranyis used, a comment must explain the rationale and how errors would still be flagged. Similarly, theuseAdminApp()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
transactionsand theproductfield 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 | 🟠 MajorAdd comments justifying
anyusage or replace with typed interfaces.The
anytypes in_mockPrisma,sortDesc,filterByWhere, andsetupMocklack the required explanatory comments per coding guidelines. Each use ofanymust 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 | 🟠 MajorFix revocation lookup to account for entry index, not just transaction ID.
The revocation system stores both
adjusted_transaction_idANDadjusted_entry_indexfor each revocation (processor.ts lines 445–446), but the lookup at line 149 uses onlytx.id, discarding the entry index entirely. Similarly,paidGrantsByTx(line 287) uses onlytx.idas 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 | 🟠 MajorRemove
entry as any; TypeScript can narrow discriminated unions directly.The
TransactionEntryis a proper discriminated union where each variant has a distincttypefield. TypeScript can automatically narrow the type by checkingentry.typewithout 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 | 🟡 MinorAdd conditional validation to require
productwhenchange_typeis"switch".The schema allows a "switch" change_type without specifying
productorproduct_id, but these fields are semantically required to know the target product. Add a.test()similar to the mutual-exclusivity check onproduct-grant(lines 76-87) to enforce thatproductis present whenchange_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 | 🟡 MinorAdd comments explaining
anyusage in mocks or use typed alternatives.This file uses
anyextensively in mock setup (lines 7, 14–28, 85–143) without explanatory comments. Per coding guidelines, everyanymust include a comment explaining why it's necessary and how errors would still be flagged. Either:
- Add inline comments for each
anyusage, or- Replace with typed mocks using
Partial<PrismaClient>andPartial<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 | 🟡 MinorReplace
product: anywith a concrete type or add a justifying comment.The
productfield storesyup.InferType<typeof inlineProductSchema>data from validated sources. Either exportinlineProductSchemaand use its type, or keepanyand 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 | 🟡 MinorRemove the unnecessary truthiness guard on
billing_cycle_anchor— it's always a requirednumberin Stripe.Subscription
subscription.billing_cycle_anchoris typed as a requirednumber(Unix timestamp in seconds), notnumber | null. The truthiness checksubscription.billing_cycle_anchor ?is unnecessary and violates the coding guideline to prefer explicit null/undefinedness checks over boolean checks. Ifbilling_cycle_anchorwere ever 0 (Unix epoch), the conditional would incorrectly storenull.🔧 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_atis correctly typed asnumber | 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 | 🟡 MinorFake timers are never restored — timer state leaks across tests.
Each test calls
vi.useFakeTimers()/vi.setSystemTime()but never callsvi.useRealTimers()or uses anafterEachcleanup. 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
afterEachfrom 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 | 🟡 MinorUse a single
Dateinstance for consistency.
currentPeriodEnd(line 124) andendedAt(line 126) each callnew 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 | 🟡 MinorUnsafe
as anycast onentry.product— potential runtime NPE.for (const itemId of Object.keys((entry.product as any).included_items ?? {})) {If
entry.productisundefinedor doesn't haveincluded_items,Object.keys(undefined ?? {})is safe, but(undefined as any).included_itemsthrows a TypeError. Theas anycast here hides a potential null-dereference ifentry.productis 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
anytype. Whenever you need to useany, 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 | 🟡 MinorInvalid cursor silently resets pagination to the beginning.
When
opts.afteris a non-empty string that doesn't match any transaction ID,findIndexreturns-1, andMath.max(0, -1 + 1)evaluates to0— 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
📒 Files selected for processing (61)
AGENTS.mdapps/backend/prisma/migrations/20260219224800_add_subscription_ended_at/migration.sqlapps/backend/prisma/migrations/20260219230000_add_billing_cycle_anchor/migration.sqlapps/backend/prisma/migrations/20260220010000_add_default_products_snapshot/migration.sqlapps/backend/prisma/schema.prismaapps/backend/scripts/verify-data-integrity/payments-verifier.tsapps/backend/scripts/verify-data-integrity/stripe-payout-integrity.tsapps/backend/src/app/api/latest/internal/payments/ledger-playground/route.tsapps/backend/src/app/api/latest/internal/payments/resync-subscriptions/route.tsapps/backend/src/app/api/latest/internal/payments/test-mode-purchase-session/route.tsxapps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsxapps/backend/src/app/api/latest/internal/payments/transactions/route.tsxapps/backend/src/app/api/latest/internal/payments/transactions/transaction-builder.tsapps/backend/src/app/api/latest/payments/billing/[customer_type]/[customer_id]/route.tsapps/backend/src/app/api/latest/payments/invoices/[customer_type]/[customer_id]/route.tsapps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/route.tsapps/backend/src/app/api/latest/payments/items/[customer_type]/[customer_id]/[item_id]/update-quantity/route.tsapps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/set-default/route.tsapps/backend/src/app/api/latest/payments/payment-method/[customer_type]/[customer_id]/setup-intent/route.tsapps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.tsapps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/route.tsapps/backend/src/app/api/latest/payments/products/[customer_type]/[customer_id]/switch/route.tsapps/backend/src/app/api/latest/payments/purchases/create-purchase-url/route.tsapps/backend/src/app/api/latest/payments/purchases/purchase-session/route.tsxapps/backend/src/app/api/latest/payments/purchases/validate-code/route.tsapps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsxapps/backend/src/lib/payments.test.tsxapps/backend/src/lib/payments/implementation.tsxapps/backend/src/lib/payments/index.tsxapps/backend/src/lib/payments/ledger/algo.test.tsapps/backend/src/lib/payments/ledger/algo.tsapps/backend/src/lib/payments/ledger/implementation.tsxapps/backend/src/lib/payments/ledger/index.tsxapps/backend/src/lib/payments/ledger/ledger.test.tsxapps/backend/src/lib/payments/ledger/playground.test.tsapps/backend/src/lib/payments/ledger/refund.tsapps/backend/src/lib/payments/ledger/transaction-helpers.tsapps/backend/src/lib/payments/ledger/transactions/helpers-core.tsapps/backend/src/lib/payments/ledger/transactions/index.tsapps/backend/src/lib/payments/ledger/transactions/list.test.tsapps/backend/src/lib/payments/ledger/transactions/list.tsapps/backend/src/lib/payments/ledger/transactions/processor.tsapps/backend/src/lib/payments/ledger/transactions/queue.tsapps/backend/src/lib/payments/ledger/transactions/seed-events.tsapps/backend/src/lib/payments/ledger/transactions/state.tsapps/backend/src/lib/payments/ledger/transactions/types.tsapps/backend/src/lib/payments/payments.test.tsxapps/backend/src/lib/stripe.tsxapps/dashboard/src/app/(main)/(protected)/projects/[projectId]/ledger-playground/page-client.tsxapps/dashboard/src/app/(main)/(protected)/projects/[projectId]/ledger-playground/page.tsxapps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments-resync/page-client.tsxapps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments-resync/page.tsxapps/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/payments/before-offer-to-product-rename/outdated--validate-code.test.tsapps/e2e/tests/backend/endpoints/api/v1/payments/validate-code.test.tsclaude/CLAUDE-KNOWLEDGE.mdpackages/stack-shared/src/interface/crud/transactions.tspackages/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
apps/backend/prisma/migrations/20260219230000_add_billing_cycle_anchor/migration.sql
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/payments/resync-subscriptions/route.ts
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/payments/ledger-playground/route.ts
Show resolved
Hide resolved
nams1570
left a comment
There was a problem hiding this comment.
This is a partial review. Flagged some bugs and things for discussion
| return; | ||
| } | ||
|
|
||
| if (event.kind === "subscription-end-event") { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
...end/src/app/api/latest/payments/products/[customer_type]/[customer_id]/[product_id]/route.ts
Show resolved
Hide resolved
apps/backend/src/app/api/latest/team-invitations/accept/verification-code-handler.tsx
Show resolved
Hide resolved
| if (conflicting.stripeSubscriptionId) { | ||
| const conflictingSubscriptions = purchaseContext.conflictingProducts.filter((p) => p.type === "subscription"); | ||
| if (conflictingSubscriptions.length > 0) { | ||
| const conflicting = conflictingSubscriptions[0]; |
There was a problem hiding this comment.
Why pick just the first conflicting subscription here?
There was a problem hiding this comment.
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 { | |||
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 }); | ||
| } |
There was a problem hiding this comment.
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)
| const includedItemCount = Object.keys(product.includedItems).length; | ||
| const subProductGrantIndex = 2; | ||
| const subTotalEntries = 3 + includedItemCount; | ||
| validateRefundEntries(refundEntries, subscription.quantity, subProductGrantIndex, subTotalEntries); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
Outdated
Show resolved
Hide resolved
apps/backend/src/app/api/latest/internal/payments/transactions/route.tsx
Outdated
Show resolved
Hide resolved
72c2829 to
d7081a8
Compare
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.
We pick the latest snapshot
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.


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:
SubscriptiongainsendedAt+billingCycleAnchor, and a newDefaultProductsSnapshottable is introduced and used to snapshot include-by-default products for correct historical calculations.Adds internal-only tools: a hidden
ledger-playgroundendpoint to run ledger functions over live/mock data with time-sliced snapshots, and aresync-subscriptionsendpoint to re-pull subscription state from Stripe across projects. Also standardizes transaction entrytypestrings to kebab-case and updates cancellation/refund paths to set/useendedAtand delegate torefundTransaction.Written by Cursor Bugbot for commit 05fe175. This will update automatically on new commits. Configure here.
Summary by CodeRabbit
New Features
Chores