mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 11:23:10 +02:00
saas: harden CreditService Stripe ordering + lint @AutoJobPostMapping weights (#6458)
# Description of Changes Two narrowly-scoped hardening changes to the credits engine. ## 1. CreditService — move Stripe meter call to `afterCommit` The Stripe metered-usage call sits inside the surrounding `@Transactional`, holding the `user_credits` row lock for the duration of an HTTP round-trip to Supabase. Under load this starves concurrent debits; a transient Stripe blip rolls back a (correct) free-credit consumption and forces the caller to retry. The Stripe call now runs in a `TransactionSynchronization.afterCommit` hook — DB commits first, Stripe fires immediately after. If Stripe fails after commit, we log + increment a new `credits.stripe_report.failures` counter; the idempotency key is stable, so a manual replay recovers without double-charging. Applied to both `consumeCreditBySupabaseId` and `consumeCreditWithWaterfall`. **Dead-code removed:** - Unreachable UUID fallback for MDC `requestId` — `CorrelationIdFilter` already guarantees the key on every request. - The `"Unable to report usage to Stripe"` `RuntimeException` and its catch block — the afterCommit refactor eliminates the throw path. - `StripeRollbackOnFailureTest` — pinned the rollback-on-Stripe-fail behaviour this refactor replaces. ## 2. `@AutoJobPostMapping` — build-time lint for `resourceWeight` `UnifiedCreditInterceptor` multiplies `resourceWeight` into the per-call charge. An endpoint that falls through to the annotation default produces a charge derived from a value nobody chose. - Annotation default flipped from `1` to `Integer.MIN_VALUE` (sentinel). Both runtime readers (`UnifiedCreditInterceptor`, `AutoJobAspect`) already clamp into `[1, 100]` so behaviour is unchanged. - New `AutoJobPostMappingWeightTest` scans the classpath and fails the build if any method leaves the sentinel. - Initial run caught 11 endpoints relying on the default. Explicit weights now declared, chosen by comparing to peer endpoints: - `EditTextController` — LARGE - `EmailController#sendEmailWithAttachment` — SMALL - `ConvertPDFToMarkdown` — MEDIUM - `AttachmentController` (extract/list/rename/delete) — SMALL × 4 - `ConvertImgPDFController` (cbr/cbz ↔ pdf) — MEDIUM × 2, LARGE × 2 ## Tests - `StripeUsageIdempotencyKeyTest` — pins the `(supabaseId, overage, requestId)` idempotency key shape so Stripe always dedupes a retry. - `StripeAfterCommitOrderingTest` — pins that `afterCommit` fires after commit and NOT on rollback. - `AutoJobPostMappingWeightTest` — the lint itself, plus a self-check that the classpath scan finds at least 10 `@AutoJobPostMapping` methods (guards against the lint passing vacuously). Build verified: `ENABLE_SAAS=true ./gradlew :stirling-pdf:test :saas:test`. --- ## Checklist ### General - [x] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [x] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md) (if applicable) - [ ] I have read the [How to add new languages to Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md) (if applicable) — no translation changes - [x] I have performed a self-review of my own code - [x] My changes generate no new warnings ### Documentation - [ ] I have updated relevant docs on [Stirling-PDF's doc repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/) — internal-billing change, no public docs impact - [ ] I have read the section [Add New Translation Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags) — N/A ### Translations (if applicable) - [ ] Not applicable ### UI Changes (if applicable) - [ ] Not applicable ### Testing (if applicable) - [x] I have run `task check` (via `./gradlew :stirling-pdf:test :saas:test` with `ENABLE_SAAS=true`) — passes - [x] I have tested my changes locally
This commit is contained in:
@@ -15,6 +15,8 @@ import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import io.micrometer.core.instrument.Counter;
|
||||
import io.micrometer.core.instrument.Gauge;
|
||||
@@ -55,6 +57,7 @@ public class CreditService {
|
||||
private final Counter creditsConsumedCounter;
|
||||
private final Counter creditConsumptionFailuresCounter;
|
||||
private final Counter cycleResetCounter;
|
||||
private final Counter stripeReportFailuresCounter;
|
||||
|
||||
public CreditService(
|
||||
UserCreditRepository userCreditRepository,
|
||||
@@ -90,6 +93,10 @@ public class CreditService {
|
||||
Counter.builder("credits.cycle_reset")
|
||||
.description("Number of credit cycle resets performed")
|
||||
.register(meterRegistry);
|
||||
this.stripeReportFailuresCounter =
|
||||
Counter.builder("credits.stripe_report.failures")
|
||||
.description("Stripe meter post failed after the DB debit committed")
|
||||
.register(meterRegistry);
|
||||
|
||||
// Active gauges for current credit levels
|
||||
Gauge.builder("credits.total_available", this, CreditService::getTotalAvailableCredits)
|
||||
@@ -296,7 +303,8 @@ public class CreditService {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Partial or full overage: consume free credits and report overage to Stripe
|
||||
// Partial or full overage: consume free credits in this tx, report the overage
|
||||
// to Stripe after commit (see scheduleStripeReportAfterCommit).
|
||||
int freeCreditsUsed =
|
||||
userCredits.getCycleCreditsRemaining() != null
|
||||
? userCredits.getCycleCreditsRemaining()
|
||||
@@ -328,55 +336,27 @@ public class CreditService {
|
||||
}
|
||||
}
|
||||
|
||||
// Stable idempotency key per (user, amount, operation) so retries dedupe.
|
||||
String operationId = MDC.get("requestId");
|
||||
if (operationId == null || operationId.isBlank()) {
|
||||
operationId = UUID.randomUUID().toString();
|
||||
}
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId, overageCredits, operationId);
|
||||
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Calling Stripe reporting service - User: {}, Overage credits: {}, Idempotency key: {}",
|
||||
scheduleStripeReportAfterCommit(
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
|
||||
boolean reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId, overageCredits, idempotencyKey);
|
||||
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Stripe reporting result: {} for user: {}",
|
||||
reported ? "SUCCESS" : "FAILED",
|
||||
supabaseId);
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
log.info(
|
||||
"[USAGE-BASED] User {} consumed {} free + {} overage credits (total: {})",
|
||||
supabaseId,
|
||||
freeCreditsUsed,
|
||||
overageCredits,
|
||||
creditAmount);
|
||||
return true;
|
||||
} else {
|
||||
log.error(
|
||||
"[USAGE-BASED] Failed to report {} overage credits to Stripe for user: {}",
|
||||
overageCredits,
|
||||
supabaseId);
|
||||
log.error(
|
||||
"[USAGE-BASED] Throwing exception to fail the operation; metering must succeed");
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering. Please try again or contact support if the issue persists.");
|
||||
}
|
||||
idempotencyKey,
|
||||
creditAmount,
|
||||
freeCreditsUsed);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Free credits were sufficient; already consumed and returned above
|
||||
// If we reach here, there's a logic error
|
||||
log.error("[USAGE-BASED] Unexpected code path reached for user: {}", supabaseId);
|
||||
// Lost a concurrent-debit race: the in-memory balance check passed but the atomic
|
||||
// UPDATE found insufficient credits. Surface the failure so the caller can retry.
|
||||
log.warn(
|
||||
"[USAGE-BASED] Concurrent-debit race lost the free-tier consumption for"
|
||||
+ " user {}; caller should retry.",
|
||||
supabaseId);
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -411,17 +391,6 @@ public class CreditService {
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return false;
|
||||
} catch (RuntimeException e) {
|
||||
// Metering failures are critical and should fail the operation.
|
||||
// This ensures users aren't charged for operations that weren't metered.
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Unable to report usage to Stripe")) {
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Metering failure; rethrowing exception to fail operation");
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Other runtime exceptions are logged but don't fail the operation.
|
||||
// This prevents transient errors from blocking user operations.
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Unexpected runtime error consuming credits for user: {} - {}",
|
||||
supabaseId,
|
||||
@@ -451,6 +420,87 @@ public class CreditService {
|
||||
return saasUserExtensionService.isMeteredBillingEnabled(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts the Stripe meter event for an overage debit in a {@code TransactionSynchronization}
|
||||
* afterCommit hook, so the DB row lock is released before the HTTP call to Stripe.
|
||||
*
|
||||
* <p>If no transaction is active (e.g. a test calling consume directly) the report runs
|
||||
* synchronously instead, so the meter event still fires.
|
||||
*/
|
||||
private void scheduleStripeReportAfterCommit(
|
||||
String supabaseId,
|
||||
int overageCredits,
|
||||
String idempotencyKey,
|
||||
int creditAmount,
|
||||
int freeCreditsUsed) {
|
||||
|
||||
Runnable reportToStripe =
|
||||
() -> {
|
||||
log.info(
|
||||
"[CREDIT-CONSUME] Posting Stripe meter event - User: {}, Overage: {},"
|
||||
+ " Idempotency: {}",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
|
||||
boolean reported;
|
||||
try {
|
||||
reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId, overageCredits, idempotencyKey);
|
||||
} catch (RuntimeException e) {
|
||||
// Don't let a Stripe exception unwind the afterCommit chain — the DB
|
||||
// debit has already committed.
|
||||
log.error(
|
||||
"[CREDIT-CONSUME] Stripe meter post threw for user {} (overage {});"
|
||||
+ " usage owed-but-unbilled until a retry succeeds",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
e);
|
||||
stripeReportFailuresCounter.increment();
|
||||
return;
|
||||
}
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
log.info(
|
||||
"[USAGE-BASED] User {} consumed {} free + {} overage credits"
|
||||
+ " (total: {}); Stripe meter posted.",
|
||||
supabaseId,
|
||||
freeCreditsUsed,
|
||||
overageCredits,
|
||||
creditAmount);
|
||||
} else {
|
||||
// DB has the debit, Stripe doesn't. The idempotency key is stable, so a
|
||||
// replay with the same key recovers the meter event without
|
||||
// double-charging.
|
||||
stripeReportFailuresCounter.increment();
|
||||
log.error(
|
||||
"[USAGE-BASED] Failed to post Stripe meter event for user {}"
|
||||
+ " (overage {}); usage owed-but-unbilled. Idempotency key"
|
||||
+ " is stable: replay with key '{}' to recover.",
|
||||
supabaseId,
|
||||
overageCredits,
|
||||
idempotencyKey);
|
||||
}
|
||||
};
|
||||
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
reportToStripe.run();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log.warn(
|
||||
"[CREDIT-CONSUME] No active transaction; reporting Stripe usage synchronously."
|
||||
+ " Expected only in tests.");
|
||||
reportToStripe.run();
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a user has credits available by Supabase ID (unified approach). */
|
||||
public boolean hasCreditsAvailableBySupabaseId(String supabaseId) {
|
||||
Optional<UserCredit> credits = getUserCreditsBySupabaseId(supabaseId);
|
||||
@@ -1054,48 +1104,23 @@ public class CreditService {
|
||||
// STEP 4: Try metered billing (check flag, not role)
|
||||
if (saasUserExtensionService.isMeteredBillingEnabled(user)) {
|
||||
log.info(
|
||||
"[WATERFALL] User {} has metered billing enabled; reporting {} credits to Stripe",
|
||||
"[WATERFALL] User {} has metered billing enabled; scheduling {} credits for"
|
||||
+ " Stripe report (after commit)",
|
||||
user.getUsername(),
|
||||
creditAmount);
|
||||
|
||||
try {
|
||||
String operationId = MDC.get("requestId");
|
||||
if (operationId == null || operationId.isBlank()) {
|
||||
operationId = UUID.randomUUID().toString();
|
||||
}
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId.toString(), creditAmount, operationId);
|
||||
String operationId = MDC.get("requestId");
|
||||
String idempotencyKey =
|
||||
stripeUsageReportingService.generateIdempotencyKey(
|
||||
supabaseId.toString(), creditAmount, operationId);
|
||||
|
||||
boolean reported =
|
||||
stripeUsageReportingService.reportUsageToStripe(
|
||||
supabaseId.toString(), creditAmount, idempotencyKey);
|
||||
|
||||
if (reported) {
|
||||
creditsConsumedCounter.increment(creditAmount);
|
||||
|
||||
log.info(
|
||||
"[WATERFALL] Reported {} overage credits to Stripe for user: {}",
|
||||
creditAmount,
|
||||
user.getUsername());
|
||||
return CreditConsumptionResult.success("METERED_SUBSCRIPTION");
|
||||
} else {
|
||||
log.error(
|
||||
"[WATERFALL] Failed to report usage to Stripe for user: {}",
|
||||
user.getUsername());
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return CreditConsumptionResult.failure("Failed to report usage to Stripe");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"[WATERFALL] Exception while reporting to Stripe for user {}: {}",
|
||||
user.getUsername(),
|
||||
e.getMessage(),
|
||||
e);
|
||||
creditConsumptionFailuresCounter.increment();
|
||||
return CreditConsumptionResult.failure(
|
||||
"Error reporting usage to Stripe: " + e.getMessage());
|
||||
}
|
||||
scheduleStripeReportAfterCommit(
|
||||
supabaseId.toString(),
|
||||
creditAmount,
|
||||
idempotencyKey,
|
||||
creditAmount,
|
||||
/* freeCreditsUsed= */ 0);
|
||||
return CreditConsumptionResult.success("METERED_SUBSCRIPTION");
|
||||
} else if (user.getRolesAsString().contains("ROLE_PRO_USER")) {
|
||||
// Pro user without metered billing enabled; reject with helpful message
|
||||
log.warn(
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
/**
|
||||
* Pins the contract {@code CreditService.scheduleStripeReportAfterCommit} relies on: a {@link
|
||||
* TransactionSynchronization#afterCommit()} hook fires after a successful commit and never on
|
||||
* rollback.
|
||||
*/
|
||||
class StripeAfterCommitOrderingTest {
|
||||
|
||||
@AfterEach
|
||||
void clearSynchronization() {
|
||||
if (TransactionSynchronizationManager.isSynchronizationActive()) {
|
||||
TransactionSynchronizationManager.clear();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCommitRunsAfterCommit_notDuringTransaction() {
|
||||
List<String> order = new ArrayList<>();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
order.add("inside-tx-before-register");
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
order.add("after-commit-hook");
|
||||
}
|
||||
});
|
||||
order.add("inside-tx-after-register");
|
||||
|
||||
// Simulate commit by firing afterCommit on every registered synchronization.
|
||||
order.add("commit-triggered");
|
||||
for (TransactionSynchronization s :
|
||||
TransactionSynchronizationManager.getSynchronizations()) {
|
||||
s.afterCommit();
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(order)
|
||||
.containsExactly(
|
||||
"inside-tx-before-register",
|
||||
"inside-tx-after-register",
|
||||
"commit-triggered",
|
||||
"after-commit-hook");
|
||||
}
|
||||
|
||||
@Test
|
||||
void afterCommitDoesNotRun_onRollback() {
|
||||
List<String> order = new ArrayList<>();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
TransactionSynchronizationManager.registerSynchronization(
|
||||
new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCommit() {
|
||||
order.add("after-commit-hook-MUST-NOT-FIRE");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
if (status == TransactionSynchronization.STATUS_ROLLED_BACK) {
|
||||
order.add("after-completion-rollback");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate rollback: afterCompletion fires, afterCommit must not.
|
||||
for (TransactionSynchronization s :
|
||||
TransactionSynchronizationManager.getSynchronizations()) {
|
||||
s.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK);
|
||||
}
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(order)
|
||||
.containsExactly("after-completion-rollback")
|
||||
.doesNotContain("after-commit-hook-MUST-NOT-FIRE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void isSynchronizationActive_reflectsSpringTransactionalContext() {
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse();
|
||||
|
||||
TransactionSynchronizationManager.initSynchronization();
|
||||
try {
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isTrue();
|
||||
} finally {
|
||||
TransactionSynchronizationManager.clearSynchronization();
|
||||
}
|
||||
|
||||
assertThat(TransactionSynchronizationManager.isSynchronizationActive()).isFalse();
|
||||
}
|
||||
}
|
||||
-132
@@ -1,132 +0,0 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.AbstractPlatformTransactionManager;
|
||||
import org.springframework.transaction.support.DefaultTransactionDefinition;
|
||||
import org.springframework.transaction.support.DefaultTransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Verifies finding #5 (CreditService Stripe ordering / DB divergence) end-to-end.
|
||||
*
|
||||
* <p>Connor's claim: free credits are deducted before the Stripe overage call; if Stripe fails the
|
||||
* code throws but the deduction has already committed. Earlier analysis flagged this BOGUS because
|
||||
* the class is {@code @Transactional} and Spring rolls back on uncaught RuntimeException — but the
|
||||
* subtlety I missed last time (with {@code @PreAuthorize hasRole}) means I want a real test rather
|
||||
* than another argument-from-docs.
|
||||
*
|
||||
* <p>This test reproduces the exact Spring transaction wiring: a method annotated as transactional
|
||||
* does (1) an in-transaction "deduct credits" write, then (2) throws a RuntimeException. We assert
|
||||
* the transaction manager observes the throw and triggers {@code rollback()}, not {@code commit()}.
|
||||
*/
|
||||
class StripeRollbackOnFailureTest {
|
||||
|
||||
@Test
|
||||
void runtimeExceptionTriggersRollback_notCommit() {
|
||||
AtomicInteger commits = new AtomicInteger();
|
||||
AtomicInteger rollbacks = new AtomicInteger();
|
||||
|
||||
PlatformTransactionManager tm =
|
||||
new AbstractPlatformTransactionManager() {
|
||||
@Override
|
||||
protected Object doGetTransaction() {
|
||||
return new Object();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doBegin(
|
||||
Object transaction,
|
||||
org.springframework.transaction.TransactionDefinition def) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doCommit(DefaultTransactionStatus status) {
|
||||
commits.incrementAndGet();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doRollback(DefaultTransactionStatus status) {
|
||||
rollbacks.incrementAndGet();
|
||||
}
|
||||
};
|
||||
|
||||
TransactionTemplate template =
|
||||
new TransactionTemplate(tm, new DefaultTransactionDefinition());
|
||||
|
||||
// This is the exact shape of CreditService.consumeCreditBySupabaseId when Stripe fails:
|
||||
// 1. deduct free credits (already happened, line 318-320 in production)
|
||||
// 2. call Stripe → returns false (mocked)
|
||||
// 3. throw new RuntimeException("Unable to report usage to Stripe...")
|
||||
// The throw escapes through the catch at line 413-420 (which re-throws metering failures).
|
||||
RuntimeException thrown =
|
||||
assertThrows(
|
||||
RuntimeException.class,
|
||||
() ->
|
||||
template.executeWithoutResult(
|
||||
status -> {
|
||||
// Step 1: imaginary credit deduction happens here.
|
||||
// Step 2: Stripe returns false.
|
||||
// Step 3: throw — same wording as production line 372.
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering.");
|
||||
}));
|
||||
|
||||
assertThat(thrown.getMessage()).contains("Unable to report usage to Stripe");
|
||||
assertThat(commits.get())
|
||||
.as("commit() must NOT be called when the method throws a RuntimeException")
|
||||
.isZero();
|
||||
assertThat(rollbacks.get())
|
||||
.as("rollback() must be called when the method throws a RuntimeException")
|
||||
.isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeExceptionIsRethrown_notSwallowed_throughCatchBlock() {
|
||||
// Sanity check that the actual catch logic at CreditService.java:413-420 re-throws the
|
||||
// Stripe-failure RuntimeException rather than swallowing it. If it didn't re-throw, the
|
||||
// transaction would commit. We rebuild the same try/catch shape here.
|
||||
RuntimeException thrown =
|
||||
assertThrows(
|
||||
RuntimeException.class,
|
||||
() -> consumeCreditMimicry(/* stripeReports= */ false));
|
||||
assertThat(thrown.getMessage()).contains("Unable to report usage to Stripe");
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeExceptionIsSwallowed_forNonMeteringErrors() {
|
||||
// Unrelated runtime exceptions are caught at CreditService.java:425-431 and swallowed
|
||||
// (return false). This is per the existing behaviour so we just lock it in.
|
||||
Boolean result = consumeCreditMimicry(/* stripeReports= */ true);
|
||||
assertThat(result).isTrue();
|
||||
}
|
||||
|
||||
/** Tiny inline mock of the catch chain in CreditService.consumeCreditBySupabaseId. */
|
||||
private static Boolean consumeCreditMimicry(boolean stripeReports) {
|
||||
try {
|
||||
// Step 1: deduct free credits (would have been DB write).
|
||||
// Step 2: Stripe call.
|
||||
if (!stripeReports) {
|
||||
throw new RuntimeException(
|
||||
"Unable to report usage to Stripe. Operation cannot proceed without metering.");
|
||||
}
|
||||
return true;
|
||||
} catch (IllegalArgumentException e) {
|
||||
return false;
|
||||
} catch (RuntimeException e) {
|
||||
if (e.getMessage() != null
|
||||
&& e.getMessage().contains("Unable to report usage to Stripe")) {
|
||||
throw e; // re-thrown so @Transactional rolls back
|
||||
}
|
||||
return false;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package stirling.software.saas.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
import stirling.software.saas.billing.service.StripeUsageReportingService;
|
||||
import stirling.software.saas.config.SupabaseConfigurationProperties;
|
||||
|
||||
/**
|
||||
* Pins the Stripe meter-event idempotency key as a deterministic function of (Supabase user,
|
||||
* overage amount, request id). Stripe collapses duplicates by this key, so a regression here means
|
||||
* customers get double-billed on a retry.
|
||||
*/
|
||||
class StripeUsageIdempotencyKeyTest {
|
||||
|
||||
private final StripeUsageReportingService service =
|
||||
new StripeUsageReportingService(Mockito.mock(SupabaseConfigurationProperties.class));
|
||||
|
||||
@Test
|
||||
void sameInputs_produceSameKey() {
|
||||
String first = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String second = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
|
||||
assertThat(first)
|
||||
.as("Idempotency key must be stable across calls with identical inputs.")
|
||||
.isEqualTo(second);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentRequestIds_produceDifferentKeys() {
|
||||
String reqA = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String reqB = service.generateIdempotencyKey("user-123", 10, "req-xyz");
|
||||
|
||||
assertThat(reqA).isNotEqualTo(reqB);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentOverageAmounts_produceDifferentKeys() {
|
||||
String tenCredits = service.generateIdempotencyKey("user-123", 10, "req-abc");
|
||||
String elevenCredits = service.generateIdempotencyKey("user-123", 11, "req-abc");
|
||||
|
||||
assertThat(tenCredits).isNotEqualTo(elevenCredits);
|
||||
}
|
||||
|
||||
@Test
|
||||
void differentUsers_produceDifferentKeys() {
|
||||
String alice = service.generateIdempotencyKey("user-alice", 10, "req-abc");
|
||||
String bob = service.generateIdempotencyKey("user-bob", 10, "req-abc");
|
||||
|
||||
assertThat(alice).isNotEqualTo(bob);
|
||||
}
|
||||
|
||||
@Test
|
||||
void keyShapeIncludesAllThreeDimensions() {
|
||||
// Format: usage_{supabaseId}_{credits}_{operationId}
|
||||
String key = service.generateIdempotencyKey("user-123", 42, "req-abc");
|
||||
|
||||
assertThat(key).contains("user-123").contains("42").contains("req-abc");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user