* Generalized auto-top-up logic that works for both users and organizations.
( entity: AutoTopUpEntity, traceId: string )
| 181 | * Generalized auto-top-up logic that works for both users and organizations. |
| 182 | */ |
| 183 | async function performAutoTopUpForEntity( |
| 184 | entity: AutoTopUpEntity, |
| 185 | traceId: string |
| 186 | ): Promise<AutoTopUpResult> { |
| 187 | const ownerColumn = |
| 188 | entity.type === 'user' |
| 189 | ? auto_top_up_configs.owned_by_user_id |
| 190 | : auto_top_up_configs.owned_by_organization_id; |
| 191 | const ownerId = entity.type === 'user' ? entity.user.id : entity.organization.id; |
| 192 | |
| 193 | // Atomically check and acquire lock in a single query using SQL NOW() |
| 194 | const [config] = await db |
| 195 | .update(auto_top_up_configs) |
| 196 | .set({ attempt_started_at: sql`NOW()` }) |
| 197 | .where( |
| 198 | and( |
| 199 | eq(ownerColumn, ownerId), |
| 200 | or( |
| 201 | isNull(auto_top_up_configs.attempt_started_at), |
| 202 | lt( |
| 203 | auto_top_up_configs.attempt_started_at, |
| 204 | sql`NOW() - INTERVAL '${sql.raw(String(ATTEMPT_LOCK_TIMEOUT_SECONDS))} second'` |
| 205 | ) |
| 206 | ) |
| 207 | ) |
| 208 | ) |
| 209 | .returning({ |
| 210 | id: auto_top_up_configs.id, |
| 211 | stripe_payment_method_id: auto_top_up_configs.stripe_payment_method_id, |
| 212 | amount_cents: auto_top_up_configs.amount_cents, |
| 213 | attempt_started_at: auto_top_up_configs.attempt_started_at, |
| 214 | }); |
| 215 | |
| 216 | if (!config) { |
| 217 | // Either no config exists, or concurrent attempt in progress |
| 218 | // Check which case it is |
| 219 | const existingConfig = await db.query.auto_top_up_configs.findFirst({ |
| 220 | where: eq(ownerColumn, ownerId), |
| 221 | }); |
| 222 | |
| 223 | if (!existingConfig) { |
| 224 | await disableAutoTopUpForEntity(entity, 'no_payment_method_saved'); |
| 225 | return failureResult('no_payment_method_saved'); |
| 226 | } |
| 227 | return failureResult('concurrent_attempt_in_progress'); |
| 228 | } |
| 229 | |
| 230 | // Re-check balance after acquiring lock to prevent duplicate top-ups |
| 231 | // (another request may have completed a top-up while we were waiting for the lock) |
| 232 | // We fetch fresh data from DB and compute balance directly to avoid |
| 233 | // calling getBalanceForUser which would create a cycle (it calls maybePerformAutoTopUp) |
| 234 | const { currentBalance_USD, stripe_customer_id } = |
| 235 | await getEntityBalanceAndStripeCustomer(entity); |
| 236 | const threshold = |
| 237 | entity.type === 'user' ? AUTO_TOP_UP_THRESHOLD_DOLLARS : ORG_AUTO_TOP_UP_THRESHOLD_DOLLARS; |
| 238 | if (currentBalance_USD >= threshold) { |
| 239 | // Balance is now sufficient, release lock and exit |
| 240 | await db |
no test coverage detected