Skip to content

Commit 9743e58

Browse files
committed
more transaction refund tests
1 parent 375271b commit 9743e58

3 files changed

Lines changed: 364 additions & 6 deletions

File tree

apps/backend/src/app/api/latest/internal/payments/transactions/refund/route.tsx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1+
import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder";
12
import { getStripeForAccount } from "@/lib/stripe";
23
import { getPrismaClientForTenancy } from "@/prisma-client";
34
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
5+
import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions";
46
import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors";
57
import { adaptSchema, adminAuthTypeSchema, moneyAmountSchema, productSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
6-
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
7-
import { SubscriptionStatus } from "@/generated/prisma/client";
8-
import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
98
import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies";
10-
import { buildOneTimePurchaseTransaction, buildSubscriptionTransaction, resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder";
9+
import { SUPPORTED_CURRENCIES, type MoneyAmount } from "@stackframe/stack-shared/dist/utils/currency-constants";
10+
import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
1111
import { InferType } from "yup";
12-
import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions";
1312

1413
const USD_CURRENCY = SUPPORTED_CURRENCIES.find((currency) => currency.code === "USD")
1514
?? throwErr("USD currency configuration missing in SUPPORTED_CURRENCIES");

apps/e2e/tests/backend/endpoints/api/v1/internal/transactions-refund.test.ts

Lines changed: 359 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,18 @@ it("refunds partial amounts for non-test mode one-time purchases", async () => {
354354
accessType: "client",
355355
});
356356
expect(productsAfterRes.body.items).toHaveLength(0);
357+
358+
const secondRefundAttempt = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
359+
accessType: "admin",
360+
method: "POST",
361+
body: {
362+
type: "one-time-purchase",
363+
id: purchaseTransaction.id,
364+
amount_usd: "1250",
365+
refund_entries: [{ entry_index: 0, quantity: 1 }],
366+
},
367+
});
368+
expect(secondRefundAttempt.body.code).toBe("ONE_TIME_PURCHASE_ALREADY_REFUNDED");
357369
});
358370

359371
it("refunds selected quantities for non-test mode one-time purchases", async () => {
@@ -388,3 +400,350 @@ it("refunds selected quantities for non-test mode one-time purchases", async ()
388400
});
389401
expect(productsAfterRes.body.items).toHaveLength(0);
390402
});
403+
404+
it("returns SCHEMA_ERROR when amount_usd is negative", async () => {
405+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
406+
407+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
408+
accessType: "admin",
409+
method: "POST",
410+
body: {
411+
type: "one-time-purchase",
412+
id: purchaseTransaction.id,
413+
amount_usd: "-1",
414+
refund_entries: [{ entry_index: 0, quantity: 1 }],
415+
},
416+
});
417+
expect(refundRes).toMatchInlineSnapshot(`
418+
NiceResponse {
419+
"status": 400,
420+
"body": {
421+
"code": "SCHEMA_ERROR",
422+
"details": {
423+
"message": deindent\`
424+
Request validation failed on POST /api/latest/internal/payments/transactions/refund:
425+
- Money amount must be in the format of <number> or <number>.<number>
426+
\`,
427+
},
428+
"error": deindent\`
429+
Request validation failed on POST /api/latest/internal/payments/transactions/refund:
430+
- Money amount must be in the format of <number> or <number>.<number>
431+
\`,
432+
},
433+
"headers": Headers {
434+
"x-stack-known-error": "SCHEMA_ERROR",
435+
<some fields may have been hidden>,
436+
},
437+
}
438+
`);
439+
});
440+
441+
it("allows amount_usd of zero", async () => {
442+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
443+
444+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
445+
accessType: "admin",
446+
method: "POST",
447+
body: {
448+
type: "one-time-purchase",
449+
id: purchaseTransaction.id,
450+
amount_usd: "0",
451+
refund_entries: [{ entry_index: 0, quantity: 1 }],
452+
},
453+
});
454+
expect(refundRes).toMatchInlineSnapshot(`
455+
NiceResponse {
456+
"status": 200,
457+
"body": { "success": true },
458+
"headers": Headers { <some fields may have been hidden> },
459+
}
460+
`);
461+
});
462+
463+
it("allows empty refund_entries (money-only refund)", async () => {
464+
const { userId, purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
465+
466+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
467+
accessType: "admin",
468+
method: "POST",
469+
body: {
470+
type: "one-time-purchase",
471+
id: purchaseTransaction.id,
472+
amount_usd: "5000",
473+
refund_entries: [],
474+
},
475+
});
476+
expect(refundRes.status).toBe(200);
477+
expect(refundRes.body).toEqual({ success: true });
478+
479+
const transactionsAfterRefund = await niceBackendFetch("/api/latest/internal/payments/transactions", {
480+
accessType: "admin",
481+
});
482+
const refundedTransaction = transactionsAfterRefund.body.transactions.find((tx: any) => tx.id === purchaseTransaction.id);
483+
expect(refundedTransaction?.adjusted_by).toEqual([
484+
{
485+
entry_index: 0,
486+
transaction_id: expect.stringContaining(`${purchaseTransaction.id}:refund`),
487+
},
488+
]);
489+
490+
const productsAfterRes = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
491+
accessType: "client",
492+
});
493+
expect(productsAfterRes.body.items).toHaveLength(0);
494+
});
495+
496+
it("returns SCHEMA_ERROR when refund_entries contains bad entry_index", async () => {
497+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
498+
499+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
500+
accessType: "admin",
501+
method: "POST",
502+
body: {
503+
type: "one-time-purchase",
504+
id: purchaseTransaction.id,
505+
amount_usd: "5000",
506+
refund_entries: [{ entry_index: 999, quantity: 1 }],
507+
},
508+
});
509+
expect(refundRes).toMatchInlineSnapshot(`
510+
NiceResponse {
511+
"status": 400,
512+
"body": {
513+
"code": "SCHEMA_ERROR",
514+
"details": { "message": "Refund entry index is invalid." },
515+
"error": "Refund entry index is invalid.",
516+
},
517+
"headers": Headers {
518+
"x-stack-known-error": "SCHEMA_ERROR",
519+
<some fields may have been hidden>,
520+
},
521+
}
522+
`);
523+
});
524+
525+
it("returns SCHEMA_ERROR when refund_entries contains negative quantity", async () => {
526+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
527+
528+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
529+
accessType: "admin",
530+
method: "POST",
531+
body: {
532+
type: "one-time-purchase",
533+
id: purchaseTransaction.id,
534+
amount_usd: "5000",
535+
refund_entries: [{ entry_index: 0, quantity: -1 }],
536+
},
537+
});
538+
expect(refundRes).toMatchInlineSnapshot(`
539+
NiceResponse {
540+
"status": 400,
541+
"body": {
542+
"code": "SCHEMA_ERROR",
543+
"details": { "message": "Refund quantity cannot be negative." },
544+
"error": "Refund quantity cannot be negative.",
545+
},
546+
"headers": Headers {
547+
"x-stack-known-error": "SCHEMA_ERROR",
548+
<some fields may have been hidden>,
549+
},
550+
}
551+
`);
552+
});
553+
554+
it("allows refund_entries with zero quantity", async () => {
555+
const { userId, purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
556+
557+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
558+
accessType: "admin",
559+
method: "POST",
560+
body: {
561+
type: "one-time-purchase",
562+
id: purchaseTransaction.id,
563+
amount_usd: "5000",
564+
refund_entries: [{ entry_index: 0, quantity: 0 }],
565+
},
566+
});
567+
expect(refundRes.status).toBe(200);
568+
expect(refundRes.body).toEqual({ success: true });
569+
570+
const transactionsAfterRefund = await niceBackendFetch("/api/latest/internal/payments/transactions", {
571+
accessType: "admin",
572+
});
573+
const refundedTransaction = transactionsAfterRefund.body.transactions.find((tx: any) => tx.id === purchaseTransaction.id);
574+
expect(refundedTransaction?.adjusted_by).toEqual([
575+
{
576+
entry_index: 0,
577+
transaction_id: expect.stringContaining(`${purchaseTransaction.id}:refund`),
578+
},
579+
]);
580+
581+
const productsAfterRes = await niceBackendFetch(`/api/v1/payments/products/user/${userId}`, {
582+
accessType: "client",
583+
});
584+
expect(productsAfterRes.body.items).toHaveLength(0);
585+
});
586+
587+
it("returns SCHEMA_ERROR when refund_entries contains quantity past limit", async () => {
588+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction({ quantity: 1 });
589+
590+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
591+
accessType: "admin",
592+
method: "POST",
593+
body: {
594+
type: "one-time-purchase",
595+
id: purchaseTransaction.id,
596+
amount_usd: "5000",
597+
refund_entries: [{ entry_index: 0, quantity: 2 }],
598+
},
599+
});
600+
expect(refundRes).toMatchInlineSnapshot(`
601+
NiceResponse {
602+
"status": 400,
603+
"body": {
604+
"code": "SCHEMA_ERROR",
605+
"details": { "message": "Refund quantity cannot exceed purchased quantity." },
606+
"error": "Refund quantity cannot exceed purchased quantity.",
607+
},
608+
"headers": Headers {
609+
"x-stack-known-error": "SCHEMA_ERROR",
610+
<some fields may have been hidden>,
611+
},
612+
}
613+
`);
614+
});
615+
616+
it("returns SCHEMA_ERROR when amount_usd exceeds charged amount", async () => {
617+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
618+
619+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
620+
accessType: "admin",
621+
method: "POST",
622+
body: {
623+
type: "one-time-purchase",
624+
id: purchaseTransaction.id,
625+
amount_usd: "5001",
626+
refund_entries: [{ entry_index: 0, quantity: 1 }],
627+
},
628+
});
629+
expect(refundRes).toMatchInlineSnapshot(`
630+
NiceResponse {
631+
"status": 400,
632+
"body": {
633+
"code": "SCHEMA_ERROR",
634+
"details": { "message": "Refund amount cannot exceed the charged amount." },
635+
"error": "Refund amount cannot exceed the charged amount.",
636+
},
637+
"headers": Headers {
638+
"x-stack-known-error": "SCHEMA_ERROR",
639+
<some fields may have been hidden>,
640+
},
641+
}
642+
`);
643+
});
644+
645+
it("returns SCHEMA_ERROR when refund_entries contains negative entry_index", async () => {
646+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
647+
648+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
649+
accessType: "admin",
650+
method: "POST",
651+
body: {
652+
type: "one-time-purchase",
653+
id: purchaseTransaction.id,
654+
amount_usd: "5000",
655+
refund_entries: [{ entry_index: -1, quantity: 1 }],
656+
},
657+
});
658+
expect(refundRes).toMatchInlineSnapshot(`
659+
NiceResponse {
660+
"status": 400,
661+
"body": {
662+
"code": "SCHEMA_ERROR",
663+
"details": { "message": "Refund entry index is invalid." },
664+
"error": "Refund entry index is invalid.",
665+
},
666+
"headers": Headers {
667+
"x-stack-known-error": "SCHEMA_ERROR",
668+
<some fields may have been hidden>,
669+
},
670+
}
671+
`);
672+
});
673+
674+
it("returns SCHEMA_ERROR when refund_entries quantity is not an integer", async () => {
675+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
676+
677+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
678+
accessType: "admin",
679+
method: "POST",
680+
body: {
681+
type: "one-time-purchase",
682+
id: purchaseTransaction.id,
683+
amount_usd: "5000",
684+
refund_entries: [{ entry_index: 0, quantity: 1.5 }],
685+
},
686+
});
687+
expect(refundRes.body.code).toBe("SCHEMA_ERROR");
688+
});
689+
690+
it("returns SCHEMA_ERROR when refund_entries references non-product_grant entries", async () => {
691+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction();
692+
693+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
694+
accessType: "admin",
695+
method: "POST",
696+
body: {
697+
type: "one-time-purchase",
698+
id: purchaseTransaction.id,
699+
amount_usd: "5000",
700+
refund_entries: [{ entry_index: 1, quantity: 1 }],
701+
},
702+
});
703+
expect(refundRes).toMatchInlineSnapshot(`
704+
NiceResponse {
705+
"status": 400,
706+
"body": {
707+
"code": "SCHEMA_ERROR",
708+
"details": { "message": "Refund entries must reference product grant entries." },
709+
"error": "Refund entries must reference product grant entries.",
710+
},
711+
"headers": Headers {
712+
"x-stack-known-error": "SCHEMA_ERROR",
713+
<some fields may have been hidden>,
714+
},
715+
}
716+
`);
717+
});
718+
719+
it("returns SCHEMA_ERROR when refund_entries contains duplicate entry indexes", async () => {
720+
const { purchaseTransaction } = await createLiveModeOneTimePurchaseTransaction({ quantity: 2 });
721+
722+
const refundRes = await niceBackendFetch("/api/latest/internal/payments/transactions/refund", {
723+
accessType: "admin",
724+
method: "POST",
725+
body: {
726+
type: "one-time-purchase",
727+
id: purchaseTransaction.id,
728+
amount_usd: "5000",
729+
refund_entries: [
730+
{ entry_index: 0, quantity: 1 },
731+
{ entry_index: 0, quantity: 1 },
732+
],
733+
},
734+
});
735+
expect(refundRes).toMatchInlineSnapshot(`
736+
NiceResponse {
737+
"status": 400,
738+
"body": {
739+
"code": "SCHEMA_ERROR",
740+
"details": { "message": "Refund entries cannot contain duplicate entry indexes." },
741+
"error": "Refund entries cannot contain duplicate entry indexes.",
742+
},
743+
"headers": Headers {
744+
"x-stack-known-error": "SCHEMA_ERROR",
745+
<some fields may have been hidden>,
746+
},
747+
}
748+
`);
749+
});

apps/e2e/tests/general/typecheck.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ describe("`pnpm run typecheck`", () => {
1010
});
1111
});
1212
expect(error, `Expected no error to be thrown!\n\n\n\nstdout: ${stdout}\n\n\n\nstderr: ${stderr}`).toBeNull();
13-
}, 240_000);
13+
}, { retry: 1, timeout: 240_000 });
1414
});

0 commit comments

Comments
 (0)