11/**
22 * Module dependencies
33 */
4- import { randomBytes } from 'node:crypto' ;
54import config from '../../../config/index.js' ;
65import getStripe from '../lib/stripe.js' ;
76import BillingPlansService from './billing.plans.service.js' ;
87import SubscriptionRepository from '../repositories/billing.subscription.repository.js' ;
98import { isDuplicateKeyError } from '../lib/billing.errors.js' ;
9+ import { SENTINEL_PENDING } from '../lib/billing.constants.js' ;
1010
1111/**
1212 * Validate that a redirect URL is safe for the current environment.
@@ -147,6 +147,28 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
147147
148148 const subscription = await _ensureStripeCustomer ( stripe , organization ) ;
149149
150+ // Server-side active subscription guard — closes race window between local-DB check and
151+ // stripe.checkout.sessions.create (webhook may have arrived mid-flight).
152+ // Fetches status:'all' and filters locally to cover both 'active' AND 'trialing' in one call
153+ // (Stripe's status filter accepts only a single string, not an array).
154+ // +1 Stripe API call cost; acceptable given infrequent checkout entry point.
155+ if ( subscription . stripeCustomerId ) {
156+ const liveSubs = await stripe . subscriptions . list ( {
157+ customer : subscription . stripeCustomerId ,
158+ status : 'all' ,
159+ limit : 10 ,
160+ } ) ;
161+ const blockingSub = liveSubs . data . find ( ( s ) => [ 'active' , 'trialing' ] . includes ( s . status ) ) ;
162+ if ( blockingSub ) {
163+ const portalUrl = await createPortalSession ( organization ) ;
164+ const err = new Error ( 'Subscription already active' ) ;
165+ err . statusCode = 409 ;
166+ err . code = 'subscription_already_active' ;
167+ err . portalUrl = portalUrl ;
168+ throw err ;
169+ }
170+ }
171+
150172 const checkoutParams = {
151173 customer : subscription . stripeCustomerId ,
152174 mode : 'subscription' ,
@@ -184,10 +206,15 @@ const createCheckout = async (organization, priceId, successUrl, cancelUrl) => {
184206 * @param {String } packId - Pack identifier (must exist in config.billing.packs)
185207 * @param {String } successUrl - URL to redirect on payment success
186208 * @param {String } cancelUrl - URL to redirect on cancel
209+ * @param {String } [intentId] - Optional stable caller-provided intent ID for idempotency.
210+ * When provided: key = `extras_checkout_{orgId}_{packId}_{intentId}` (fully stable).
211+ * When absent: key bucketed to the minute — prevents instant double-click but not
212+ * cross-minute replay. Callers should pass a UUID generated on button click.
213+ * Max 180 chars (Stripe idempotency key limit is 255; prefix consumes ~64 chars).
187214 * @returns {Promise<{url: String}> } Object with the Checkout session URL
188215 */
189216// biome-ignore lint/correctness/useQwikValidLexicalScope: false positive — Node.js service, not Qwik
190- const createExtrasCheckout = async ( organization , packId , successUrl , cancelUrl ) => {
217+ const createExtrasCheckout = async ( organization , packId , successUrl , cancelUrl , intentId ) => {
191218 const stripe = getStripe ( ) ;
192219 if ( ! stripe ) throw new Error ( 'Stripe is not configured' ) ;
193220
@@ -206,9 +233,14 @@ const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl)
206233
207234 const subscription = await _ensureStripeCustomer ( stripe , organization ) ;
208235
209- // Per-intent idempotency key: timestamp + crypto-random suffix reduces collision risk under concurrent clicks.
210- // Full deduplication would require a caller-provided stable intent id — deferred to a future improvement.
211- const idempotencyKey = `extras_checkout_${ String ( organization . _id ) } _${ packId } _${ Date . now ( ) } _${ randomBytes ( 4 ) . toString ( 'hex' ) } ` ;
236+ // Idempotency key strategy:
237+ // - If intentId is provided by the caller (e.g. UUID generated on button click): fully stable key.
238+ // - Otherwise: minute-bucketed key — prevents double-click within the same minute window.
239+ // This is strictly better than Date.now()+random (which disabled idempotency entirely).
240+ const orgId = String ( organization . _id ) ;
241+ const idempotencyKey = intentId
242+ ? `extras_checkout_${ orgId } _${ packId } _${ intentId } `
243+ : `extras_checkout_${ orgId } _${ packId } _${ Math . floor ( Date . now ( ) / 60000 ) } ` ;
212244
213245 const extrasCheckoutParams = {
214246 customer : subscription . stripeCustomerId ,
@@ -217,17 +249,17 @@ const createExtrasCheckout = async (organization, packId, successUrl, cancelUrl)
217249 success_url : successUrl ,
218250 cancel_url : cancelUrl ,
219251 metadata : {
220- organizationId : String ( organization . _id ) ,
252+ organizationId : orgId ,
221253 packId,
222254 kind : 'extras' ,
223- stripeSessionId : '__pending__' ,
255+ stripeSessionId : SENTINEL_PENDING ,
224256 } ,
225257 payment_intent_data : {
226258 metadata : {
227- organizationId : String ( organization . _id ) ,
259+ organizationId : orgId ,
228260 packId,
229261 kind : 'extras' ,
230- stripeSessionId : '__pending__' ,
262+ stripeSessionId : SENTINEL_PENDING ,
231263 } ,
232264 } ,
233265 } ;
0 commit comments