Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
ca299a3
payments: rework refund flow to three-knob API (amount, revoke, end-sub)
BilalG1 May 8, 2026
e255f6c
Merge branch 'dev' into fix/reworked-refunds
BilalG1 May 13, 2026
a9368aa
payments: address pre-merge review on refund rework
BilalG1 May 13, 2026
cd3b58e
payments: address second-pass review on refund rework
BilalG1 May 13, 2026
8fc2027
payments: fix refund dialog defaults, sub-revocation entry index, and…
BilalG1 May 13, 2026
08ff1bd
payments: address third-pass refund review feedback
BilalG1 May 13, 2026
165af2e
payments: address fourth-pass refund review feedback
BilalG1 May 14, 2026
ec9c16e
payments: replace revoke_product+end_subscription with end_action tri…
BilalG1 May 14, 2026
8c8ffcf
dashboard: refund dialog uses single tri-state lifecycle picker
BilalG1 May 14, 2026
a6fbe9f
dashboard: await refund directly in dialog onClick
BilalG1 May 14, 2026
5484ea8
e2e: use decimal money notation in refund tests so values match comments
BilalG1 May 14, 2026
a253cfa
payments: idempotent-cancel guard on endAtPeriodEnd Stripe update
BilalG1 May 14, 2026
97cdc11
payments: dedupe sub product-revocation via Subscription.productRevok…
BilalG1 May 15, 2026
badf6d6
payments: emit sub-end entries on the refund row, drop refund-driven …
BilalG1 May 15, 2026
cd0e084
merge origin/dev
BilalG1 May 15, 2026
d21afa6
dashboard: reload transactions grid after a refund
BilalG1 May 15, 2026
94eb603
payments: reject contradictory end-at-period-end refunds, fix Stripe …
BilalG1 May 15, 2026
2caf02d
dashboard: drop refund confirmation checkbox
BilalG1 May 15, 2026
6969588
dashboard: reword refund lifecycle picker as actions
BilalG1 May 15, 2026
8cb042f
payments: regrant free plan after a refund ends an internal-tenancy sub
BilalG1 May 15, 2026
5c36839
Merge remote-tracking branch 'origin/dev' into fix/reworked-refunds
BilalG1 May 15, 2026
37f99bc
payments: surface customer + describe refund rows in transaction list
BilalG1 May 16, 2026
b7a425f
payments: allow refunding API-granted subscriptions
BilalG1 May 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Subscription" ADD COLUMN "productRevokedAt" TIMESTAMP(3);
8 changes: 8 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -1301,6 +1301,14 @@ model Subscription {

refundedAt DateTime?

// Set when a refund explicitly ends product access via end_action="now".
// Distinct from `endedAt` (which fires for natural expiry, webhook cancel,
// etc.) so phase-1's subscription-end mapper can tell refund-driven ends
// apart and avoid emitting a second product-revocation entry — the refund
// row already carries one. See refund/route.tsx and phase-1/transactions.ts
// for the consumer side.
productRevokedAt DateTime?

creationSource PurchaseCreationSource
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
getRefundDrivenImmediateEndedAt,
shouldRejectSubscriptionProductRevocationReplay,
} from "./route";

describe("subscription refund replay guard", () => {
it("allows retry repair when subscription marker exists but refund revocation row is missing", () => {
expect(shouldRejectSubscriptionProductRevocationReplay({
endNow: true,
productRevokedAt: new Date("2026-01-01T00:00:00Z"),
priorProductRevoked: false,
})).toBe(false);
});

it("rejects replay only after both subscription marker and refund revocation row exist", () => {
expect(shouldRejectSubscriptionProductRevocationReplay({
endNow: true,
productRevokedAt: new Date("2026-01-01T00:00:00Z"),
priorProductRevoked: true,
})).toBe(true);
});

it("does not reject non-immediate refunds", () => {
expect(shouldRejectSubscriptionProductRevocationReplay({
endNow: false,
productRevokedAt: new Date("2026-01-01T00:00:00Z"),
priorProductRevoked: true,
})).toBe(false);
});
});

describe("refund-driven immediate end timestamp", () => {
it("preserves an existing past endedAt", () => {
const existingEndedAt = new Date("2026-01-01T00:00:00Z");
const now = new Date("2026-01-02T00:00:00Z");

expect(getRefundDrivenImmediateEndedAt({ existingEndedAt, now })).toBe(existingEndedAt);
});

it("pulls a scheduled future endedAt forward to now", () => {
const now = new Date("2026-01-02T00:00:00Z");
const existingEndedAt = new Date("2026-02-01T00:00:00Z");

expect(getRefundDrivenImmediateEndedAt({ existingEndedAt, now })).toBe(now);
});

it("uses now when no endedAt exists", () => {
const now = new Date("2026-01-02T00:00:00Z");

expect(getRefundDrivenImmediateEndedAt({ existingEndedAt: null, now })).toBe(now);
});
});
1,446 changes: 1,106 additions & 340 deletions apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx
Comment thread
BilalG1 marked this conversation as resolved.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Prisma } from "@/generated/prisma/client";
import { createBulldozerExecutionContext, toQueryableSqlQuery } from "@/lib/bulldozer/db/index";
import { quoteSqlStringLiteral } from "@/lib/bulldozer/db/utilities";
import { paymentsSchema } from "@/lib/payments/schema/singleton";
import { REFUND_TXN_PREFIX, parseRefundTxnId } from "@/lib/payments/refund-txn-id";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { TRANSACTION_TYPES, transactionSchema, type Transaction, type TransactionEntry, type TransactionType } from "@stackframe/stack-shared/dist/interface/crud/transactions";
Expand All @@ -15,7 +16,8 @@ type LedgerTransactionType =
| "subscription-start"
| "one-time-purchase"
| "manual-item-quantity-change"
| "subscription-renewal";
| "subscription-renewal"
| "refund";

type LedgerCursor = {
createdAtMillis: number,
Expand All @@ -25,6 +27,8 @@ type LedgerCursor = {
type LedgerTransactionRow = {
type: LedgerTransactionType,
txnId: string,
customerType: "user" | "team" | "custom",
customerId: string,
effectiveAtMillis: number,
createdAtMillis: number,
entries: unknown[],
Expand All @@ -41,6 +45,7 @@ const DEFAULT_LEDGER_TRANSACTION_TYPES: readonly LedgerTransactionType[] = [
"one-time-purchase",
"manual-item-quantity-change",
"subscription-renewal",
"refund",
];

function parseCursor(cursor: string): LedgerCursor {
Expand Down Expand Up @@ -89,6 +94,9 @@ function getLedgerTypesForFilter(type: string | undefined): readonly LedgerTrans
case "subscription-renewal": {
return ["subscription-renewal"];
}
case "refund": {
return ["refund"];
}
case "subscription-cancellation":
case "chargeback":
case "product-change": {
Expand All @@ -110,6 +118,7 @@ function readLedgerTransactionRow(rowData: unknown): LedgerTransactionRow {
}
const txnId = Reflect.get(rowData, "txnId");
const type = Reflect.get(rowData, "type");
const customerId = Reflect.get(rowData, "customerId");
const effectiveAtMillis = Reflect.get(rowData, "effectiveAtMillis");
const createdAtMillis = Reflect.get(rowData, "createdAtMillis");
const entries = Reflect.get(rowData, "entries");
Expand All @@ -120,11 +129,15 @@ function readLedgerTransactionRow(rowData: unknown): LedgerTransactionRow {
if (typeof txnId !== "string" || txnId.length === 0) {
throw new StackAssertionError("Ledger transaction row is missing txnId", { rowData });
}
if (typeof customerId !== "string" || customerId.length === 0) {
throw new StackAssertionError("Ledger transaction row is missing customerId", { rowData });
}
if (
type !== "subscription-start" &&
type !== "one-time-purchase" &&
type !== "manual-item-quantity-change" &&
type !== "subscription-renewal"
type !== "subscription-renewal" &&
type !== "refund"
) {
throw new StackAssertionError("Unexpected ledger transaction type", { rowData });
}
Expand All @@ -147,6 +160,8 @@ function readLedgerTransactionRow(rowData: unknown): LedgerTransactionRow {
return {
type,
txnId,
customerType: readCustomerType(Reflect.get(rowData, "customerType"), "ledger transaction row"),
customerId,
effectiveAtMillis,
createdAtMillis,
entries,
Expand Down Expand Up @@ -174,6 +189,13 @@ function parseSourceId(row: LedgerTransactionRow): string {
}
return row.txnId.slice("miqc:".length);
}
if (row.type === "refund") {
// Return the full ledger txnId. Source rows link to refunds via
// `adjusted_by.transaction_id`, which carries the full refund txnId
// (matching what the refund route returns as `refund_transaction_id`).
// The listing's `id` field must match for the dashboard to join the two.
return row.txnId;
}
if (!row.txnId.startsWith("sub-renewal:")) {
throw new StackAssertionError("subscription-renewal transaction id has invalid prefix", { txnId: row.txnId });
}
Expand Down Expand Up @@ -511,6 +533,9 @@ function mapLedgerTransactionTypeToApiType(type: LedgerTransactionType): Transac
if (type === "subscription-renewal") {
return "subscription-renewal";
}
if (type === "refund") {
return "refund";
}
return "purchase";
}

Expand All @@ -522,50 +547,52 @@ function buildAdjustedByFromRefunds(options: {
return adjustedByFromRefunds ?? [];
}

/**
* Builds the source-txn → refunds lookup. New-format refunds are linked by
* parsing the txnId (`refund:<sourceTxnId>:<uuid>`). Legacy refund rows
* (`<sourceId>:refund`, written by the pre-three-knob flow) don't have a
* parseable txnId, so we fall back to scanning their `product-revocation`
* entries for `adjustedTransactionId`. This keeps the "refunded" badge
* accurate across both formats.
*/
function buildAdjustedByLookupFromRefundRows(rows: unknown[]): Map<string, Transaction["adjusted_by"]> {
const lookup = new Map<string, Transaction["adjusted_by"]>();
// Note on `entry_index`: for new-format refunds we always emit `0`. The
// SDK contract still exposes this field, but with the three-knob refund
// model there is no longer a per-source-entry refund concept — a refund
// is "amount + revoke + end-sub" against the whole source. The dashboard
// doesn't render based on this value. Legacy refund rows keep their
// original entry index for back-compat with any external readers.
const addLink = (sourceTxnId: string, refundTxnId: string, entryIndex: number) => {
const existing = lookup.get(sourceTxnId) ?? [];
lookup.set(sourceTxnId, [...existing, { transaction_id: refundTxnId, entry_index: entryIndex }]);
};
for (const rowData of rows) {
if (!isRecord(rowData)) {
throw new StackAssertionError("Refund transaction rowData is not an object", { rowData });
}
const refundTxnId = Reflect.get(rowData, "txnId");
const entries = Reflect.get(rowData, "entries");
if (typeof refundTxnId !== "string" || refundTxnId.length === 0) {
throw new StackAssertionError("Refund transaction row is missing txnId", { rowData });
}
if (!Array.isArray(entries)) {
throw new StackAssertionError("Refund transaction row has invalid entries", { rowData });
const parsed = parseRefundTxnId(refundTxnId);
if (parsed) {
addLink(parsed.sourceTxnId, refundTxnId, 0);
continue;
}
for (let entryIdx = 0; entryIdx < entries.length; entryIdx++) {
const entry = entries[entryIdx];
if (!isRecord(entry)) {
throw new StackAssertionError("Refund transaction entry is not an object", { entry, rowData });
}
if (entry.type !== "product-revocation") {
continue;
}
const adjustedTransactionId = Reflect.get(entry, "adjustedTransactionId");
// Legacy fallback: extract source txns from product-revocation entries.
const entries = Reflect.get(rowData, "entries");
if (!Array.isArray(entries)) continue;
for (const entry of entries) {
if (!isRecord(entry)) continue;
if (entry.type !== "product-revocation") continue;
const adjustedTxnId = Reflect.get(entry, "adjustedTransactionId");
if (typeof adjustedTxnId !== "string" || adjustedTxnId.length === 0) continue;
const adjustedEntryIndex = Reflect.get(entry, "adjustedEntryIndex");
if (
typeof adjustedTransactionId !== "string" ||
adjustedTransactionId.length === 0 ||
typeof adjustedEntryIndex !== "number" ||
!Number.isInteger(adjustedEntryIndex) ||
adjustedEntryIndex < 0
) {
throw new StackAssertionError("Refund transaction has invalid product-revocation back reference", {
entry,
rowData,
});
}
const existing = lookup.get(adjustedTransactionId) ?? [];
lookup.set(adjustedTransactionId, [
...existing,
{
transaction_id: refundTxnId,
entry_index: entryIdx,
},
]);
const entryIndex = typeof adjustedEntryIndex === "number" && Number.isInteger(adjustedEntryIndex) && adjustedEntryIndex >= 0
? adjustedEntryIndex
: 0;
addLink(adjustedTxnId, refundTxnId, entryIndex);
}
}
return lookup;
Expand Down Expand Up @@ -645,18 +672,33 @@ async function getTransactions(options: {

const hasMore = parsedRows.length > options.limit;
const pageRows = hasMore ? parsedRows.slice(0, options.limit) : parsedRows;
// Source rows are anything that could be refunded — refund rows themselves
// can't be the target of another refund. We only look up refunds for these.
const pageSourceRows = pageRows.filter((row) => row.type !== "refund");
let refundRows: Array<{ rowData: unknown }> = [];
if (pageRows.length > 0) {
const adjustedTransactionIdsSql = pageRows.map((row) => quoteSqlStringLiteral(row.txnId).sql).join(", ");
if (pageSourceRows.length > 0) {
// New-format refunds: txnId starts with 'refund:<sourceTxnId>:'.
// LIKE pattern is safe today because source txnIds (sub-start:<uuid>,
// sub-renewal:<id>, otp:<id>, etc.) contain no LIKE metacharacters
// (percent / underscore / backslash). Escape if a future source-id format
// includes them.
const refundLikeClauses = pageSourceRows
.map((row) => `"__rows"."rowdata"->>'txnId' LIKE ${quoteSqlStringLiteral(`${REFUND_TXN_PREFIX}${row.txnId}:%`).sql}`)
.join(" OR ");
// Legacy refunds (`<sourceId>:refund`) link via product-revocation entries.
const adjustedTransactionIdsSql = pageSourceRows
.map((row) => quoteSqlStringLiteral(row.txnId).sql)
.join(", ");
const legacyRefundClause = `EXISTS (
SELECT 1
FROM jsonb_array_elements("__rows"."rowdata"->'entries') AS "__entry"
WHERE "__entry"->>'type' = 'product-revocation'
AND "__entry"->>'adjustedTransactionId' IN (${adjustedTransactionIdsSql})
)`;
const refundWhereClauses = [
`"__rows"."rowdata"->>'tenancyId' = ${quoteSqlStringLiteral(options.tenancyId).sql}`,
`"__rows"."rowdata"->>'type' = 'refund'`,
`EXISTS (
SELECT 1
FROM jsonb_array_elements("__rows"."rowdata"->'entries') AS "__entry"
WHERE "__entry"->>'type' = 'product-revocation'
AND "__entry"->>'adjustedTransactionId' IN (${adjustedTransactionIdsSql})
)`,
`((${refundLikeClauses}) OR ${legacyRefundClause})`,
];
if (options.customerType) {
refundWhereClauses.push(`"__rows"."rowdata"->>'customerType' = ${quoteSqlStringLiteral(options.customerType).sql}`);
Expand All @@ -683,6 +725,8 @@ async function getTransactions(options: {
created_at_millis: row.createdAtMillis,
effective_at_millis: row.effectiveAtMillis,
type: mapLedgerTransactionTypeToApiType(row.type),
customer_type: row.customerType,
customer_id: row.customerId,
entries,
adjusted_by: buildAdjustedByFromRefunds({
row,
Expand Down
Loading
Loading