1+ import { buildOneTimePurchaseTransaction , buildSubscriptionTransaction , resolveSelectedPriceFromProduct } from "@/app/api/latest/internal/payments/transactions/transaction-builder" ;
12import { getStripeForAccount } from "@/lib/stripe" ;
23import { getPrismaClientForTenancy } from "@/prisma-client" ;
34import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler" ;
5+ import type { TransactionEntry } from "@stackframe/stack-shared/dist/interface/crud/transactions" ;
46import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors" ;
5- import { adaptSchema , adminAuthTypeSchema , yupBoolean , yupNumber , yupObject , yupString } from "@stackframe/stack-shared/dist/schema-fields" ;
6- import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors" ;
7- import { SubscriptionStatus } from "@/generated/prisma/client" ;
7+ import { adaptSchema , adminAuthTypeSchema , moneyAmountSchema , productSchema , yupArray , yupBoolean , yupNumber , yupObject , yupString } from "@stackframe/stack-shared/dist/schema-fields" ;
8+ import { moneyAmountToStripeUnits } from "@stackframe/stack-shared/dist/utils/currencies" ;
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" ;
11+ import { InferType } from "yup" ;
12+
13+ const USD_CURRENCY = SUPPORTED_CURRENCIES . find ( ( currency ) => currency . code === "USD" )
14+ ?? throwErr ( "USD currency configuration missing in SUPPORTED_CURRENCIES" ) ;
15+
16+ function getTotalUsdStripeUnits ( options : { product : InferType < typeof productSchema > , priceId : string | null , quantity : number } ) {
17+ const selectedPrice = resolveSelectedPriceFromProduct ( options . product , options . priceId ?? null ) ;
18+ const usdPrice = selectedPrice ?. USD ;
19+ if ( typeof usdPrice !== "string" ) {
20+ throw new KnownErrors . SchemaError ( "Refund amounts can only be specified for USD-priced purchases." ) ;
21+ }
22+ if ( ! Number . isFinite ( options . quantity ) || Math . trunc ( options . quantity ) !== options . quantity ) {
23+ throw new StackAssertionError ( "Purchase quantity is not an integer" , { quantity : options . quantity } ) ;
24+ }
25+ return moneyAmountToStripeUnits ( usdPrice as MoneyAmount , USD_CURRENCY ) * options . quantity ;
26+ }
27+
28+ type RefundEntrySelection = {
29+ entry_index : number ,
30+ quantity : number ,
31+ amount_usd : MoneyAmount ,
32+ } ;
33+
34+ function validateRefundEntries ( options : { entries : TransactionEntry [ ] , refundEntries : RefundEntrySelection [ ] } ) {
35+ const seenEntryIndexes = new Set < number > ( ) ;
36+ const entryByIndex = new Map < number , TransactionEntry > (
37+ options . entries . map ( ( entry , index ) => [ index , entry ] ) ,
38+ ) ;
39+
40+ for ( const refundEntry of options . refundEntries ) {
41+ if ( ! Number . isFinite ( refundEntry . quantity ) || Math . trunc ( refundEntry . quantity ) !== refundEntry . quantity ) {
42+ throw new KnownErrors . SchemaError ( "Refund quantity must be an integer." ) ;
43+ }
44+ if ( refundEntry . quantity < 0 ) {
45+ throw new KnownErrors . SchemaError ( "Refund quantity cannot be negative." ) ;
46+ }
47+ if ( seenEntryIndexes . has ( refundEntry . entry_index ) ) {
48+ throw new KnownErrors . SchemaError ( "Refund entries cannot contain duplicate entry indexes." ) ;
49+ }
50+ seenEntryIndexes . add ( refundEntry . entry_index ) ;
51+ const entry = entryByIndex . get ( refundEntry . entry_index ) ;
52+ if ( ! entry ) {
53+ throw new KnownErrors . SchemaError ( "Refund entry index is invalid." ) ;
54+ }
55+ if ( entry . type !== "product_grant" ) {
56+ throw new KnownErrors . SchemaError ( "Refund entries must reference product grant entries." ) ;
57+ }
58+ if ( refundEntry . quantity > entry . quantity ) {
59+ throw new KnownErrors . SchemaError ( "Refund quantity cannot exceed purchased quantity." ) ;
60+ }
61+ }
62+ }
63+
64+ function getRefundedQuantity ( refundEntries : RefundEntrySelection [ ] ) {
65+ let total = 0 ;
66+ for ( const refundEntry of refundEntries ) {
67+ total += refundEntry . quantity ;
68+ }
69+ return total ;
70+ }
71+
72+ function getRefundAmountStripeUnits ( refundEntries : RefundEntrySelection [ ] ) {
73+ let total = 0 ;
74+ for ( const refundEntry of refundEntries ) {
75+ total += moneyAmountToStripeUnits ( refundEntry . amount_usd , USD_CURRENCY ) ;
76+ }
77+ return total ;
78+ }
879
980export const POST = createSmartRouteHandler ( {
1081 metadata : {
@@ -19,6 +90,13 @@ export const POST = createSmartRouteHandler({
1990 body : yupObject ( {
2091 type : yupString ( ) . oneOf ( [ "subscription" , "one-time-purchase" ] ) . defined ( ) ,
2192 id : yupString ( ) . defined ( ) ,
93+ refund_entries : yupArray (
94+ yupObject ( {
95+ entry_index : yupNumber ( ) . integer ( ) . defined ( ) ,
96+ quantity : yupNumber ( ) . integer ( ) . defined ( ) ,
97+ amount_usd : moneyAmountSchema ( USD_CURRENCY ) . defined ( ) ,
98+ } ) . defined ( ) ,
99+ ) . defined ( ) ,
22100 } ) . defined ( )
23101 } ) ,
24102 response : yupObject ( {
@@ -30,10 +108,13 @@ export const POST = createSmartRouteHandler({
30108 } ) ,
31109 handler : async ( { auth, body } ) => {
32110 const prisma = await getPrismaClientForTenancy ( auth . tenancy ) ;
111+ const refundEntries = body . refund_entries . map ( ( entry ) => ( {
112+ ...entry ,
113+ amount_usd : entry . amount_usd as MoneyAmount ,
114+ } ) ) ;
33115 if ( body . type === "subscription" ) {
34116 const subscription = await prisma . subscription . findUnique ( {
35117 where : { tenancyId_id : { tenancyId : auth . tenancy . id , id : body . id } } ,
36- select : { refundedAt : true } ,
37118 } ) ;
38119 if ( ! subscription ) {
39120 throw new KnownErrors . SubscriptionInvoiceNotFound ( body . id ) ;
@@ -72,16 +153,73 @@ export const POST = createSmartRouteHandler({
72153 if ( ! paymentIntentId || typeof paymentIntentId !== "string" ) {
73154 throw new StackAssertionError ( "Payment has no payment intent" , { invoiceId : subscriptionInvoice . stripeInvoiceId } ) ;
74155 }
75- await stripe . refunds . create ( { payment_intent : paymentIntentId } ) ;
76- await prisma . subscription . update ( {
77- where : { tenancyId_id : { tenancyId : auth . tenancy . id , id : body . id } } ,
78- data : {
79- status : SubscriptionStatus . canceled ,
80- cancelAtPeriodEnd : true ,
81- currentPeriodEnd : new Date ( ) ,
82- refundedAt : new Date ( ) ,
83- } ,
156+ let refundAmountStripeUnits : number | null = null ;
157+ const transaction = buildSubscriptionTransaction ( { subscription } ) ;
158+ validateRefundEntries ( {
159+ entries : transaction . entries ,
160+ refundEntries,
84161 } ) ;
162+ const refundedQuantity = getRefundedQuantity ( refundEntries ) ;
163+ const totalStripeUnits = getTotalUsdStripeUnits ( {
164+ product : subscription . product as InferType < typeof productSchema > ,
165+ priceId : subscription . priceId ?? null ,
166+ quantity : subscription . quantity ,
167+ } ) ;
168+ refundAmountStripeUnits = getRefundAmountStripeUnits ( refundEntries ) ;
169+ if ( refundAmountStripeUnits < 0 ) {
170+ throw new KnownErrors . SchemaError ( "Refund amount cannot be negative." ) ;
171+ }
172+ if ( refundAmountStripeUnits > totalStripeUnits ) {
173+ throw new KnownErrors . SchemaError ( "Refund amount cannot exceed the charged amount." ) ;
174+ }
175+ await stripe . refunds . create ( {
176+ payment_intent : paymentIntentId ,
177+ amount : refundAmountStripeUnits ,
178+ } ) ;
179+ if ( refundedQuantity > 0 ) {
180+ if ( ! subscription . stripeSubscriptionId ) {
181+ throw new StackAssertionError ( "Stripe subscription id missing for refund" , { subscriptionId : subscription . id } ) ;
182+ }
183+ const stripeSubscription = await stripe . subscriptions . retrieve ( subscription . stripeSubscriptionId ) ;
184+ if ( stripeSubscription . items . data . length === 0 ) {
185+ throw new StackAssertionError ( "Stripe subscription has no items" , { subscriptionId : subscription . id } ) ;
186+ }
187+ const subscriptionItem = stripeSubscription . items . data [ 0 ] ;
188+ if ( ! Number . isFinite ( subscriptionItem . quantity ) || Math . trunc ( subscriptionItem . quantity ?? 0 ) !== subscriptionItem . quantity ) {
189+ throw new StackAssertionError ( "Stripe subscription item quantity is not an integer" , {
190+ subscriptionId : subscription . id ,
191+ itemQuantity : subscriptionItem . quantity ,
192+ } ) ;
193+ }
194+ const currentQuantity = subscriptionItem . quantity ?? 0 ;
195+ const newQuantity = currentQuantity - refundedQuantity ;
196+ if ( newQuantity < 0 ) {
197+ throw new StackAssertionError ( "Refund quantity exceeds Stripe subscription item quantity" , {
198+ subscriptionId : subscription . id ,
199+ currentQuantity,
200+ refundedQuantity,
201+ } ) ;
202+ }
203+ await stripe . subscriptions . update ( subscription . stripeSubscriptionId , {
204+ cancel_at_period_end : newQuantity === 0 ,
205+ items : [ {
206+ id : subscriptionItem . id ,
207+ quantity : newQuantity ,
208+ } ] ,
209+ } ) ;
210+ await prisma . subscription . update ( {
211+ where : { tenancyId_id : { tenancyId : auth . tenancy . id , id : body . id } } ,
212+ data : {
213+ cancelAtPeriodEnd : newQuantity === 0 ,
214+ refundedAt : new Date ( ) ,
215+ } ,
216+ } ) ;
217+ } else {
218+ await prisma . subscription . update ( {
219+ where : { tenancyId_id : { tenancyId : auth . tenancy . id , id : body . id } } ,
220+ data : { refundedAt : new Date ( ) } ,
221+ } ) ;
222+ }
85223 } else {
86224 const purchase = await prisma . oneTimePurchase . findUnique ( {
87225 where : { tenancyId_id : { tenancyId : auth . tenancy . id , id : body . id } } ,
@@ -99,8 +237,27 @@ export const POST = createSmartRouteHandler({
99237 if ( ! purchase . stripePaymentIntentId ) {
100238 throw new KnownErrors . OneTimePurchaseNotFound ( body . id ) ;
101239 }
240+ let refundAmountStripeUnits : number | null = null ;
241+ const transaction = buildOneTimePurchaseTransaction ( { purchase } ) ;
242+ validateRefundEntries ( {
243+ entries : transaction . entries ,
244+ refundEntries,
245+ } ) ;
246+ const totalStripeUnits = getTotalUsdStripeUnits ( {
247+ product : purchase . product as InferType < typeof productSchema > ,
248+ priceId : purchase . priceId ?? null ,
249+ quantity : purchase . quantity ,
250+ } ) ;
251+ refundAmountStripeUnits = getRefundAmountStripeUnits ( refundEntries ) ;
252+ if ( refundAmountStripeUnits < 0 ) {
253+ throw new KnownErrors . SchemaError ( "Refund amount cannot be negative." ) ;
254+ }
255+ if ( refundAmountStripeUnits > totalStripeUnits ) {
256+ throw new KnownErrors . SchemaError ( "Refund amount cannot exceed the charged amount." ) ;
257+ }
102258 await stripe . refunds . create ( {
103259 payment_intent : purchase . stripePaymentIntentId ,
260+ amount : refundAmountStripeUnits ,
104261 metadata : {
105262 tenancyId : auth . tenancy . id ,
106263 purchaseId : purchase . id ,
0 commit comments