saas: wrap long strings + Jackson import reorder

Reflow and wrap long string literals across multiple SaaS modules (logging messages, SQL/JPQL queries, email/content headers, and agreement text) and relocate tools.jackson imports for consistent grouping. These are formatting-only changes to improve line lengths and readability; no functional logic was altered.
This commit is contained in:
Ludy87
2026-09-01 11:23:50 +02:00
parent dcdd517ffd
commit fb9cb74f9c
28 changed files with 126 additions and 97 deletions
@@ -26,9 +26,6 @@ import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -53,6 +50,9 @@ import stirling.software.saas.payg.model.JobSource;
import stirling.software.saas.payg.model.ProcessType;
import stirling.software.saas.util.AuthenticationUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai/create")
@@ -14,9 +14,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -29,6 +26,9 @@ import stirling.software.saas.ai.service.AiCreateSessionService;
import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
@RestController
@Profile("saas")
@RequestMapping("/api/v1/ai/create/internal")
@@ -12,12 +12,12 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import tools.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.config.SupabaseConfigurationProperties;
import tools.jackson.databind.ObjectMapper;
/**
* Reports per-tenant overage to Stripe Billing Meters via the Supabase {@code meter-usage} Edge
* Function. Only credits consumed above the free tier flow through {@link #reportUsageToStripe}.
@@ -58,13 +58,15 @@ public class StripeUsageReportingService {
if (supabaseUrl == null || supabaseUrl.isEmpty()) {
log.error(
"[USAGE-BILLING] supabase.url not configured; cannot report usage. Set SUPABASE_URL.");
"[USAGE-BILLING] supabase.url not configured; cannot report usage. Set"
+ " SUPABASE_URL.");
return false;
}
if (!supabaseConfig.isEdgeFunctionConfigured()) {
log.error(
"[USAGE-BILLING] Supabase edge function not configured (URL + secret required); cannot report usage.");
"[USAGE-BILLING] Supabase edge function not configured (URL + secret required);"
+ " cannot report usage.");
return false;
}
@@ -50,7 +50,8 @@ public class SaasDataSourceConfig {
// search_path so native SQL hits stirling_pdf, not the postgres-default public.
@Value(
"${spring.datasource.hikari.connection-init-sql:SET search_path TO stirling_pdf, auth, public}")
"${spring.datasource.hikari.connection-init-sql:SET search_path TO stirling_pdf, auth,"
+ " public}")
private String connectionInitSql;
@Bean
@@ -59,9 +60,9 @@ public class SaasDataSourceConfig {
public DataSource saasDataSource() {
if (url == null || url.isBlank()) {
throw new IllegalStateException(
"spring.datasource.url is required when the saas profile is active. "
+ "Set it via application-{profile}.properties (e.g. application-dev.properties) "
+ "or via the SPRING_DATASOURCE_URL env var.");
"spring.datasource.url is required when the saas profile is active. Set it via"
+ " application-{profile}.properties (e.g. application-dev.properties) or"
+ " via the SPRING_DATASOURCE_URL env var.");
}
HikariConfig config = new HikariConfig();
@@ -79,7 +80,8 @@ public class SaasDataSourceConfig {
}
log.info(
"Saas DataSource configured (ApplicationName: '{}', max pool: {}, min idle: {}, search_path init: '{}')",
"Saas DataSource configured (ApplicationName: '{}', max pool: {}, min idle: {},"
+ " search_path init: '{}')",
applicationName,
maximumPoolSize,
minimumIdle,
@@ -45,8 +45,9 @@ public class SaasProjectNotice {
return;
}
log.info(
"SaaS dev profile: Supabase preview branch {}, ddl-auto={}. Disposable, so Hibernate"
+ " is allowed to add the inherited tables the migrations do not create.",
"SaaS dev profile: Supabase preview branch {}, ddl-auto={}. Disposable, so"
+ " Hibernate is allowed to add the inherited tables the migrations do not"
+ " create.",
projectRef,
ddlAuto);
}
@@ -13,13 +13,13 @@ import java.util.regex.Pattern;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Loads the versioned legal-document registry from {@code legal/manifest.json} on startup and
* serves document metadata + rendered markdown from the classpath.
@@ -63,8 +63,7 @@ public class LegalDocumentRegistry {
d.path("parts"),
objectMapper
.getTypeFactory()
.constructCollectionType(
List.class, String.class));
.constructCollectionType(List.class, String.class));
documents.put(
id,
new LegalDocumentMeta(
@@ -20,8 +20,6 @@ import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import tools.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.Counter;
import io.micrometer.core.instrument.MeterRegistry;
@@ -40,6 +38,8 @@ import stirling.software.saas.payg.cap.RequiresFeature;
import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.util.AuthenticationUtils;
import tools.jackson.databind.ObjectMapper;
/**
* Hot-path entitlement check. Runs after {@code PaygChargeInterceptor} in the MVC chain and short-
* circuits the request before any handler work happens when the team's snapshot is missing one of
@@ -155,7 +155,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
this.callsBypassed =
Counter.builder("payg.filter.bypassed")
.description(
"Manual UI tool calls that skipped openProcess (BillingCategory.BYPASSED)")
"Manual UI tool calls that skipped openProcess"
+ " (BillingCategory.BYPASSED)")
.register(meterRegistry);
this.refundsCounter =
Counter.builder("payg.filter.refunds")
@@ -429,7 +430,8 @@ public class PaygChargeInterceptor implements AsyncHandlerInterceptor {
Long maxBytes = properties.getResponse().getMaxBytes();
if (maxBytes != null && wrapper.bytesWritten() > maxBytes) {
log.debug(
"Response size {} exceeds payg.filter.response.max-bytes={}; skipping OUTPUT recording",
"Response size {} exceeds payg.filter.response.max-bytes={}; skipping OUTPUT"
+ " recording",
wrapper.bytesWritten(),
maxBytes);
return;
@@ -181,7 +181,8 @@ public class PaygOutputExtractor {
// ZipException extends IOException; IllegalArgumentException covers ZipEntry
// "MALFORMED" surfaces from zlib. Fail-open: caller still serves the response.
log.debug(
"ZIP unpack failed for response body {} ({}); skipping per-PDF OUTPUT recording",
"ZIP unpack failed for response body {} ({}); skipping per-PDF OUTPUT"
+ " recording",
bodyPath,
e.getClass().getSimpleName());
// Close anything we already opened.
@@ -91,7 +91,8 @@ public class PaygResponseBodyWrapper extends HttpServletResponseWrapper implemen
if (writer != null) {
// Servlet spec: getOutputStream() and getWriter() are mutually exclusive per request.
throw new IllegalStateException(
"getWriter() was already called on this response; cannot switch to getOutputStream()");
"getWriter() was already called on this response; cannot switch to"
+ " getOutputStream()");
}
if (teeOut == null) {
teeOut = new TeeingServletOutputStream(super.getOutputStream());
@@ -103,7 +104,8 @@ public class PaygResponseBodyWrapper extends HttpServletResponseWrapper implemen
public PrintWriter getWriter() throws IOException {
if (teeOut != null) {
throw new IllegalStateException(
"getOutputStream() was already called on this response; cannot switch to getWriter()");
"getOutputStream() was already called on this response; cannot switch to"
+ " getWriter()");
}
if (writer == null) {
String encoding = getCharacterEncoding() != null ? getCharacterEncoding() : "UTF-8";
@@ -101,7 +101,8 @@ public class InstanceUsageIngestService {
// The cumulative counter went backwards — a reset or tampering. Refuse to credit; don't
// advance, so the discrepancy stays visible and a corrected resend can reconcile.
log.warn(
"Instance usage regression team={} category={} reported {} < last {}; ignoring.",
"Instance usage regression team={} category={} reported {} < last {};"
+ " ignoring.",
teamId,
category,
cumulative,
@@ -69,7 +69,8 @@ public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry,
/** Sum of signed amounts over a team's entries — the wallet's current balance in units. */
@Query(
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e WHERE e.teamId = :teamId")
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e WHERE e.teamId ="
+ " :teamId")
long sumBalanceForTeam(@Param("teamId") Long teamId);
/** Period-bounded spend for one team in units (debits only). */
@@ -92,12 +93,11 @@ public interface WalletLedgerRepository extends JpaRepository<WalletLedgerEntry,
* monthly bill + cap.
*/
@Query(
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e"
+ " WHERE e.teamId = :teamId"
+ " AND e.entryType IN (stirling.software.saas.payg.model.LedgerEntryType.DEBIT,"
+ " stirling.software.saas.payg.model.LedgerEntryType.REFUND)"
+ " AND e.occurredAt >= :periodStart"
+ " AND e.occurredAt < :periodEnd")
"SELECT COALESCE(SUM(e.amountUnits), 0) FROM WalletLedgerEntry e WHERE e.teamId ="
+ " :teamId AND e.entryType IN"
+ " (stirling.software.saas.payg.model.LedgerEntryType.DEBIT,"
+ " stirling.software.saas.payg.model.LedgerEntryType.REFUND) AND e.occurredAt >="
+ " :periodStart AND e.occurredAt < :periodEnd")
long sumPeriodNetBillable(
@Param("teamId") Long teamId,
@Param("periodStart") LocalDateTime periodStart,
@@ -175,12 +175,10 @@ public class StripeInvoiceDao {
}
String placeholders = invoiceIds.stream().map(id -> "?").collect(Collectors.joining(","));
String sql =
"SELECT i.id AS invoice_id,"
+ " (SELECT SUM((l->>'quantity')::int)"
+ " FROM jsonb_array_elements(COALESCE(i.lines->'data', '[]'::jsonb)) AS l"
+ " WHERE l->'price'->'recurring'->>'usage_type' = 'metered') AS qty"
+ " FROM stripe.invoices i"
+ " WHERE i.id IN ("
"SELECT i.id AS invoice_id, (SELECT SUM((l->>'quantity')::int) FROM"
+ " jsonb_array_elements(COALESCE(i.lines->'data', '[]'::jsonb)) AS l WHERE"
+ " l->'price'->'recurring'->>'usage_type' = 'metered') AS qty FROM"
+ " stripe.invoices i WHERE i.id IN ("
+ placeholders
+ ")";
try {
@@ -18,8 +18,6 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import tools.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Hidden;
import jakarta.servlet.http.HttpServletRequest;
@@ -43,6 +41,8 @@ import stirling.software.saas.procurement.pricing.QuoteLineItem;
import stirling.software.saas.procurement.service.ProcurementService;
import stirling.software.saas.util.AuthenticationUtils;
import tools.jackson.databind.ObjectMapper;
/**
* The enterprise procurement journey for a linked team: read the deal snapshot, start/extend a
* (mock-licensed) trial, build a server-priced quote, and accept it. Stripe checkout itself is a
@@ -449,7 +449,7 @@ public class ProcurementController {
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment;"
+ " filename=\"stirling-enterprise-agreement.pdf\"")
+ " filename=\"stirling-enterprise-agreement.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf))
.orElseGet(() -> ResponseEntity.notFound().build());
@@ -472,7 +472,7 @@ public class ProcurementController {
.header(
HttpHeaders.CONTENT_DISPOSITION,
"attachment;"
+ " filename=\"stirling-enterprise-agreement.pdf\"")
+ " filename=\"stirling-enterprise-agreement.pdf\"")
.contentType(MediaType.APPLICATION_PDF)
.body(pdf))
.orElseGet(() -> ResponseEntity.notFound().build());
@@ -10,8 +10,6 @@ import java.util.Map;
import org.springframework.stereotype.Service;
import tools.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -22,6 +20,8 @@ import stirling.software.saas.procurement.pricing.ProcurementPricingService;
import stirling.software.saas.procurement.pricing.QuoteConfig;
import stirling.software.saas.procurement.pricing.QuoteLineItem;
import tools.jackson.databind.ObjectMapper;
/**
* Builds the full Stirling Enterprise Agreement for a specific quote: the static MSA (Part A) and
* DPA (Part C) from the {@link LegalDocumentRegistry}, with the dynamic Order Form (Part B)
@@ -159,7 +159,8 @@ public class AgreementAssembler {
row(
sb,
"Term",
"{{term_years}} year(s) · term discount {{term_discount_pct}} on committed processing");
"{{term_years}} year(s) · term discount {{term_discount_pct}} on committed"
+ " processing");
row(sb, "Itemized services", itemizedServices(quote));
row(sb, "Annual Fee (year 1)", "{{annual_fee_y1}}");
row(sb, "Total (paid in advance)", "{{contract_total}}");
@@ -174,12 +175,13 @@ public class AgreementAssembler {
sb,
"Data schedule",
"First 25 MB per file included; each additional 25 MB or part thereof (decimal MB,"
+ " rounded up per file, measured once at ingestion) draws down 1 PDF Process."
+ " Frozen for the Term (MSA §3.5).");
+ " rounded up per file, measured once at ingestion) draws down 1 PDF Process."
+ " Frozen for the Term (MSA §3.5).");
row(
sb,
"Drawdown schedule",
"{{posture}}: {{processes_per_pdf}} PDF Processes per PDF (MSA §3.3, frozen for the Term)");
"{{posture}}: {{processes_per_pdf}} PDF Processes per PDF (MSA §3.3, frozen for the"
+ " Term)");
row(
sb,
"Enhanced IP Protection",
@@ -187,12 +189,13 @@ public class AgreementAssembler {
row(sb, "Standard terms", "SSO, SCIM, RBAC, and audit logs included.");
sb.append(
"\n**Itemized services menu (include as elected):** Self-hosted deployment $12,000/yr"
+ " · Air-gapped deployment $36,000/yr · Dedicated SE/CSM $30,000/yr · Enhanced IP"
+ " Protection (patent coverage, Section 7.3) 5% of committed processing fees ·"
+ " Onboarding & training $7,500 one-time · Quarterly business reviews $8,000/yr."
+ " Baseline IP indemnification (copyright, trademark, trade secret) is included at"
+ " no charge.\n\n");
"\n"
+ "**Itemized services menu (include as elected):** Self-hosted deployment"
+ " $12,000/yr · Air-gapped deployment $36,000/yr · Dedicated SE/CSM $30,000/yr"
+ " · Enhanced IP Protection (patent coverage, Section 7.3) 5% of committed"
+ " processing fees · Onboarding & training $7,500 one-time · Quarterly"
+ " business reviews $8,000/yr. Baseline IP indemnification (copyright,"
+ " trademark, trade secret) is included at no charge.\n\n");
sb.append(
"**Signatures.** By signing, each signatory represents they have authority to bind"
+ " their Party. Signatures delivered electronically or in counterparts are"
@@ -45,8 +45,8 @@ public class AgreementPdfRenderer {
} catch (Exception e) {
org.slf4j.LoggerFactory.getLogger(AgreementPdfRenderer.class)
.warn(
"[legal] agreement PDF render unavailable; recording signature without a"
+ " stored PDF: {}",
"[legal] agreement PDF render unavailable; recording signature without"
+ " a stored PDF: {}",
e.getMessage());
return null;
}
@@ -16,13 +16,13 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.procurement.config.KeygenConfigurationProperties;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Real {@link EnterpriseLicenseService}: manages the team's enterprise licence directly against the
* Keygen API (the "call Keygen from Java" direction), rather than via the Supabase edge functions
@@ -53,8 +53,9 @@ public class KeygenEnterpriseLicenseService implements EnterpriseLicenseService
// caught at startup, not at the first trial/provision. (Flag off → Mock bean, never here.)
if (!config.isConfigured()) {
throw new IllegalStateException(
"stirling.keygen.enabled=true but Keygen is not fully configured — set "
+ "STIRLING_KEYGEN_ACCOUNT_ID / _API_TOKEN / _POLICY_ID, or turn the flag off");
"stirling.keygen.enabled=true but Keygen is not fully configured — set"
+ " STIRLING_KEYGEN_ACCOUNT_ID / _API_TOKEN / _POLICY_ID, or turn the flag"
+ " off");
}
}
@@ -55,7 +55,8 @@ public class MockEnterpriseLicenseService implements EnterpriseLicenseService {
String ref = existingRef != null ? existingRef : "mock-annual-" + UUID.randomUUID();
// Owner email is deliberately not logged — it's PII and adds nothing to the mock trace.
log.info(
"[procurement][mock-license] issue annual team={} seats={} volume={} deployment={} expires={} ref={} upgrade={}",
"[procurement][mock-license] issue annual team={} seats={} volume={} deployment={}"
+ " expires={} ref={} upgrade={}",
teamId,
ent.seats(),
ent.volume(),
@@ -10,9 +10,6 @@ import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.enumeration.TeamRole;
@@ -38,6 +35,9 @@ import stirling.software.saas.procurement.repository.ProcurementQuoteRepository;
import stirling.software.saas.service.SaasTeamService;
import stirling.software.saas.util.LogRedactionUtils;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;
/**
* Orchestrates a linked team's procurement journey: start a (mock-licensed) trial, build a
* server-priced quote, and accept it. Stripe checkout itself lives in a Supabase edge function the
@@ -21,7 +21,8 @@ public interface SupabaseUserRepository extends JpaRepository<SupabaseUser, UUID
* anonymous sessions in batch (avoids long-running transactions on a single big delete).
*/
@Query(
"SELECT s.id FROM SupabaseUser s WHERE s.isAnonymous = true AND s.createdAt < :cutoffDate")
"SELECT s.id FROM SupabaseUser s WHERE s.isAnonymous = true AND s.createdAt <"
+ " :cutoffDate")
Stream<UUID> findByCreatedAtBeforeAndIsAnonymousTrue(
@Param("cutoffDate") LocalDateTime cutoffDate);
@@ -23,7 +23,8 @@ public interface TeamInvitationRepository extends JpaRepository<TeamInvitation,
* @return Optional of TeamInvitation if found
*/
@Query(
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE ti.invitationToken = :token")
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE"
+ " ti.invitationToken = :token")
Optional<TeamInvitation> findByInvitationToken(@Param("token") String token);
/**
@@ -33,7 +34,8 @@ public interface TeamInvitationRepository extends JpaRepository<TeamInvitation,
* @return List of invitations
*/
@Query(
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE ti.inviteeEmail = :email")
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE"
+ " ti.inviteeEmail = :email")
List<TeamInvitation> findByInviteeEmail(@Param("email") String email);
/**
@@ -43,8 +45,8 @@ public interface TeamInvitationRepository extends JpaRepository<TeamInvitation,
* @return List of pending invitations
*/
@Query(
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter "
+ "WHERE ti.inviteeEmail = :email AND ti.status = 'PENDING' AND ti.expiresAt > :now")
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team JOIN FETCH ti.inviter WHERE"
+ " ti.inviteeEmail = :email AND ti.status = 'PENDING' AND ti.expiresAt > :now")
List<TeamInvitation> findPendingInvitationsByEmail(
@Param("email") String email, @Param("now") LocalDateTime now);
@@ -64,7 +66,8 @@ public interface TeamInvitationRepository extends JpaRepository<TeamInvitation,
* @return List of invitations
*/
@Query(
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team WHERE ti.inviter.id = :inviterUserId")
"SELECT ti FROM TeamInvitation ti JOIN FETCH ti.team WHERE ti.inviter.id ="
+ " :inviterUserId")
List<TeamInvitation> findByInviterUserId(@Param("inviterUserId") Long inviterUserId);
/**
@@ -75,7 +78,8 @@ public interface TeamInvitationRepository extends JpaRepository<TeamInvitation,
*/
@Modifying
@Query(
"UPDATE TeamInvitation ti SET ti.status = 'EXPIRED' WHERE ti.status = 'PENDING' AND ti.expiresAt < :now")
"UPDATE TeamInvitation ti SET ti.status = 'EXPIRED' WHERE ti.status = 'PENDING' AND"
+ " ti.expiresAt < :now")
int markExpiredInvitations(@Param("now") LocalDateTime now);
/**
@@ -383,7 +383,8 @@ public class SupabaseAuthenticationFilter extends OncePerRequestFilter {
.orElseThrow(
() ->
new AuthenticationFailureException(
"User creation conflict, but unable to find existing user",
"User creation conflict, but unable to find existing"
+ " user",
dup));
}
}
@@ -174,8 +174,9 @@ public class SupabaseSecurityConfig {
String issuerError = validateIssuer(issuer);
if (issuerError != null) {
log.warn(
"{} saas profile is active but JWTs cannot be validated. Set SAAS_DB_PROJECT_REF"
+ " (or app.supabase.issuer) in application-saas.properties or via env.",
"{} saas profile is active but JWTs cannot be validated. Set"
+ " SAAS_DB_PROJECT_REF (or app.supabase.issuer) in"
+ " application-saas.properties or via env.",
issuerError);
// Build a decoder that will reject every token; failing closed is safer than failing
// open when configuration is incomplete.
@@ -274,7 +274,8 @@ public class SaasTeamService {
existingUser -> {
if (hasPaidSubscription(existingUser)) {
throw new IllegalArgumentException(
"Cannot invite paid users to teams. Only team leaders manage billing.");
"Cannot invite paid users to teams. Only team leaders"
+ " manage billing.");
}
// Check if already a member
@@ -324,7 +325,8 @@ public class SaasTeamService {
Team userTeam = user.getTeam();
if (userTeam == null || !hasActivePaidSubscription(userTeam)) {
log.warn(
"User {} joined team {} but team has no active subscription - not granting PRO role",
"User {} joined team {} but team has no active subscription - not granting PRO"
+ " role",
user.getUsername(),
userTeam != null ? userTeam.getName() : "null");
return;
@@ -386,7 +388,8 @@ public class SaasTeamService {
// Validate: user doesn't have paid subscription
if (hasPaidSubscription(acceptingUser)) {
throw new IllegalArgumentException(
"Cannot join team with active paid subscription. Cancel your subscription first.");
"Cannot join team with active paid subscription. Cancel your subscription"
+ " first.");
}
// Validate: team has available seats
@@ -683,9 +686,9 @@ public class SaasTeamService {
// Check if Supabase is configured
if (!supabaseConfig.isEdgeFunctionConfigured()) {
log.warn(
"Supabase integration not configured, skipping email send. "
+ "Please configure supabase.edgeFunctionUrl and supabase.edgeFunctionSecret "
+ "in application properties.");
"Supabase integration not configured, skipping email send. Please configure"
+ " supabase.edgeFunctionUrl and supabase.edgeFunctionSecret in"
+ " application properties.");
return;
}
@@ -748,7 +751,8 @@ public class SaasTeamService {
if (maxSeats < currentSeatsUsed) {
int excessMembers = currentSeatsUsed - maxSeats;
log.warn(
"Team {} reducing seats from {} to {} with {} current members. Removing {} excess members.",
"Team {} reducing seats from {} to {} with {} current members. Removing {}"
+ " excess members.",
teamId,
currentMaxSeats,
maxSeats,
@@ -811,7 +815,8 @@ public class SaasTeamService {
downgradeUserToFree(userToRemove);
log.info(
"User {} removed from team {} and migrated to personal team due to seat reduction",
"User {} removed from team {} and migrated to personal team due to seat"
+ " reduction",
userToRemove.getId(),
teamId);
}
@@ -886,7 +891,8 @@ public class SaasTeamService {
if (hasActiveSubscription) {
log.info(
"User {} has active subscription (trial or paid), maintaining PRO access after leaving team",
"User {} has active subscription (trial or paid), maintaining PRO"
+ " access after leaving team",
refetchedUser.getUsername());
return; // Keep PRO access
}
@@ -121,7 +121,8 @@ class SaasSchemaOwnershipTest {
will not exist on a fresh preview branch.
- HIBERNATE_MANAGED: only correct for a table inherited from the \
self-hosted app that no Supabase migration creates.
Offending tables -> entities: %s"""
Offending tables -> entities: %s\
"""
.formatted(
undeclared.stream()
.map(t -> t + " (" + mapped.get(t) + ")")
@@ -28,9 +28,6 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.method.HandlerMethod;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.simple.SimpleMeterRegistry;
@@ -45,6 +42,9 @@ import stirling.software.saas.payg.model.FeatureGate;
import stirling.software.saas.payg.model.FeatureSet;
import stirling.software.saas.security.EnhancedJwtAuthenticationToken;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
/**
* Pure-Mockito tests for {@link EntitlementGuard}. Covers the four decision-matrix cells: anonymous
* billable → 401, anonymous manual → pass, authenticated FULL → pass, authenticated DEGRADED for a
@@ -1433,8 +1433,8 @@ class SaasTeamServiceTest {
assertThatThrownBy(() -> service.acceptInvitation(TOKEN, joiner))
.isInstanceOf(IllegalStateException.class)
.hasMessage(
"Revoke linked self-hosted instances on this team before joining another"
+ " team.");
"Revoke linked self-hosted instances on this team before joining"
+ " another team.");
// Guard fires before any team mutation.
verify(membershipRepository, never()).delete(any());
@@ -178,7 +178,8 @@ class SaasUserAccountServiceTest {
@Test
@DisplayName(
"downgrades a PRO user whose team is personal (personal team is not a shared PRO source)")
"downgrades a PRO user whose team is personal (personal team is not a shared PRO"
+ " source)")
void proWithPersonalTeam_isDowngraded() {
User u = userWithRole(Role.PRO_USER.getRoleId());
Team t = team(7L, "alice-personal");
@@ -414,7 +415,8 @@ class SaasUserAccountServiceTest {
@Test
@DisplayName(
"does not overwrite username/email when the email is blank, but still promotes the type")
"does not overwrite username/email when the email is blank, but still promotes the"
+ " type")
void blankEmail_keepsUsername() {
SupabaseUser su = supabaseUser(false);
User u = anonymousLocalUser();