Tags: hexclave/stack-auth
Tags
payments: rework refund flow to three-knob API (#1429) ## Summary - Replaces per-entry refund schema with a flat `{ amount_usd, revoke_product, end_subscription? }` shape; refund state is now derived from bulldozer ledger rows (`refund:<sourceTxnId>:<uuid>`) instead of the legacy `refundedAt` column, enabling multiple partial refunds up to the remaining cap. - Adds `invoice_id` for refunding any subscription invoice (start or renewal), Stripe idempotency keys derived from `(tenancyId, sourceTxnId, amount, prior_refunded)` so retries dedupe but intentional partials don't collide, and a legacy backstop that rejects pre-rework `refundedAt` purchases. - Dashboard refund dialog rebuilt around the three toggles (revoke→end coupling cascades into the UI); refund rows surface in the listing as `type: "refund"` with `adjusted_by` linkage handling both new and legacy formats. ## Implements [STA2-52 — Build in refund logic for payments](https://linear.app/stack-auth/issue/STA2-52/build-in-refund-logic-for-payments) ## Documented limitations (planned follow-up work) These are called out in code comments and intentionally deferred to a follow-up PR: - **Cap-check race under concurrent refunds.** Bulldozer's embedded `BEGIN/COMMIT` prevents an outer Prisma tx from scoping the writes, so two concurrent refunds can both pass the cap check. Needs a bulldozer-aware mutex or pending-refund-intent pattern. In practice refunds are admin-only and rare, so the race window is small. - **Stripe + DB non-atomicity on the DB-success → response-loss path.** The Stripe idempotency key is keyed on `(tenancyId, sourceTxnId, amount, priorRefunded)`, so a retry after Stripe-success → DB-fail self-heals (Stripe dedupes; the next attempt writes the bulldozer row). The hole is the reverse direction: if the bulldozer row commits but the response is lost, a retry sees a higher `priorRefunded` and generates a fresh key — Stripe would issue a second real refund. No out-of-band reconciliation today. - **Dashboard can't reach the `invoice_id` path.** Refund actions are only enabled on `purchase` rows and the submit call never passes `invoice_id`, so admins refunding a renewal must use the API directly. Follow-up: enable the action on `subscription-renewal` rows and thread `invoice_id` through. ## Architectural note `active-subscription-end` and `item-quantity-expire` entries are **not** emitted on the refund row itself. They're produced by the derived sub-end transaction (`transactions.ts:158-228`) once Prisma `subscription.endedAt` is updated, keeping the `expiresWhen` / `when-repeated` semantics in one place. This is the main structural divergence from the ticket's literal entry recipe. ## Review follow-ups addressed in this PR **First-pass review:** - **KnownError back-compat preserved**: `SubscriptionAlreadyRefunded` / `OneTimePurchaseAlreadyRefunded` are once again thrown by the legacy-`refundedAt` backstop, and `TestModePurchaseNonRefundable` is thrown when an admin sends `amount_usd > 0` against a test-mode purchase. Callers catching by error code keep working through the rework. - **Idempotency-key comment corrected**: now accurately describes the `(tenancyId, sourceTxnId, amount, priorRefunded)` key and its self-healing behaviour on the Stripe-success → DB-fail retry path (see Documented limitations above for the remaining hole). - **Renewal-invoice e2e coverage added**: new test sets up a live-mode subscription via Stripe webhooks (`subscription_create` + `subscription_cycle` invoices), refunds the renewal invoice via `invoice_id`, and asserts the resulting `refund_transaction_id` starts with `refund:sub-renewal:` and is linked back via `adjusted_by` on the *renewal* row (not the start row). Plus negative cases: cross-subscription `invoice_id` → 404, `invoice_id` on a one-time purchase → SchemaError. **Second-pass review:** - **Idempotent sub-cancel error-code string fix**: the Stripe code for re-cancelling an already-canceled sub is `subscription_already_canceled`, not `subscription_canceled` — the previous catch would have re-thrown. - **End-only sub refund replay rejected**: when `amount=0, revoke=false, end=true` and the sub is already `cancelAtPeriodEnd` or `endedAt`, throw SchemaError. Otherwise `readPriorRefundSummary` doesn't see end-only events and the call would be a forever-no-op accumulating empty refund rows. - **`revoke_product=true` with renewal `invoice_id` rejected**: the product grant lives on the sub-start txn, not on renewal txns — a renewal-scoped revocation would write a back-reference to a non-existent entry. Forces admin to revoke against the start invoice (or the default no-`invoice_id` call). - **Refund row `id` matches the linkage**: the listing route now returns the full refund txnId as `id` for `type: "refund"` rows so it matches `adjusted_by.transaction_id` — the dashboard can join source rows to their refund rows. - **+2 e2e tests** for the above (end-only replay rejection, revoke+renewal rejection). **Third-pass review:** - **Dashboard refund dialog seeds state on open**: previously the reset block lived in `ActionDialog`'s `onOpenChange`, which doesn't fire on the open transition for a controlled dialog. As a result the dialog opened with the initial `useState` defaults (`amountUsd = '0'`), and an admin submitting unchanged on a paid purchase would revoke/end at $0 instead of refunding the charged amount. The seed now runs in the menu `onClick` before `setIsDialogOpen(true)`. - **`SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX` corrected from 1 → 0**: the constant is persisted as `adjustedEntryIndex` on product-revocation entries and copied through verbatim by `mapLedgerEntry`. That mapper drops the hidden `active-subscription-start` entry, so the public-API layout puts the product grant at index 0. The prior value of `1` pointed at the money-transfer entry (or out of range on test-mode subs) through the public listing. - **`amountTotal` cap gated behind a USD pre-flight**: `SubscriptionInvoice` doesn't persist invoice currency, and the previous code took `invoice.amountTotal` as USD cents directly. Now `getTotalUsdStripeUnits` (which throws on non-USD pricing) is always called first; `amountTotal` is only preferred as the actual cap after that pre-flight succeeds. ## Test plan - [x] `pnpm typecheck` — 28/28 pass - [x] `pnpm lint` — 28/28 pass - [x] `pnpm test run apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts` — **19/19 pass** (was 14/14 on the original PR; +3 for `invoice_id` path: renewal refund happy path, unrelated `invoice_id` rejection, `invoice_id` on OTP rejection; +2 for second-pass: end-only replay rejection, revoke+renewal rejection) - [x] curl smoke against `/api/latest/internal/payments/transactions/refund` — unknown purchase → 404, no-op → 400, negative → 400, sub-revoke-without-end → 400 - [x] **Dashboard UI end-to-end re-run pending** — the original agent-browser pass ran before the third-pass dialog-seed fix, so any "money + revoke" submissions may have actually sent `amount_usd = "0"`. Re-test before un-drafting: open the refund dialog from the menu, confirm the amount field pre-fills with the charged amount, exercise validation (negative / exceeds-cap / no-op), and submit both an end-subscription-only sub refund and a money+revoke OTP refund; verify bulldozer rows and Prisma `cancelAtPeriodEnd` updates. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Ledger-driven refund flow with stable refund IDs, invoice-aware refunds, OTP/product-revocation support, tri-state end_action (now / at-period-end / none), and API responses that include refund_transaction_id. * **Bug Fixes / Improvements** * Deterministic Stripe idempotency, stronger replay protection, refundable-amount caps, test-mode constraints, and transactions listing updated to surface refunds. * **Tests** * Expanded unit and E2E coverage for new request shape, invoice paths, money-unit conversion, and edge cases. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1429) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
payments: rework refund flow to three-knob API (#1429) ## Summary - Replaces per-entry refund schema with a flat `{ amount_usd, revoke_product, end_subscription? }` shape; refund state is now derived from bulldozer ledger rows (`refund:<sourceTxnId>:<uuid>`) instead of the legacy `refundedAt` column, enabling multiple partial refunds up to the remaining cap. - Adds `invoice_id` for refunding any subscription invoice (start or renewal), Stripe idempotency keys derived from `(tenancyId, sourceTxnId, amount, prior_refunded)` so retries dedupe but intentional partials don't collide, and a legacy backstop that rejects pre-rework `refundedAt` purchases. - Dashboard refund dialog rebuilt around the three toggles (revoke→end coupling cascades into the UI); refund rows surface in the listing as `type: "refund"` with `adjusted_by` linkage handling both new and legacy formats. ## Implements [STA2-52 — Build in refund logic for payments](https://linear.app/stack-auth/issue/STA2-52/build-in-refund-logic-for-payments) ## Documented limitations (planned follow-up work) These are called out in code comments and intentionally deferred to a follow-up PR: - **Cap-check race under concurrent refunds.** Bulldozer's embedded `BEGIN/COMMIT` prevents an outer Prisma tx from scoping the writes, so two concurrent refunds can both pass the cap check. Needs a bulldozer-aware mutex or pending-refund-intent pattern. In practice refunds are admin-only and rare, so the race window is small. - **Stripe + DB non-atomicity on the DB-success → response-loss path.** The Stripe idempotency key is keyed on `(tenancyId, sourceTxnId, amount, priorRefunded)`, so a retry after Stripe-success → DB-fail self-heals (Stripe dedupes; the next attempt writes the bulldozer row). The hole is the reverse direction: if the bulldozer row commits but the response is lost, a retry sees a higher `priorRefunded` and generates a fresh key — Stripe would issue a second real refund. No out-of-band reconciliation today. - **Dashboard can't reach the `invoice_id` path.** Refund actions are only enabled on `purchase` rows and the submit call never passes `invoice_id`, so admins refunding a renewal must use the API directly. Follow-up: enable the action on `subscription-renewal` rows and thread `invoice_id` through. ## Architectural note `active-subscription-end` and `item-quantity-expire` entries are **not** emitted on the refund row itself. They're produced by the derived sub-end transaction (`transactions.ts:158-228`) once Prisma `subscription.endedAt` is updated, keeping the `expiresWhen` / `when-repeated` semantics in one place. This is the main structural divergence from the ticket's literal entry recipe. ## Review follow-ups addressed in this PR **First-pass review:** - **KnownError back-compat preserved**: `SubscriptionAlreadyRefunded` / `OneTimePurchaseAlreadyRefunded` are once again thrown by the legacy-`refundedAt` backstop, and `TestModePurchaseNonRefundable` is thrown when an admin sends `amount_usd > 0` against a test-mode purchase. Callers catching by error code keep working through the rework. - **Idempotency-key comment corrected**: now accurately describes the `(tenancyId, sourceTxnId, amount, priorRefunded)` key and its self-healing behaviour on the Stripe-success → DB-fail retry path (see Documented limitations above for the remaining hole). - **Renewal-invoice e2e coverage added**: new test sets up a live-mode subscription via Stripe webhooks (`subscription_create` + `subscription_cycle` invoices), refunds the renewal invoice via `invoice_id`, and asserts the resulting `refund_transaction_id` starts with `refund:sub-renewal:` and is linked back via `adjusted_by` on the *renewal* row (not the start row). Plus negative cases: cross-subscription `invoice_id` → 404, `invoice_id` on a one-time purchase → SchemaError. **Second-pass review:** - **Idempotent sub-cancel error-code string fix**: the Stripe code for re-cancelling an already-canceled sub is `subscription_already_canceled`, not `subscription_canceled` — the previous catch would have re-thrown. - **End-only sub refund replay rejected**: when `amount=0, revoke=false, end=true` and the sub is already `cancelAtPeriodEnd` or `endedAt`, throw SchemaError. Otherwise `readPriorRefundSummary` doesn't see end-only events and the call would be a forever-no-op accumulating empty refund rows. - **`revoke_product=true` with renewal `invoice_id` rejected**: the product grant lives on the sub-start txn, not on renewal txns — a renewal-scoped revocation would write a back-reference to a non-existent entry. Forces admin to revoke against the start invoice (or the default no-`invoice_id` call). - **Refund row `id` matches the linkage**: the listing route now returns the full refund txnId as `id` for `type: "refund"` rows so it matches `adjusted_by.transaction_id` — the dashboard can join source rows to their refund rows. - **+2 e2e tests** for the above (end-only replay rejection, revoke+renewal rejection). **Third-pass review:** - **Dashboard refund dialog seeds state on open**: previously the reset block lived in `ActionDialog`'s `onOpenChange`, which doesn't fire on the open transition for a controlled dialog. As a result the dialog opened with the initial `useState` defaults (`amountUsd = '0'`), and an admin submitting unchanged on a paid purchase would revoke/end at $0 instead of refunding the charged amount. The seed now runs in the menu `onClick` before `setIsDialogOpen(true)`. - **`SUBSCRIPTION_START_PRODUCT_GRANT_ENTRY_INDEX` corrected from 1 → 0**: the constant is persisted as `adjustedEntryIndex` on product-revocation entries and copied through verbatim by `mapLedgerEntry`. That mapper drops the hidden `active-subscription-start` entry, so the public-API layout puts the product grant at index 0. The prior value of `1` pointed at the money-transfer entry (or out of range on test-mode subs) through the public listing. - **`amountTotal` cap gated behind a USD pre-flight**: `SubscriptionInvoice` doesn't persist invoice currency, and the previous code took `invoice.amountTotal` as USD cents directly. Now `getTotalUsdStripeUnits` (which throws on non-USD pricing) is always called first; `amountTotal` is only preferred as the actual cap after that pre-flight succeeds. ## Test plan - [x] `pnpm typecheck` — 28/28 pass - [x] `pnpm lint` — 28/28 pass - [x] `pnpm test run apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts` — **19/19 pass** (was 14/14 on the original PR; +3 for `invoice_id` path: renewal refund happy path, unrelated `invoice_id` rejection, `invoice_id` on OTP rejection; +2 for second-pass: end-only replay rejection, revoke+renewal rejection) - [x] curl smoke against `/api/latest/internal/payments/transactions/refund` — unknown purchase → 404, no-op → 400, negative → 400, sub-revoke-without-end → 400 - [x] **Dashboard UI end-to-end re-run pending** — the original agent-browser pass ran before the third-pass dialog-seed fix, so any "money + revoke" submissions may have actually sent `amount_usd = "0"`. Re-test before un-drafting: open the refund dialog from the menu, confirm the amount field pre-fills with the charged amount, exercise validation (negative / exceeds-cap / no-op), and submit both an end-subscription-only sub refund and a money+revoke OTP refund; verify bulldozer rows and Prisma `cancelAtPeriodEnd` updates. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Ledger-driven refund flow with stable refund IDs, invoice-aware refunds, OTP/product-revocation support, tri-state end_action (now / at-period-end / none), and API responses that include refund_transaction_id. * **Bug Fixes / Improvements** * Deterministic Stripe idempotency, stronger replay protection, refundable-amount caps, test-mode constraints, and transactions listing updated to surface refunds. * **Tests** * Expanded unit and E2E coverage for new request shape, invoice paths, money-unit conversion, and edge cases. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1429) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Project transfer page redesign (#1309) <!-- Make sure you've read the CONTRIBUTING.md guidelines: https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md --> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Reusable transfer confirmation UI with clear loading, success, and error states. * Neon-specific transfer flow added, guiding sign-in, account switching, or accepting transfers. * Custom integration transfer flow with streamlined confirm/check behavior. * Improved transfer sign-up redirect so users return to the correct page after auth. * **Bug Fixes** * Consistent messaging for missing/invalid/expired transfer codes. * Safer widget “Reload” handling when reset may be unavailable. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --- ## Summary Redesigns the **custom integration** project-transfer confirmation page (`/integrations/custom/projects/transfer/confirm`) onto the new design-components system (`DesignCard` + `DesignAlert` + `DesignButton` + `DesignInput`). The presentational shell is extracted into a reusable `ProjectTransferConfirmView` so the route file only handles state + API calls. The legacy Neon transfer page is split out unchanged into its own client component to keep the existing Neon × Stack co-branded UI intact. --- ## Screenshots — before and after > Captured against `http://localhost:8101` at 1280×900. Dev-only overlays (outdated-version banner, console toast, DEV badge) are hidden via injected CSS for clarity. ### Custom integration — missing transfer code Visiting `/integrations/custom/projects/transfer/confirm` with no `?code=…` query param. | Before (`dev`) | After (this PR) | | --- | --- | |  |  | |  |  | Before was a raw `"Error: No transfer code provided."` line. After is a dedicated `DesignAlert` with an explanation and recovery instructions. ### Custom integration — invalid / expired code (check endpoint fails) | Before (`dev`) | After (this PR) | | --- | --- | |  |  | |  |  | Before showed the raw backend error string (`Request validation failed on POST …`). After uses a `DesignCard` with the `ArrowsLeftRightIcon`, a friendlier "This transfer can't continue" copy in an inline `DesignAlert`, the Stack Auth logomark in the actions slot, and an explicit **Close** button to dismiss. ### Neon integration — legacy UI preserved The Neon page (`/integrations/neon/projects/transfer/confirm`) was deliberately **not** redesigned — it still uses the Neon × Stack co-branded card so partner-facing copy/branding stay identical. It's now its own client component (`neon-transfer-confirm-page.tsx`) instead of sharing the redesigned one. | Before (`dev`) | After (this PR) | | --- | --- | |  |  | |  |  | Same shell on both sides — copy was tightened slightly ("Return to your Neon dashboard and start the transfer again") and the raw API error string is gone. --- ## What changed - **New** `apps/dashboard/src/components/project-transfer-confirm-view.tsx` — purely presentational `ProjectTransferConfirmView`. Owns the design-components shell, the loading spinner, the signed-in vs signed-out branches of the success state (with `DesignInput` + "Use a different account" button), and the error / missing-code alerts. - **New** `apps/dashboard/src/app/(main)/integrations/neon-transfer-confirm-page.tsx` — extraction of the legacy Neon UI (Neon logo, Stack logo, "Project transfer" header, Card / CardContent / CardFooter). Behaviour and copy match the previous `transfer-confirm-page` exactly when `type === "neon"`. - **Rewritten** `apps/dashboard/src/app/(main)/integrations/transfer-confirm-page.tsx` — now hard-coded to the `custom` integration (no more `type` prop), defers UI to `ProjectTransferConfirmView`, and exports a `TransferConfirmMissingCodeView` used by the route when `code` is absent from the URL. - **Route plumbing** - `app/(main)/integrations/custom/projects/transfer/confirm/page.tsx` — renders the redesigned flow, falls back to `TransferConfirmMissingCodeView` when `code` is missing. - `app/(main)/integrations/neon/projects/transfer/confirm/page.tsx` — points at the new dedicated Neon client component. - **New** `apps/dashboard/src/lib/stack-app-internals.ts` — consolidates the symbol-keyed `getStackAppInternals(app)` helper (and `stackAppInternalsSymbol`) into one module with a JSDoc explainer + runtime type guard, replacing scattered `as any` casts. - **New** `apps/dashboard/src/lib/transfer-utils.ts` — `buildTransferSignUpUrl()` helper so the route file + the view stay in sync on the `/handler/signup?after_auth_return_to=…` query construction. --- ## Bot review follow-ups addressed in this PR - **Fail-loud assertions for unset handlers** in the success state of `ProjectTransferConfirmView` (`StackAssertionError` instead of silent no-op). - **SSR safety:** moved every `window.location` read into client-only handlers / `useEffect`s — the page was previously evaluating it at module load. - **Friendly error fallback** when the backend `/check` endpoint throws — replaces the raw `KnownError<…>` message with "This transfer link is invalid, has expired, or has already been used. Open the original link from the partner or integrations dashboard, or start the transfer again." - **`runAsynchronouslyWithAlert`** around every async `onClick` (Transfer, Sign in, Switch account, Close) so unhandled rejections surface to the user. - **JSX entity bug fix:** `'` was a string-attribute literal, not a JSX expression — converted to a JSX expression so it renders as `'`. - **`window.close()` removal** in error state — replaced with a Close button that resets local state, so users on a fresh tab (no opener) aren't stuck. - **`getStackAppInternals` consolidated** — previously three independent copies (here + two in `projects/page-client.tsx`). Now one helper with a runtime type guard instead of `as any`, plus a comment explaining the symbol-keyed SDK escape hatch. - **Widget-playground reset:** the original change here turned out to duplicate a deliberate prior fix on `dev` (N2D4, `e68015909d "Fix lint"`). Reverted in `fe92689eb` so we don't fight that fix. --- ## Notes for reviewers - **Start with** `components/project-transfer-confirm-view.tsx`. Everything reviewer-interesting is in the props shape (`ProjectTransferConfirmUiState` union, `onPrimary` / `onCancel` / `onSwitchAccount` callbacks). The route file just wires those to the `getStackAppInternals(app).sendRequest(...)` calls. - **The Neon page was intentionally not migrated.** Partner-facing co-branding (Neon logo × Stack logo, "Neon would like to transfer…" copy) is unchanged — flag it if you think it should be brought onto design-components too, but the goal of this PR was only the custom flow. - **API surface is unchanged** — same `/integrations/custom/projects/transfer/confirm/check` and `/integrations/custom/projects/transfer/confirm` endpoints, same request bodies, same redirect to `/projects/{project_id}` on success. - **Success state isn't in the screenshots** because reproducing it locally needs a real transfer code (the `/check` endpoint validates the code against the DB). It uses the same `DesignCard` shell with either a `DesignInput` showing the receiving account + a "Use a different account" outline button (signed-in branch), or a `DesignAlert variant="info"` prompting sign-in (signed-out branch). Worth manually testing on a real transfer before merging. ## Test plan - [ ] Visit `/integrations/custom/projects/transfer/confirm` with no `code` → renders the "transfer link is incomplete" alert (screenshots above) - [ ] Visit `/integrations/custom/projects/transfer/confirm?code=invalid` → renders the redesigned card with the friendly error inside a `DesignAlert variant="error"` and a working Close button - [ ] Trigger a real custom-integration transfer end to end → loading spinner, success state, "Accept transfer" works while signed in, "Sign in" deep-links to `/handler/signup?after_auth_return_to=…` while signed out - [ ] Visit `/integrations/neon/projects/transfer/confirm?code=…` → unchanged legacy Neon × Stack co-branded card - [ ] Light + dark mode visual sanity (screenshots above are the canonical reference) --------- Co-authored-by: Aadesh Kheria <[email protected]> Co-authored-by: aadesh18 <[email protected]>
[Refactor] [Fix] Remove default prod creation (#1350) With the new bulldozer rework we dont support default products anymore. Users are encouraged to currently manually handle granting products to their end users. We block api requests and new product creations that attempt to set no price, and we remove any options to set include-by-default. We also migrate users' existing product snapshots in `Subscriptions`, `OneTimePurchases`, and `ProductVersions` to have no price set if it's an include-by-default product. This will make it so that next time a user goes onto their products page, they will be informed that the pricing is invalid and it is no longer delivered by default. Note, however, that these products will still be providing items and the like to the users who have them. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Migrated legacy product snapshots so missing included-items no longer break readers. * Removed deprecated "include-by-default" pricing sentinel; pricing now requires explicit price entries and write validation rejects the old sentinel. * **Chores** * Simplified dashboard pricing flows: create/edit/save now use explicit prices and surface an alert when a formerly implicit free plan needs an explicit $0 price. * Config overrides and stored data are auto-normalized to explicit price objects. * **Tests** * Updated and added tests covering migration, validation, and switching behavior for explicit prices. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: mantrakp04 <[email protected]> Co-authored-by: Mantra <[email protected]>
[Fix] recover stale external db requests (#1428) Failures between claiming and the deletion of outgoing requests from the handler can leave requests stale and never clean them up. Some of these requests may also have duplicates that are fresh in the outgoing queue. These requests need to be deleted or retried. It's important to still log the stale requests to sentry so the root cause can be investigated. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved detection and recovery of stale outgoing requests; telemetry now records precise reset/deleted counts and includes sampled affected IDs. * Added an early fast path to skip unnecessary external calls when there are no pending requests. * **Refactor** * Consolidated stale-request handling into a dedicated helper and optimized recovery logic; poller telemetry now includes claim-limit attributes. [](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1428) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
[Fix] recover stale external db requests (#1428) Failures between claiming and the deletion of outgoing requests from the handler can leave requests stale and never clean them up. Some of these requests may also have duplicates that are fresh in the outgoing queue. These requests need to be deleted or retried. It's important to still log the stale requests to sentry so the root cause can be investigated. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved detection and recovery of stale outgoing requests; telemetry now records precise reset/deleted counts and includes sampled affected IDs. * Added an early fast path to skip unnecessary external calls when there are no pending requests. * **Refactor** * Consolidated stale-request handling into a dedicated helper and optimized recovery logic; poller telemetry now includes claim-limit attributes. [](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1428) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
[Feat] new scripts on migrate/seed/init run for internal (#1421) ### Context One script grants free plan to any team which is a customer of the internal project who doesnt have it already. We also want to migrate our users (internal) to the latest version of their products. Needed because some subs on dev right now dont have a plan. And internal isnt using latest version of its own growth plan. ### Describing the Paths we want to Account for 1. Users on production who currently don't have a plan should get free plans, since this script is run with every migrate 2. Users on production should get the latest version of each plan of ours. So a forced migration to latest version of internal project plans 3. No other project's products/product lines should be affected. They will continue to have product versioning 4. 2 should apply to test mode subscriptions as well, on top of stripe subscriptions. All of them should be refreshed 5. Internal project itself should get latest version of its own growth plan 6. If the bulldozer write fails, we should be able to recover on next migration (this should already be handled by init bulldozer script, because it checks if prisma db and bulldozer db are out of sync) 7. if the regenerate or backfill fail, we should be able to recover just by rerunning the script 8. Product version table should not balloon. No table should really balloon ### What I've tested on local 1. Put in 1000 db subscription rows, made them all stale and then ran the regen script. It took about 6 minutes to update all of them, and it was idempotent so rerunning it again did nothing. 2. With proper stripe keys I switched off of test mode on the internal app, granted a product to a new team and updated the product's item list. At this point I checked and the new team had the outdated version of the product. Then I ran the regen script and the new team was moved to latest product version. 3. Tried the above with the internal team's growth plan too and it worked as well. 4. Backfill actually grants free plan ### Deployment strategy in prod Run the backfill and the regen scripts once each after your migrations on the prod db. `pnpm db:backfill-internal-free-plans` will make sure every team has a free plan at least if they dont have an existing plan (and it is idempotent). After that, run `pnpm db:regen-internal-subscriptions-to-latest` which will migrate every user to the latest version of their plan (i.e latest snapshot). This should also be idempotent. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Automated backfill to grant internal free plans to qualifying billing teams. * Regeneration tool to refresh internal subscription snapshots to the latest product versions. * **Chores** * Added CLI commands and package scripts to run backfill and regen jobs. * Database init now runs payment initialization before backfill/regen. * **Tests** * Integration and unit tests added/updated to validate backfill, regeneration, and free-plan idempotency. [](https://app.coderabbit.ai/change-stack/hexclave/stack-auth/pull/1421) <!-- end of auto-generated comment: release notes by coderabbit.ai -->
Fix null-unsafe payments config validation for partial overrides (#1363) ## Summary - Make the `branchPaymentsSchema` custom validator tolerant of partial override objects - Avoid crashing when `payments.products` or `payments.productLines` are absent during validation - Add regression tests for partial configs plus the existing missing-line and customer-type mismatch cases ## Testing - Added Vitest coverage for partial payments configs and validation failures - Lint passed for the touched schema files - Typecheck passed for `packages/stack-shared` <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved validation robustness with stricter type-safety checks for payment-related data configurations. * Enhanced error messages for clearer feedback on validation failures. * **Tests** * Added comprehensive test coverage for edge cases including missing configurations and type mismatches. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
PreviousNext