diff --git a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java index d3d79760d..0d91fedf1 100644 --- a/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java +++ b/app/common/src/main/java/stirling/software/common/annotations/AutoJobPostMapping.java @@ -77,6 +77,10 @@ public @interface AutoJobPostMapping { /** * Relative resource weight (1-100). See {@link * stirling.software.common.enumeration.ResourceWeight} for the standard tiers. + * + *
The default is a sentinel ({@link Integer#MIN_VALUE}); {@code + * AutoJobPostMappingWeightTest} fails the build if any endpoint leaves it unset. Runtime + * readers clamp the value into {@code [1, 100]}. */ - int resourceWeight() default 1; + int resourceWeight() default Integer.MIN_VALUE; } diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java index 84f4c8f6e..ec4420fb1 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/EditTextController.java @@ -32,6 +32,7 @@ import stirling.software.SPDF.model.json.PdfJsonTextElement; import stirling.software.SPDF.service.PdfJsonConversionService; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.GeneralApi; +import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.general.EditTextOperation; import stirling.software.common.util.ExceptionUtils; import stirling.software.common.util.GeneralUtils; @@ -75,7 +76,10 @@ public class EditTextController { new StringToArrayListPropertyEditor<>(EditTextOperation.class)); } - @AutoJobPostMapping(consumes = "multipart/form-data", value = "/edit-text") + @AutoJobPostMapping( + consumes = "multipart/form-data", + value = "/edit-text", + resourceWeight = ResourceWeight.LARGE_WEIGHT) @StandardPdfResponse @Operation( summary = "Edit text in a PDF via find and replace", diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java index 19e564418..1255a2a22 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ConvertImgPDFController.java @@ -275,7 +275,10 @@ public class ConvertImgPDFController { GeneralUtils.generateFilename(file[0].getOriginalFilename(), "_converted.pdf")); } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbz/pdf") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/cbz/pdf", + resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @Operation( summary = "Convert CBZ comic book archive to PDF", description = @@ -301,7 +304,10 @@ public class ConvertImgPDFController { return WebResponseUtils.pdfFileToWebResponse(pdfFile, filename); } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbz") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/pdf/cbz", + resourceWeight = ResourceWeight.LARGE_WEIGHT) @Operation( summary = "Convert PDF to CBZ comic book archive", description = @@ -324,7 +330,10 @@ public class ConvertImgPDFController { return WebResponseUtils.zipFileToWebResponse(cbzFile, filename); } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/cbr/pdf") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/cbr/pdf", + resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @Operation( summary = "Convert CBR comic book archive to PDF", description = @@ -350,7 +359,10 @@ public class ConvertImgPDFController { return WebResponseUtils.bytesToWebResponse(pdfBytes, filename); } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/cbr") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/pdf/cbr", + resourceWeight = ResourceWeight.LARGE_WEIGHT) @Operation( summary = "Convert PDF to CBR comic book archive", description = diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java index 924013aba..0e272678c 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/AttachmentController.java @@ -141,7 +141,8 @@ public class AttachmentController { @AutoJobPostMapping( consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - value = "/extract-attachments") + value = "/extract-attachments", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @Operation( summary = "Extract attachments from PDF", description = @@ -176,7 +177,10 @@ public class AttachmentController { } } - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/list-attachments") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/list-attachments", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @Operation( summary = "List attachments in PDF", description = @@ -193,7 +197,8 @@ public class AttachmentController { @AutoJobPostMapping( consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - value = "/rename-attachment") + value = "/rename-attachment", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse @Operation( summary = "Rename attachment in PDF", @@ -228,7 +233,8 @@ public class AttachmentController { @AutoJobPostMapping( consumes = MediaType.MULTIPART_FORM_DATA_VALUE, - value = "/delete-attachment") + value = "/delete-attachment", + resourceWeight = ResourceWeight.SMALL_WEIGHT) @StandardPdfResponse @Operation( summary = "Delete attachment from PDF", diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java index d44233425..ce5a61078 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/converters/ConvertPDFToMarkdown.java @@ -13,6 +13,7 @@ import lombok.RequiredArgsConstructor; import stirling.software.SPDF.config.swagger.MarkdownConversionResponse; import stirling.software.common.annotations.AutoJobPostMapping; import stirling.software.common.annotations.api.ConvertApi; +import stirling.software.common.enumeration.ResourceWeight; import stirling.software.common.model.api.PDFFile; import stirling.software.common.util.PDFToFile; import stirling.software.common.util.TempFileManager; @@ -23,7 +24,10 @@ public class ConvertPDFToMarkdown { private final TempFileManager tempFileManager; - @AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/pdf/markdown") + @AutoJobPostMapping( + consumes = MediaType.MULTIPART_FORM_DATA_VALUE, + value = "/pdf/markdown", + resourceWeight = ResourceWeight.MEDIUM_WEIGHT) @MarkdownConversionResponse @Operation( summary = "Convert PDF to Markdown", diff --git a/app/core/src/test/java/stirling/software/SPDF/config/AutoJobPostMappingWeightTest.java b/app/core/src/test/java/stirling/software/SPDF/config/AutoJobPostMappingWeightTest.java new file mode 100644 index 000000000..2b675650e --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/config/AutoJobPostMappingWeightTest.java @@ -0,0 +1,132 @@ +package stirling.software.SPDF.config; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.core.io.support.ResourcePatternResolver; +import org.springframework.core.type.classreading.CachingMetadataReaderFactory; +import org.springframework.core.type.classreading.MetadataReader; +import org.springframework.core.type.classreading.MetadataReaderFactory; +import org.springframework.core.type.filter.TypeFilter; + +import stirling.software.common.annotations.AutoJobPostMapping; + +/** + * Build-time guardrail: every {@link AutoJobPostMapping} method must declare an explicit {@code + * resourceWeight}. + * + *
The credits interceptor multiplies {@code resourceWeight} into the per-call charge. An + * endpoint that falls through to the annotation default produces a charge derived from a value + * nobody chose — silently under- or over-billing depending on the endpoint's true cost. Forcing + * each method to pick a value from {@link stirling.software.common.enumeration.ResourceWeight} + * keeps the choice deliberate. + * + *
The annotation's default is {@link Integer#MIN_VALUE} (a sentinel). Runtime readers clamp the + * value into {@code [1, 100]}, so a missed declaration can't crash production — this test is the + * contract, the clamp is the safety net. + * + *
Lives in {@code :stirling-pdf} (core) because that's the module whose compile classpath
+ * transitively sees every other module's controllers ({@code :common}, {@code :proprietary}, and
+ * {@code :saas} when enabled).
+ */
+class AutoJobPostMappingWeightTest {
+
+ private static final String SCAN_BASE_PACKAGE = "stirling.software";
+
+ @Test
+ void everyAutoJobPostMappingDeclaresExplicitResourceWeight() throws Exception {
+ List 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 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.
- *
- * 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;
- }
- }
-}
diff --git a/app/saas/src/test/java/stirling/software/saas/service/StripeUsageIdempotencyKeyTest.java b/app/saas/src/test/java/stirling/software/saas/service/StripeUsageIdempotencyKeyTest.java
new file mode 100644
index 000000000..8c3414e94
--- /dev/null
+++ b/app/saas/src/test/java/stirling/software/saas/service/StripeUsageIdempotencyKeyTest.java
@@ -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");
+ }
+}