Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
122 changes: 122 additions & 0 deletions apps/api/src/lib/self-refund.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,128 @@ describe("computeSelfRefundEligibility", () => {
});
});

test("with one pass redeemed, only the newest of two purchases is refundable", async () => {
// Two pro passes bought, one redeemed → inventory 1. The remaining pass
// is attributed to the newest purchase; the older one counts as redeemed
// and must not be refundable against the newer purchase's pass.
await seedOrg({
kind: "devpass",
devPlan: "pro",
devPlanResetPassesPro: 1,
});
const older = await seedTransaction({
type: "dev_plan_reset_pass",
amount: "29",
creditAmount: null,
createdAt: daysAgo(2),
stripePaymentIntentId: "pi_test_older",
});
const newer = await seedTransaction({
type: "dev_plan_reset_pass",
amount: "29",
creditAmount: null,
createdAt: daysAgo(1),
stripePaymentIntentId: "pi_test_newer",
});

expect(await getEligibility(newer.id)).toEqual({ eligible: true });
expect(await getEligibility(older.id)).toEqual({
eligible: false,
reason: "pass_already_used",
});
});

test("both purchases are refundable while inventory covers both", async () => {
await seedOrg({
kind: "devpass",
devPlan: "pro",
devPlanResetPassesPro: 2,
});
const older = await seedTransaction({
type: "dev_plan_reset_pass",
amount: "29",
creditAmount: null,
createdAt: daysAgo(2),
stripePaymentIntentId: "pi_test_older",
});
const newer = await seedTransaction({
type: "dev_plan_reset_pass",
amount: "29",
creditAmount: null,
createdAt: daysAgo(1),
stripePaymentIntentId: "pi_test_newer",
});

expect(await getEligibility(newer.id)).toEqual({ eligible: true });
expect(await getEligibility(older.id)).toEqual({ eligible: true });
});

test("a refunded newer purchase no longer blocks the older one", async () => {
// The newer purchase was refunded (clawback already applied → inventory
// back to 1); the older purchase's pass is genuinely unredeemed.
await seedOrg({
kind: "devpass",
devPlan: "pro",
devPlanResetPassesPro: 1,
});
const older = await seedTransaction({
type: "dev_plan_reset_pass",
amount: "29",
creditAmount: null,
createdAt: daysAgo(2),
stripePaymentIntentId: "pi_test_older",
});
const newer = await seedTransaction({
type: "dev_plan_reset_pass",
amount: "29",
creditAmount: null,
createdAt: daysAgo(1),
stripePaymentIntentId: "pi_test_newer",
});
await seedTransaction({
type: "credit_refund",
amount: "29",
creditAmount: "0",
stripeRefundId: "re_test_newer",
relatedTransactionId: newer.id,
stripePaymentIntentId: null,
});

expect(await getEligibility(older.id)).toEqual({ eligible: true });
});

test("a redeemed lite pass can't be refunded against pro inventory", async () => {
// The attribution count is tier-bound both ways: a lite purchase whose
// pass was redeemed stays ineligible even with unrelated pro passes and
// a newer pro purchase present.
await seedOrg({
kind: "devpass",
devPlan: "pro",
devPlanResetPassesLite: 0,
devPlanResetPassesPro: 1,
});
const lite = await seedTransaction({
type: "dev_plan_reset_pass",
amount: "9",
creditAmount: null,
createdAt: daysAgo(2),
stripePaymentIntentId: "pi_test_lite",
});
const pro = await seedTransaction({
type: "dev_plan_reset_pass",
amount: "29",
creditAmount: null,
createdAt: daysAgo(1),
stripePaymentIntentId: "pi_test_pro",
});

expect(await getEligibility(lite.id)).toEqual({
eligible: false,
reason: "pass_already_used",
});
expect(await getEligibility(pro.id)).toEqual({ eligible: true });
});

test("reset pass inventory is tier-bound: a lite purchase can't ride on pro passes", async () => {
// $9 maps to a lite pass; the held inventory is pro-only, so the lite
// purchase itself has already been redeemed.
Expand Down
45 changes: 35 additions & 10 deletions apps/api/src/lib/self-refund.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,15 +243,23 @@ function checkPlanEligibility(
}

/**
* A Reset Pass purchase is returnable while the pass itself is still unused:
* the tier-bound inventory the purchase granted must still hold at least one
* pass. The tier is recovered from the charged amount — fulfilment validated
* it against the tier's fixed price, so the mapping is unambiguous. The
* `charge.refunded` webhook performs the clawback, so a redeem racing the
* refund can at worst leave the clamped-at-zero inventory, never a free pass.
* A Reset Pass purchase is returnable while the pass itself is still unused.
* Passes are fungible within a tier, so redemptions are attributed to the
* oldest un-refunded purchase first: a purchase is only refundable while it
* ranks within the newest `inventory` un-refunded purchases of its tier.
* Gating on the rank rather than just `inventory >= 1` stops a second
* purchase from being refunded against the same unredeemed pass — both
* outright (a redeemed older purchase never becomes refundable again) and
* during the window before the `charge.refunded` webhook records the first
* refund's clawback. The tier is recovered from the charged amount —
* fulfilment validated it against the tier's fixed price, so the mapping is
* unambiguous. The webhook performs the clawback clamped at zero, so a
* redeem racing the refund can at worst leave empty inventory, never a free
* pass.
*/
function checkResetPassEligibility(
organization: OrganizationRow,
transactions: TransactionRow[],
transaction: TransactionRow,
): SelfRefundEligibility {
const amount = dec(transaction.amount);
Expand All @@ -262,12 +270,29 @@ function checkResetPassEligibility(
return ineligible("unsupported_type");
}
const inventory =
tier === "lite"
(tier === "lite"
? organization.devPlanResetPassesLite
: tier === "pro"
? organization.devPlanResetPassesPro
: organization.devPlanResetPassesMax;
if ((inventory ?? 0) < 1) {
: organization.devPlanResetPassesMax) ?? 0;

const refundedIds = new Set(
transactions
.filter((t) => t.type === "credit_refund" && t.relatedTransactionId)
.map((t) => t.relatedTransactionId),
);
Comment on lines +279 to +283

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Require credit_refund transactions to be completed to prevent double-refunds.

If a newer pass has a pending or failed refund, its relatedTransactionId is added to refundedIds here. This causes the newer pass to be excluded from the newerUnrefundedSameTier count below, bypassing the rank check. Because an incomplete refund has not clawed back the inventory (and won't, if failed), this artificially lowers the rank count and allows older passes to be refunded against the same unredeemed inventory, creating a double-refund loophole.

Filter for isCompleted(t) to ensure rank count and inventory stay properly in sync.

🔒️ Proposed fix to require completed refunds
 	const refundedIds = new Set(
 		transactions
-			.filter((t) => t.type === "credit_refund" && t.relatedTransactionId)
+			.filter((t) => t.type === "credit_refund" && isCompleted(t) && t.relatedTransactionId)
 			.map((t) => t.relatedTransactionId),
 	);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const refundedIds = new Set(
transactions
.filter((t) => t.type === "credit_refund" && t.relatedTransactionId)
.map((t) => t.relatedTransactionId),
);
const refundedIds = new Set(
transactions
.filter((t) => t.type === "credit_refund" && isCompleted(t) && t.relatedTransactionId)
.map((t) => t.relatedTransactionId),
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/lib/self-refund.ts` around lines 279 - 283, Update the
refundedIds construction near the existing credit_refund filter to include only
transactions for which isCompleted(t) is true. Keep requiring type ===
"credit_refund" and relatedTransactionId, so pending or failed refunds do not
affect newerUnrefundedSameTier rank counting.

const tierPrice = dec(DEV_PLAN_RESET_PASS_PRICES[tier]);
const newerUnrefundedSameTier = transactions.filter(
(t) =>
t.type === "dev_plan_reset_pass" &&
isCompleted(t) &&
!refundedIds.has(t.id) &&
dec(t.amount).eq(tierPrice) &&
(t.createdAt > transaction.createdAt ||
(t.createdAt.getTime() === transaction.createdAt.getTime() &&
t.id > transaction.id)),
).length;
if (newerUnrefundedSameTier >= inventory) {
return ineligible("pass_already_used");
}
return { eligible: true };
Expand Down Expand Up @@ -338,7 +363,7 @@ export function computeSelfRefundEligibility({
"dev",
);
case "dev_plan_reset_pass":
return checkResetPassEligibility(organization, transaction);
return checkResetPassEligibility(organization, transactions, transaction);
case "chat_plan_start":
case "chat_plan_renewal":
return checkPlanEligibility(
Expand Down
Loading