diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index d36adcf8..bbc1bb97 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -602,7 +602,7 @@ jobs: else PUBLIC_BACKEND_URL=$B_URL fi - + gcloud run services update ${{ env.BACKEND_SERVICE }} \ --region $REGION \ --update-env-vars "FRONTEND_URL=$FINAL_FRONTEND_URL,BACKEND_URL=$PUBLIC_BACKEND_URL" @@ -674,7 +674,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "| Component | Status | Target Service | Service URL |" >> $GITHUB_STEP_SUMMARY echo "|---|---|---|---|" >> $GITHUB_STEP_SUMMARY - + if [ -n "$PUBLIC_API_URL" ]; then echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($PUBLIC_API_URL) |" >> $GITHUB_STEP_SUMMARY elif [ -n "$B_URL" ]; then @@ -682,13 +682,13 @@ jobs: else echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY fi - + if [ -n "$F_URL" ]; then echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | [App URL]($F_URL) |" >> $GITHUB_STEP_SUMMARY else echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY fi - + echo "" >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index b0c9cc3f..e939e2e0 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,10 @@ The Spring Boot backend relies on environment variables. You can set these in yo | `R2_SECRET_KEY` | Storage Secret Key | `your_dev_secret_key` | | `R2_ENDPOINT` | Storage Endpoint URL | `https://.r2.cloudflarestorage.com` | | `WARDEN_ENABLED` | **Must be false locally** | `false` | +| `STRIPE_SECRET_KEY` | Stripe server-side API key (`sk_test_...`) | `sk_test_...` | +| `STRIPE_PUBLISHABLE_KEY` | Stripe client key (`pk_test_...`) | `pk_test_...` | +| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) | `whsec_...` | +| `STRIPE_MOCK_ENABLED` | Optional Stripe mock mode for local testing | `false` | | `STATUS_DISCORD_WEBHOOK_URL` | Optional Discord webhook for status-change alerts | `https://discord.com/api/webhooks/...` | > **Note on Warden:** The "Warden" malware and security scanner is proprietary to protect our threat-detection logic. You **must** set `WARDEN_ENABLED=false` to run the backend locally. This enables a "Mock Mode" where file uploads bypass the scanner and automatically return a mock "CLEAN" status. @@ -100,6 +104,28 @@ The Spring Boot backend relies on environment variables. You can set these in yo **(Optional) OAuth Variables:** To test social logins (GitHub, Discord, Google), provide their respective Client IDs and Secrets (e.g., `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`). +### Stripe Integration Setup (Step-by-Step) + +1. Create a Stripe account and switch to **Test mode** in the Stripe dashboard. +2. Create/get API keys: + - `STRIPE_SECRET_KEY` from **Developers -> API keys** (`sk_test_...`) + - `STRIPE_PUBLISHABLE_KEY` from the same screen (`pk_test_...`) +3. Configure backend env vars before starting Spring Boot: + - `STRIPE_SECRET_KEY=...` + - `STRIPE_PUBLISHABLE_KEY=...` + - `STRIPE_MOCK_ENABLED=false` +4. Start backend (`./gradlew bootRun`) and frontend (`npm run dev`). +5. In Stripe dashboard, ensure your account can use Checkout + Connect in test mode. +6. Ensure `STRIPE_MOCK_ENABLED=false` in backend configuration for real integration testing. +7. Connect a creator Stripe account from the in-app Finance Manager (`Connect / Continue Stripe`) and then click `Refresh Stripe Status`. +8. Test one-time donations: + - Open a project with donations enabled. + - Start donation checkout and complete payment with Stripe test cards. + - Return to project page; donation is only counted after Stripe confirms paid/completed. +9. Optional local-only testing without real Stripe API: + - Set `STRIPE_MOCK_ENABLED=true` in backend configuration. + - Mock mode now simulates checkout links/status only and does **not** auto-mark donations as paid. + ### 3. Run the Backend Open a terminal in the `backend/` directory and use the Gradle wrapper. diff --git a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java index 187e00b1..cee83b6f 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -303,11 +303,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/api/v1/status", "/api/v1/version/**", "/api/v1/analytics/platform/stats", - "/api/v1/wiki/**" + "/api/v1/wiki/**", + "/api/v1/finance/public/**", + "/api/v1/finance/projects/*/donation-config", + "/api/v1/finance/projects/*/donations/checkout-url", + "/api/v1/finance/donations/confirm", + "/api/v1/finance/ads/slot/**", + "/api/v1/finance/ads/click/**" ).permitAll() .requestMatchers(HttpMethod.HEAD, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**").permitAll() .requestMatchers(HttpMethod.POST, - "/api/v1/users/batch" + "/api/v1/users/batch", + "/api/v1/finance/ads/impression" ).permitAll() .requestMatchers("/api/v1/analytics/platform/full").access((authentication, context) -> { boolean isApiKeyUser = authentication.get().getAuthorities().stream() diff --git a/backend/src/main/java/net/modtale/controller/finance/AdController.java b/backend/src/main/java/net/modtale/controller/finance/AdController.java new file mode 100644 index 00000000..219a8cac --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/AdController.java @@ -0,0 +1,57 @@ +package net.modtale.controller.finance; + +import jakarta.servlet.http.HttpServletRequest; +import net.modtale.service.finance.AdCampaignService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/finance") +public class AdController { + + @Autowired private AdCampaignService financeAdsService; + + @GetMapping("/ads/slot/{projectId}") + public ResponseEntity getAdSlot( + @PathVariable String projectId, + @RequestParam(required = false) String placement + ) { + return ResponseEntity.ok(financeAdsService.getAdSlotForProject(projectId, placement)); + } + + @PostMapping("/ads/impression") + public ResponseEntity trackAdImpression(@RequestBody Map payload, HttpServletRequest request) { + String campaignId = payload.get("campaignId"); + String projectId = payload.get("projectId"); + if (campaignId == null || projectId == null) return ResponseEntity.badRequest().build(); + + String ip = getClientIp(request); + financeAdsService.trackAdImpression(campaignId, projectId, ip); + return ResponseEntity.ok(Map.of("ok", true)); + } + + @GetMapping("/ads/click/{campaignId}") + public ResponseEntity clickAd( + @PathVariable String campaignId, + @RequestParam String projectId, + HttpServletRequest request + ) { + String ip = getClientIp(request); + String url = financeAdsService.registerAdClickAndResolveUrl(campaignId, projectId, ip); + + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(URI.create(url)); + return new ResponseEntity<>(headers, HttpStatus.FOUND); + } + + private String getClientIp(HttpServletRequest request) { + String xfHeader = request.getHeader("X-Forwarded-For"); + return xfHeader == null ? request.getRemoteAddr() : xfHeader.split(",")[0]; + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/CreatorRevenueController.java b/backend/src/main/java/net/modtale/controller/finance/CreatorRevenueController.java new file mode 100644 index 00000000..efa25436 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/CreatorRevenueController.java @@ -0,0 +1,170 @@ +package net.modtale.controller.finance; + +import net.modtale.model.dto.request.finance.UpdateProjectMonetizationRequest; +import net.modtale.model.project.Project; +import net.modtale.model.user.User; +import net.modtale.service.finance.EarningsAccountService; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.user.account.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/finance") +public class CreatorRevenueController { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private AccountService accountService; + @Autowired private ProjectService projectService; + @Autowired private AccessControlService accessControlService; + + @GetMapping("/creator/overview") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity getCreatorOverview( + @RequestParam(defaultValue = "30d") String range, + @RequestParam(required = false) String ownerId + ) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + try { + return ResponseEntity.ok(financeAccountService.getCreatorOverview(user, ownerId, range)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/creator/contexts") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity getFinanceContexts() { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + return ResponseEntity.ok(financeAccountService.getFinanceContexts(user)); + } + + @PostMapping("/creator/stripe/onboarding-link") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_EDIT_BASIC', authentication)") + public ResponseEntity createStripeOnboardingLink(@RequestBody(required = false) Map payload) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + String returnPath = payload != null ? payload.get("returnPath") : null; + String ownerId = payload != null ? payload.get("ownerId") : null; + try { + return ResponseEntity.ok(financeAccountService.createStripeOnboardingLink(user, ownerId, returnPath)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PostMapping("/creator/stripe/refresh-status") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity refreshStripeStatus(@RequestBody(required = false) Map payload) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + String ownerId = payload != null ? payload.get("ownerId") : null; + try { + return ResponseEntity.ok(financeAccountService.refreshStripeStatus(user, ownerId)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PostMapping("/creator/payouts/request") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity requestPayout(@RequestBody(required = false) Map payload) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + Long amountCents = null; + String ownerId = null; + if (payload != null && payload.get("amountCents") != null) { + try { + amountCents = Long.parseLong(String.valueOf(payload.get("amountCents"))); + } catch (Exception ignored) {} + } + if (payload != null && payload.get("ownerId") != null) { + ownerId = String.valueOf(payload.get("ownerId")); + } + + try { + return ResponseEntity.ok(financeAccountService.requestPayout(user, ownerId, amountCents)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalStateException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/creator/orgs/{orgId}/payout-policy") + @PreAuthorize("@apiSecurity.hasOrgPerm(#orgId, 'ORG_EDIT_METADATA', authentication)") + public ResponseEntity getOrgPayoutPolicy(@PathVariable String orgId) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + try { + return ResponseEntity.ok(financeAccountService.getOrgPayoutPolicy(user, orgId)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/creator/orgs/{orgId}/payout-policy") + @PreAuthorize("@apiSecurity.hasOrgPerm(#orgId, 'ORG_EDIT_METADATA', authentication)") + public ResponseEntity updateOrgPayoutPolicy(@PathVariable String orgId, @RequestBody Map payload) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + String payoutMode = payload == null || payload.get("payoutMode") == null ? null : String.valueOf(payload.get("payoutMode")); + List> shares = new ArrayList<>(); + if (payload != null && payload.get("shares") instanceof List rawShares) { + for (Object item : rawShares) { + if (item instanceof Map rawMap) { + @SuppressWarnings("unchecked") + Map typed = (Map) rawMap; + shares.add(typed); + } + } + } + + try { + return ResponseEntity.ok(financeAccountService.updateOrgPayoutPolicy(user, orgId, payoutMode, shares)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/projects/{projectId}/settings") + @PreAuthorize("@apiSecurity.hasProjectPerm(#projectId, 'PROJECT_EDIT_METADATA', authentication)") + public ResponseEntity updateProjectMonetization( + @PathVariable String projectId, + @RequestBody UpdateProjectMonetizationRequest request + ) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + Project project = projectService.getRawProjectById(projectId); + if (project == null) return ResponseEntity.notFound().build(); + if (!accessControlService.hasProjectPermission(project, user, "PROJECT_EDIT_METADATA")) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + return ResponseEntity.ok(financeAccountService.updateProjectMonetization(user, project, request)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/DonationController.java b/backend/src/main/java/net/modtale/controller/finance/DonationController.java new file mode 100644 index 00000000..02e63432 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/DonationController.java @@ -0,0 +1,49 @@ +package net.modtale.controller.finance; + +import net.modtale.model.user.User; +import net.modtale.service.finance.DonationCheckoutService; +import net.modtale.service.user.account.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/finance") +public class DonationController { + + @Autowired private DonationCheckoutService financeDonationService; + @Autowired private AccountService accountService; + + @GetMapping("/projects/{projectId}/donation-config") + public ResponseEntity getDonationConfig(@PathVariable String projectId) { + try { + return ResponseEntity.ok(financeDonationService.getDonationConfig(projectId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/projects/{projectId}/donations/checkout-url") + public ResponseEntity createDonationCheckout( + @PathVariable String projectId, + @RequestParam long amountCents, + @RequestParam(defaultValue = "false") boolean recurring, + @RequestParam(defaultValue = "false") boolean guestCheckout + ) { + try { + User donor = accountService.getCurrentUser(); + return ResponseEntity.ok(financeDonationService.createDonationCheckout(projectId, amountCents, recurring, donor, guestCheckout)); + } catch (IllegalStateException | IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/donations/confirm") + public ResponseEntity confirmDonation(@RequestParam String intentId) { + try { + return ResponseEntity.ok(financeDonationService.confirmDonationIntent(intentId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/RevenueAdminController.java b/backend/src/main/java/net/modtale/controller/finance/RevenueAdminController.java new file mode 100644 index 00000000..8b09bbf6 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/RevenueAdminController.java @@ -0,0 +1,112 @@ +package net.modtale.controller.finance; + +import net.modtale.model.dto.request.finance.UpdatePlatformFinanceSettingsRequest; +import net.modtale.model.user.User; +import net.modtale.service.finance.EarningsAccountService; +import net.modtale.service.finance.AdCampaignService; +import net.modtale.service.security.access.AccessControlService; +import net.modtale.service.user.account.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/finance") +public class RevenueAdminController { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private AdCampaignService financeAdsService; + @Autowired private AccountService accountService; + @Autowired private AccessControlService accessControlService; + + @GetMapping("/admin/overview") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity getAdminOverview(@RequestParam(defaultValue = "30d") String range) { + User user = accountService.getCurrentUser(); + if (user == null || !accessControlService.isAdmin(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(financeAccountService.getAdminOverview(range)); + } + + @PutMapping("/admin/settings") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity updateAdminSettings(@RequestBody UpdatePlatformFinanceSettingsRequest request) { + User user = accountService.getCurrentUser(); + if (user == null || !accessControlService.isAdmin(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(financeAccountService.updatePlatformSettings(request)); + } + + @GetMapping("/admin/ads/campaigns") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity getAdCampaigns() { + User user = accountService.getCurrentUser(); + if (!isSuperAdmin(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(financeAdsService.getAdCampaigns()); + } + + @PostMapping("/admin/ads/campaigns") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity createAdCampaign(@RequestBody Map payload) { + User user = accountService.getCurrentUser(); + if (!isSuperAdmin(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(financeAdsService.createAdCampaign(payload)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/admin/ads/campaigns/{campaignId}") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity updateAdCampaign(@PathVariable String campaignId, @RequestBody Map payload) { + User user = accountService.getCurrentUser(); + if (!isSuperAdmin(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(financeAdsService.updateAdCampaign(campaignId, payload)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PostMapping("/admin/ads/campaigns/{campaignId}/start") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity startAdCampaign(@PathVariable String campaignId) { + User user = accountService.getCurrentUser(); + if (!isSuperAdmin(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(financeAdsService.setCampaignActiveState(campaignId, true)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PostMapping("/admin/ads/campaigns/{campaignId}/pause") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity pauseAdCampaign(@PathVariable String campaignId) { + User user = accountService.getCurrentUser(); + if (!isSuperAdmin(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + try { + return ResponseEntity.ok(financeAdsService.setCampaignActiveState(campaignId, false)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/admin/ads/test-slot/{projectId}") + @PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)") + public ResponseEntity getTestAdSlot( + @PathVariable String projectId, + @RequestParam(required = false) String placement + ) { + User user = accountService.getCurrentUser(); + if (!isSuperAdmin(user)) return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + return ResponseEntity.ok(financeAdsService.getTestAdSlotForProject(projectId, placement)); + } + + private boolean isSuperAdmin(User user) { + return accessControlService.isSuperAdmin(user); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/RevenuePublicController.java b/backend/src/main/java/net/modtale/controller/finance/RevenuePublicController.java new file mode 100644 index 00000000..3c9b3fd3 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/RevenuePublicController.java @@ -0,0 +1,49 @@ +package net.modtale.controller.finance; + +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.service.finance.EarningsAccountService; +import net.modtale.service.finance.RevenueReportingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@RestController +@RequestMapping("/api/v1/finance") +public class RevenuePublicController { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private RevenueReportingService financeReportingService; + + @GetMapping("/public/daily-revenue") + public ResponseEntity getPublicDailyRevenue(@RequestParam(defaultValue = "90") int days) { + long secondsToMidnight = Duration.between(LocalDateTime.now(), LocalDateTime.now().toLocalDate().plusDays(1).atStartOfDay()).getSeconds(); + PlatformFinanceSettings settings = financeAccountService.getSettings(); + + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(secondsToMidnight, TimeUnit.SECONDS).cachePublic()) + .body(Map.of( + "currency", settings.getCurrency(), + "days", Math.max(1, Math.min(365, days)), + "data", financeReportingService.getPublicDailyRevenue(days) + )); + } + + @GetMapping("/public/settings") + public ResponseEntity getPublicMonetizationSettings() { + PlatformFinanceSettings settings = financeAccountService.getSettings(); + return ResponseEntity.ok(Map.of( + "adCreatorSplitPercent", settings.getAdCreatorSplitBps() / 100.0, + "donationPlatformCutPercent", settings.getDonationPlatformCutBps() / 100.0, + "fundExpiryDays", settings.getFundExpiryDays() + )); + } +} diff --git a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java index 5bd0a314..77ef2988 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -231,6 +231,11 @@ public static ProjectDTO toDTO(Project project, boolean isSummary, String curren dto.setTypes(project.getTypes()); dto.setAllowModpacks(project.isAllowModpacks()); dto.setAllowComments(project.isAllowComments()); + dto.setAdsEnabled(project.isAdsEnabled()); + dto.setDonationsEnabled(project.isDonationsEnabled()); + dto.setSuggestedDonationCents(project.getSuggestedDonationCents()); + dto.setDonationRecurringDefault(project.isDonationRecurringDefault()); + dto.setDonationPlatformCutBps(project.getDonationPlatformCutBps()); dto.setHmWikiEnabled(project.isHmWikiEnabled()); dto.setHmWikiSlug(project.getHmWikiSlug()); dto.setGalleryCarouselEnabled(project.isGalleryCarouselEnabled()); diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java index a5e0e063..e635d11c 100644 --- a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java +++ b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java @@ -38,6 +38,11 @@ public class ProjectDTO { private List modIds; private boolean allowModpacks; private boolean allowComments; + private boolean adsEnabled; + private boolean donationsEnabled; + private int suggestedDonationCents; + private boolean donationRecurringDefault; + private int donationPlatformCutBps; private boolean hmWikiEnabled; private String hmWikiSlug; private boolean galleryCarouselEnabled; @@ -113,6 +118,16 @@ public class ProjectDTO { public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } public void setAllowComments(boolean allowComments) { this.allowComments = allowComments; } + public boolean isAdsEnabled() { return adsEnabled; } + public void setAdsEnabled(boolean adsEnabled) { this.adsEnabled = adsEnabled; } + public boolean isDonationsEnabled() { return donationsEnabled; } + public void setDonationsEnabled(boolean donationsEnabled) { this.donationsEnabled = donationsEnabled; } + public int getSuggestedDonationCents() { return suggestedDonationCents; } + public void setSuggestedDonationCents(int suggestedDonationCents) { this.suggestedDonationCents = suggestedDonationCents; } + public boolean isDonationRecurringDefault() { return donationRecurringDefault; } + public void setDonationRecurringDefault(boolean donationRecurringDefault) { this.donationRecurringDefault = donationRecurringDefault; } + public int getDonationPlatformCutBps() { return donationPlatformCutBps; } + public void setDonationPlatformCutBps(int donationPlatformCutBps) { this.donationPlatformCutBps = donationPlatformCutBps; } public boolean isHmWikiEnabled() { return hmWikiEnabled; } public void setHmWikiEnabled(boolean hmWikiEnabled) { this.hmWikiEnabled = hmWikiEnabled; } public String getHmWikiSlug() { return hmWikiSlug; } diff --git a/backend/src/main/java/net/modtale/model/dto/request/finance/UpdatePlatformFinanceSettingsRequest.java b/backend/src/main/java/net/modtale/model/dto/request/finance/UpdatePlatformFinanceSettingsRequest.java new file mode 100644 index 00000000..866df0de --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/finance/UpdatePlatformFinanceSettingsRequest.java @@ -0,0 +1,23 @@ +package net.modtale.model.dto.request.finance; + +public class UpdatePlatformFinanceSettingsRequest { + private Integer defaultAdRevenuePerClickCents; + private Integer minPayoutCents; + + public Integer getDefaultAdRevenuePerClickCents() { + return defaultAdRevenuePerClickCents; + } + + public void setDefaultAdRevenuePerClickCents(Integer defaultAdRevenuePerClickCents) { + this.defaultAdRevenuePerClickCents = defaultAdRevenuePerClickCents; + } + + public Integer getMinPayoutCents() { + return minPayoutCents; + } + + public void setMinPayoutCents(Integer minPayoutCents) { + this.minPayoutCents = minPayoutCents; + } + +} diff --git a/backend/src/main/java/net/modtale/model/dto/request/finance/UpdateProjectMonetizationRequest.java b/backend/src/main/java/net/modtale/model/dto/request/finance/UpdateProjectMonetizationRequest.java new file mode 100644 index 00000000..a23e4538 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/finance/UpdateProjectMonetizationRequest.java @@ -0,0 +1,49 @@ +package net.modtale.model.dto.request.finance; + +public class UpdateProjectMonetizationRequest { + private Boolean adsEnabled; + private Boolean donationsEnabled; + private Integer suggestedDonationCents; + private Boolean donationRecurringDefault; + private Integer donationPlatformCutBps; + + public Boolean getAdsEnabled() { + return adsEnabled; + } + + public void setAdsEnabled(Boolean adsEnabled) { + this.adsEnabled = adsEnabled; + } + + public Boolean getDonationsEnabled() { + return donationsEnabled; + } + + public void setDonationsEnabled(Boolean donationsEnabled) { + this.donationsEnabled = donationsEnabled; + } + + public Integer getSuggestedDonationCents() { + return suggestedDonationCents; + } + + public void setSuggestedDonationCents(Integer suggestedDonationCents) { + this.suggestedDonationCents = suggestedDonationCents; + } + + public Boolean getDonationRecurringDefault() { + return donationRecurringDefault; + } + + public void setDonationRecurringDefault(Boolean donationRecurringDefault) { + this.donationRecurringDefault = donationRecurringDefault; + } + + public Integer getDonationPlatformCutBps() { + return donationPlatformCutBps; + } + + public void setDonationPlatformCutBps(Integer donationPlatformCutBps) { + this.donationPlatformCutBps = donationPlatformCutBps; + } +} diff --git a/backend/src/main/java/net/modtale/model/dto/request/project/UpdateProjectRequest.java b/backend/src/main/java/net/modtale/model/dto/request/project/UpdateProjectRequest.java index 6f34207b..06f71d09 100644 --- a/backend/src/main/java/net/modtale/model/dto/request/project/UpdateProjectRequest.java +++ b/backend/src/main/java/net/modtale/model/dto/request/project/UpdateProjectRequest.java @@ -33,6 +33,10 @@ public class UpdateProjectRequest { private Boolean customLicenseOpenSource; private Boolean allowModpacks; private Boolean allowComments; + private Boolean adsEnabled; + private Boolean donationsEnabled; + private Integer suggestedDonationCents; + private Boolean donationRecurringDefault; private Boolean hmWikiEnabled; private Boolean galleryCarouselEnabled; @@ -64,6 +68,14 @@ public class UpdateProjectRequest { public void setAllowModpacks(Boolean allowModpacks) { this.allowModpacks = allowModpacks; } public Boolean getAllowComments() { return allowComments; } public void setAllowComments(Boolean allowComments) { this.allowComments = allowComments; } + public Boolean getAdsEnabled() { return adsEnabled; } + public void setAdsEnabled(Boolean adsEnabled) { this.adsEnabled = adsEnabled; } + public Boolean getDonationsEnabled() { return donationsEnabled; } + public void setDonationsEnabled(Boolean donationsEnabled) { this.donationsEnabled = donationsEnabled; } + public Integer getSuggestedDonationCents() { return suggestedDonationCents; } + public void setSuggestedDonationCents(Integer suggestedDonationCents) { this.suggestedDonationCents = suggestedDonationCents; } + public Boolean getDonationRecurringDefault() { return donationRecurringDefault; } + public void setDonationRecurringDefault(Boolean donationRecurringDefault) { this.donationRecurringDefault = donationRecurringDefault; } public Boolean getHmWikiEnabled() { return hmWikiEnabled; } public void setHmWikiEnabled(Boolean hmWikiEnabled) { this.hmWikiEnabled = hmWikiEnabled; } public Boolean getGalleryCarouselEnabled() { return galleryCarouselEnabled; } diff --git a/backend/src/main/java/net/modtale/model/finance/AdCampaign.java b/backend/src/main/java/net/modtale/model/finance/AdCampaign.java new file mode 100644 index 00000000..24a6cff0 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/AdCampaign.java @@ -0,0 +1,275 @@ +package net.modtale.model.finance; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Document(collection = "ad_campaigns") +public class AdCampaign { + + public enum ProviderType { + GENERIC_PROVIDER, + CUSTOM_AFFILIATE + } + + public enum AdPlacement { + SIDEBAR_CARD, + WIDE_BANNER, + TALL_BANNER + } + + public static class AdCreative { + private AdPlacement placement = AdPlacement.SIDEBAR_CARD; + private String imageUrl; + private String altText; + + public AdPlacement getPlacement() { + return placement; + } + + public void setPlacement(AdPlacement placement) { + this.placement = placement; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public String getAltText() { + return altText; + } + + public void setAltText(String altText) { + this.altText = altText; + } + } + + @Id + private String id; + + @Indexed + private String name; + + @Indexed + private boolean active = true; + + private ProviderType providerType = ProviderType.CUSTOM_AFFILIATE; + + private String providerName; + private String providerPlacementKey; + + private String sponsorName; + private String headline; + private String body; + private String callToAction = "Learn more"; + private String imageUrl; + private List creatives = new ArrayList<>(); + + private String targetUrl; + private String affiliateParam = "ref"; + private String affiliateCode; + + private int baseRevenuePerClickCents = 3; + private int weight = 100; + private boolean testCampaign = false; + + private boolean privacyRespecting = true; + private boolean nonIntrusive = true; + + private List allowedClassifications = new ArrayList<>(); + + private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime updatedAt = LocalDateTime.now(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + public ProviderType getProviderType() { + return providerType; + } + + public void setProviderType(ProviderType providerType) { + this.providerType = providerType; + } + + public String getProviderName() { + return providerName; + } + + public void setProviderName(String providerName) { + this.providerName = providerName; + } + + public String getProviderPlacementKey() { + return providerPlacementKey; + } + + public void setProviderPlacementKey(String providerPlacementKey) { + this.providerPlacementKey = providerPlacementKey; + } + + public String getSponsorName() { + return sponsorName; + } + + public void setSponsorName(String sponsorName) { + this.sponsorName = sponsorName; + } + + public String getHeadline() { + return headline; + } + + public void setHeadline(String headline) { + this.headline = headline; + } + + public String getBody() { + return body; + } + + public void setBody(String body) { + this.body = body; + } + + public String getCallToAction() { + return callToAction; + } + + public void setCallToAction(String callToAction) { + this.callToAction = callToAction; + } + + public String getImageUrl() { + return imageUrl; + } + + public void setImageUrl(String imageUrl) { + this.imageUrl = imageUrl; + } + + public List getCreatives() { + return creatives; + } + + public void setCreatives(List creatives) { + this.creatives = creatives == null ? new ArrayList<>() : creatives; + } + + public String getTargetUrl() { + return targetUrl; + } + + public void setTargetUrl(String targetUrl) { + this.targetUrl = targetUrl; + } + + public String getAffiliateParam() { + return affiliateParam; + } + + public void setAffiliateParam(String affiliateParam) { + this.affiliateParam = affiliateParam; + } + + public String getAffiliateCode() { + return affiliateCode; + } + + public void setAffiliateCode(String affiliateCode) { + this.affiliateCode = affiliateCode; + } + + public int getBaseRevenuePerClickCents() { + return baseRevenuePerClickCents; + } + + public void setBaseRevenuePerClickCents(int baseRevenuePerClickCents) { + this.baseRevenuePerClickCents = baseRevenuePerClickCents; + } + + public int getWeight() { + return weight; + } + + public void setWeight(int weight) { + this.weight = weight; + } + + public boolean isTestCampaign() { + return testCampaign; + } + + public void setTestCampaign(boolean testCampaign) { + this.testCampaign = testCampaign; + } + + public boolean isPrivacyRespecting() { + return privacyRespecting; + } + + public void setPrivacyRespecting(boolean privacyRespecting) { + this.privacyRespecting = privacyRespecting; + } + + public boolean isNonIntrusive() { + return nonIntrusive; + } + + public void setNonIntrusive(boolean nonIntrusive) { + this.nonIntrusive = nonIntrusive; + } + + public List getAllowedClassifications() { + return allowedClassifications; + } + + public void setAllowedClassifications(List allowedClassifications) { + this.allowedClassifications = allowedClassifications; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/net/modtale/model/finance/DonationIntent.java b/backend/src/main/java/net/modtale/model/finance/DonationIntent.java new file mode 100644 index 00000000..ceee0b99 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/DonationIntent.java @@ -0,0 +1,179 @@ +package net.modtale.model.finance; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document(collection = "donation_intents") +@CompoundIndexes({ + @CompoundIndex(name = "project_created_idx", def = "{'projectId': 1, 'createdAt': -1}"), + @CompoundIndex(name = "status_expires_idx", def = "{'status': 1, 'expiresAt': 1}") +}) +public class DonationIntent { + + public enum DonationStatus { + PENDING, + COMPLETED, + FAILED, + EXPIRED + } + + @Id + private String id; + + @Indexed + private String projectId; + + @Indexed + private String creatorId; + private String donorUserId; + private boolean guestDonation; + + private long amountCents; + private long creatorCents; + private long platformCents; + private boolean recurring; + private String currency = "usd"; + + @Indexed + private DonationStatus status = DonationStatus.PENDING; + + private String stripeSessionId; + private String checkoutUrl; + + private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime completedAt; + private LocalDateTime expiresAt = LocalDateTime.now().plusHours(12); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProjectId() { + return projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getCreatorId() { + return creatorId; + } + + public void setCreatorId(String creatorId) { + this.creatorId = creatorId; + } + + public String getDonorUserId() { + return donorUserId; + } + + public void setDonorUserId(String donorUserId) { + this.donorUserId = donorUserId; + } + + public boolean isGuestDonation() { + return guestDonation; + } + + public void setGuestDonation(boolean guestDonation) { + this.guestDonation = guestDonation; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public long getCreatorCents() { + return creatorCents; + } + + public void setCreatorCents(long creatorCents) { + this.creatorCents = creatorCents; + } + + public long getPlatformCents() { + return platformCents; + } + + public void setPlatformCents(long platformCents) { + this.platformCents = platformCents; + } + + public boolean isRecurring() { + return recurring; + } + + public void setRecurring(boolean recurring) { + this.recurring = recurring; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public DonationStatus getStatus() { + return status; + } + + public void setStatus(DonationStatus status) { + this.status = status; + } + + public String getStripeSessionId() { + return stripeSessionId; + } + + public void setStripeSessionId(String stripeSessionId) { + this.stripeSessionId = stripeSessionId; + } + + public String getCheckoutUrl() { + return checkoutUrl; + } + + public void setCheckoutUrl(String checkoutUrl) { + this.checkoutUrl = checkoutUrl; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(LocalDateTime completedAt) { + this.completedAt = completedAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } +} diff --git a/backend/src/main/java/net/modtale/model/finance/FinanceLedgerEntry.java b/backend/src/main/java/net/modtale/model/finance/FinanceLedgerEntry.java new file mode 100644 index 00000000..c49ed947 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/FinanceLedgerEntry.java @@ -0,0 +1,204 @@ +package net.modtale.model.finance; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Document(collection = "finance_ledger_entries") +@CompoundIndexes({ + @CompoundIndex(name = "creator_status_expires_idx", def = "{'creatorId': 1, 'status': 1, 'expiresAt': 1}"), + @CompoundIndex(name = "created_at_idx", def = "{'createdAt': -1}"), + @CompoundIndex(name = "type_created_idx", def = "{'type': 1, 'createdAt': -1}") +}) +public class FinanceLedgerEntry { + + public enum LedgerType { + DONATION, + AD_CLICK, + AD_IMPRESSION, + PAYOUT, + EXPIRED_TRANSFER, + PLATFORM_CUT, + MANUAL_ADJUSTMENT + } + + public enum EntryStatus { + PENDING, + AVAILABLE, + PAID, + EXPIRED + } + + @Id + private String id; + + @Indexed + private String creatorId; + + @Indexed + private String projectId; + + @Indexed + private LedgerType type; + + private long grossCents; + private long creatorCents; + private long platformCents; + private String currency = "usd"; + + @Indexed + private EntryStatus status = EntryStatus.PENDING; + + private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime availableAt; + private LocalDateTime expiresAt; + private LocalDateTime completedAt; + + private String stripeReference; + private String externalReference; + private boolean recurring; + + private Map metadata = new HashMap<>(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCreatorId() { + return creatorId; + } + + public void setCreatorId(String creatorId) { + this.creatorId = creatorId; + } + + public String getProjectId() { + return projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public LedgerType getType() { + return type; + } + + public void setType(LedgerType type) { + this.type = type; + } + + public long getGrossCents() { + return grossCents; + } + + public void setGrossCents(long grossCents) { + this.grossCents = grossCents; + } + + public long getCreatorCents() { + return creatorCents; + } + + public void setCreatorCents(long creatorCents) { + this.creatorCents = creatorCents; + } + + public long getPlatformCents() { + return platformCents; + } + + public void setPlatformCents(long platformCents) { + this.platformCents = platformCents; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public EntryStatus getStatus() { + return status; + } + + public void setStatus(EntryStatus status) { + this.status = status; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(LocalDateTime createdAt) { + this.createdAt = createdAt; + } + + public LocalDateTime getAvailableAt() { + return availableAt; + } + + public void setAvailableAt(LocalDateTime availableAt) { + this.availableAt = availableAt; + } + + public LocalDateTime getExpiresAt() { + return expiresAt; + } + + public void setExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; + } + + public LocalDateTime getCompletedAt() { + return completedAt; + } + + public void setCompletedAt(LocalDateTime completedAt) { + this.completedAt = completedAt; + } + + public String getStripeReference() { + return stripeReference; + } + + public void setStripeReference(String stripeReference) { + this.stripeReference = stripeReference; + } + + public String getExternalReference() { + return externalReference; + } + + public void setExternalReference(String externalReference) { + this.externalReference = externalReference; + } + + public boolean isRecurring() { + return recurring; + } + + public void setRecurring(boolean recurring) { + this.recurring = recurring; + } + + public Map getMetadata() { + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } +} diff --git a/backend/src/main/java/net/modtale/model/finance/PlatformFinanceSettings.java b/backend/src/main/java/net/modtale/model/finance/PlatformFinanceSettings.java new file mode 100644 index 00000000..5419c2b1 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/PlatformFinanceSettings.java @@ -0,0 +1,103 @@ +package net.modtale.model.finance; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document(collection = "platform_finance_settings") +public class PlatformFinanceSettings { + + @Id + private String id = "platform"; + + private int adCreatorSplitBps = 9000; + private int donationPlatformCutBps = 1000; + private int fundExpiryDays = 365; + private int defaultAdRevenuePerClickCents = 3; + private int minPayoutCents = 1000; + private boolean adTestModeEnabled = false; + private boolean mockStripeEnabled = false; + private String currency = "usd"; + private LocalDateTime updatedAt = LocalDateTime.now(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public int getAdCreatorSplitBps() { + return adCreatorSplitBps; + } + + public void setAdCreatorSplitBps(int adCreatorSplitBps) { + this.adCreatorSplitBps = Math.max(0, Math.min(10000, adCreatorSplitBps)); + } + + public int getDonationPlatformCutBps() { + return donationPlatformCutBps; + } + + public void setDonationPlatformCutBps(int donationPlatformCutBps) { + this.donationPlatformCutBps = Math.max(0, Math.min(10000, donationPlatformCutBps)); + } + + public int getFundExpiryDays() { + return fundExpiryDays; + } + + public void setFundExpiryDays(int fundExpiryDays) { + this.fundExpiryDays = Math.max(30, fundExpiryDays); + } + + public int getDefaultAdRevenuePerClickCents() { + return defaultAdRevenuePerClickCents; + } + + public void setDefaultAdRevenuePerClickCents(int defaultAdRevenuePerClickCents) { + this.defaultAdRevenuePerClickCents = Math.max(0, defaultAdRevenuePerClickCents); + } + + public int getMinPayoutCents() { + return minPayoutCents; + } + + public void setMinPayoutCents(int minPayoutCents) { + this.minPayoutCents = Math.max(100, minPayoutCents); + } + + public boolean isAdTestModeEnabled() { + return adTestModeEnabled; + } + + public void setAdTestModeEnabled(boolean adTestModeEnabled) { + this.adTestModeEnabled = adTestModeEnabled; + } + + public boolean isMockStripeEnabled() { + return mockStripeEnabled; + } + + public void setMockStripeEnabled(boolean mockStripeEnabled) { + this.mockStripeEnabled = mockStripeEnabled; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/net/modtale/model/project/Project.java b/backend/src/main/java/net/modtale/model/project/Project.java index 6a5998db..45120a31 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -173,6 +173,11 @@ public ProjectMember(String userId, String roleId) { private List modIds; private boolean allowModpacks = true; private boolean allowComments = true; + private boolean adsEnabled = true; + private boolean donationsEnabled = false; + private int suggestedDonationCents = 500; + private boolean donationRecurringDefault = false; + private int donationPlatformCutBps = 1000; private boolean hmWikiEnabled = false; private String hmWikiSlug; @@ -275,6 +280,16 @@ public Project() {} public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } public void setAllowComments(boolean allowComments) { this.allowComments = allowComments; } + public boolean isAdsEnabled() { return adsEnabled; } + public void setAdsEnabled(boolean adsEnabled) { this.adsEnabled = adsEnabled; } + public boolean isDonationsEnabled() { return donationsEnabled; } + public void setDonationsEnabled(boolean donationsEnabled) { this.donationsEnabled = donationsEnabled; } + public int getSuggestedDonationCents() { return suggestedDonationCents; } + public void setSuggestedDonationCents(int suggestedDonationCents) { this.suggestedDonationCents = suggestedDonationCents; } + public boolean isDonationRecurringDefault() { return donationRecurringDefault; } + public void setDonationRecurringDefault(boolean donationRecurringDefault) { this.donationRecurringDefault = donationRecurringDefault; } + public int getDonationPlatformCutBps() { return donationPlatformCutBps; } + public void setDonationPlatformCutBps(int donationPlatformCutBps) { this.donationPlatformCutBps = Math.max(0, Math.min(10000, donationPlatformCutBps)); } public boolean isHmWikiEnabled() { return hmWikiEnabled; } public void setHmWikiEnabled(boolean hmWikiEnabled) { this.hmWikiEnabled = hmWikiEnabled; } public String getHmWikiSlug() { return hmWikiSlug; } diff --git a/backend/src/main/java/net/modtale/model/user/User.java b/backend/src/main/java/net/modtale/model/user/User.java index a33c9eb5..cd600d40 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -80,6 +80,13 @@ public class User implements Serializable { private String gitlabRefreshToken; private LocalDateTime gitlabTokenExpiresAt; + private String stripeConnectAccountId; + private boolean stripeOnboardingComplete = false; + private boolean stripePayoutsEnabled = false; + private String stripeAccountCountry; + private OrgPayoutMode orgPayoutMode = OrgPayoutMode.DIRECT_TO_ORG_STRIPE; + private List orgPayoutShares = new ArrayList<>(); + public User() { this.id = UUID.randomUUID().toString(); this.tier = ApiKey.Tier.USER; @@ -105,6 +112,11 @@ public enum AccountType { USER, ORGANIZATION } + public enum OrgPayoutMode { + DIRECT_TO_ORG_STRIPE, + DISTRIBUTE_TO_MEMBERS + } + public static class OrganizationRole implements Serializable { private static final long serialVersionUID = 1L; private String id; @@ -204,6 +216,24 @@ public ConnectedAccount(OAuthProvider provider, String providerId, String userna public void setVisible(boolean visible) { this.visible = visible; } } + public static class OrgPayoutShare implements Serializable { + private static final long serialVersionUID = 1L; + private String userId; + private int percent; + + public OrgPayoutShare() {} + + public OrgPayoutShare(String userId, int percent) { + this.userId = userId; + this.percent = percent; + } + + public String getUserId() { return userId; } + public void setUserId(String userId) { this.userId = userId; } + public int getPercent() { return percent; } + public void setPercent(int percent) { this.percent = percent; } + } + public String getId() { return id; } public void setId(String id) { this.id = id; } @@ -295,4 +325,20 @@ public ConnectedAccount(OAuthProvider provider, String providerId, String userna public LocalDateTime getGitlabTokenExpiresAt() { return gitlabTokenExpiresAt; } public void setGitlabTokenExpiresAt(LocalDateTime gitlabTokenExpiresAt) { this.gitlabTokenExpiresAt = gitlabTokenExpiresAt; } + public String getStripeConnectAccountId() { return stripeConnectAccountId; } + public void setStripeConnectAccountId(String stripeConnectAccountId) { this.stripeConnectAccountId = stripeConnectAccountId; } + + public boolean isStripeOnboardingComplete() { return stripeOnboardingComplete; } + public void setStripeOnboardingComplete(boolean stripeOnboardingComplete) { this.stripeOnboardingComplete = stripeOnboardingComplete; } + + public boolean isStripePayoutsEnabled() { return stripePayoutsEnabled; } + public void setStripePayoutsEnabled(boolean stripePayoutsEnabled) { this.stripePayoutsEnabled = stripePayoutsEnabled; } + + public String getStripeAccountCountry() { return stripeAccountCountry; } + public void setStripeAccountCountry(String stripeAccountCountry) { this.stripeAccountCountry = stripeAccountCountry; } + + public OrgPayoutMode getOrgPayoutMode() { return orgPayoutMode == null ? OrgPayoutMode.DIRECT_TO_ORG_STRIPE : orgPayoutMode; } + public void setOrgPayoutMode(OrgPayoutMode orgPayoutMode) { this.orgPayoutMode = orgPayoutMode; } + public List getOrgPayoutShares() { return orgPayoutShares; } + public void setOrgPayoutShares(List orgPayoutShares) { this.orgPayoutShares = orgPayoutShares; } } diff --git a/backend/src/main/java/net/modtale/repository/finance/AdCampaignRepository.java b/backend/src/main/java/net/modtale/repository/finance/AdCampaignRepository.java new file mode 100644 index 00000000..97b2c135 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/finance/AdCampaignRepository.java @@ -0,0 +1,12 @@ +package net.modtale.repository.finance; + +import net.modtale.model.finance.AdCampaign; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.util.List; + +public interface AdCampaignRepository extends MongoRepository { + List findByActiveTrue(); + List findByActiveTrueAndTestCampaignFalse(); + List findByActiveTrueAndTestCampaignTrue(); +} diff --git a/backend/src/main/java/net/modtale/repository/finance/DonationIntentRepository.java b/backend/src/main/java/net/modtale/repository/finance/DonationIntentRepository.java new file mode 100644 index 00000000..852ac990 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/finance/DonationIntentRepository.java @@ -0,0 +1,13 @@ +package net.modtale.repository.finance; + +import net.modtale.model.finance.DonationIntent; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface DonationIntentRepository extends MongoRepository { + Optional findByStripeSessionId(String stripeSessionId); + List findByStatusAndExpiresAtBefore(DonationIntent.DonationStatus status, LocalDateTime cutoff); +} diff --git a/backend/src/main/java/net/modtale/repository/finance/FinanceLedgerEntryRepository.java b/backend/src/main/java/net/modtale/repository/finance/FinanceLedgerEntryRepository.java new file mode 100644 index 00000000..4c778805 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/finance/FinanceLedgerEntryRepository.java @@ -0,0 +1,15 @@ +package net.modtale.repository.finance; + +import net.modtale.model.finance.FinanceLedgerEntry; +import org.springframework.data.mongodb.repository.MongoRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface FinanceLedgerEntryRepository extends MongoRepository { + List findByCreatorId(String creatorId); + List findByCreatorIdAndStatus(String creatorId, FinanceLedgerEntry.EntryStatus status); + List findByCreatorIdAndStatusOrderByCreatedAtAsc(String creatorId, FinanceLedgerEntry.EntryStatus status); + List findByStatusAndExpiresAtBefore(FinanceLedgerEntry.EntryStatus status, LocalDateTime cutoff); + List findByCreatedAtBetween(LocalDateTime start, LocalDateTime end); +} diff --git a/backend/src/main/java/net/modtale/repository/finance/PlatformFinanceSettingsRepository.java b/backend/src/main/java/net/modtale/repository/finance/PlatformFinanceSettingsRepository.java new file mode 100644 index 00000000..d55c0482 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/finance/PlatformFinanceSettingsRepository.java @@ -0,0 +1,7 @@ +package net.modtale.repository.finance; + +import net.modtale.model.finance.PlatformFinanceSettings; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface PlatformFinanceSettingsRepository extends MongoRepository { +} diff --git a/backend/src/main/java/net/modtale/service/finance/AdCampaignService.java b/backend/src/main/java/net/modtale/service/finance/AdCampaignService.java new file mode 100644 index 00000000..bc817407 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/AdCampaignService.java @@ -0,0 +1,272 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.AdCampaign; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.model.project.Project; +import net.modtale.repository.finance.AdCampaignRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import net.modtale.service.project.query.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class AdCampaignService { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private AdCampaignRepository adCampaignRepository; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private ProjectService projectService; + @Autowired private RevenueOpsSupport core; + + @Value("${app.finance.ads.test-mode-enabled:false}") + private boolean defaultAdTestModeEnabled; + + public Map getAdSlotForProject(String projectId, String placementRaw) { + if (defaultAdTestModeEnabled) { + Map testSlot = resolveAdSlot(projectId, true, placementRaw); + Object enabled = testSlot.get("enabled"); + if (enabled instanceof Boolean b && b) { + return testSlot; + } + } + return resolveAdSlot(projectId, false, placementRaw); + } + + public Map getTestAdSlotForProject(String projectId, String placementRaw) { + if (!defaultAdTestModeEnabled) { + return Map.of("enabled", false, "reason", "TEST_MODE_DISABLED"); + } + return resolveAdSlot(projectId, true, placementRaw); + } + + public List> getAdCampaigns() { + return adCampaignRepository.findAll().stream() + .sorted(Comparator.comparing(AdCampaign::getUpdatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed()) + .map(core::toCampaignMap) + .collect(Collectors.toList()); + } + + public Map createAdCampaign(Map payload) { + AdCampaign campaign = new AdCampaign(); + applyCampaignPayload(campaign, payload, true); + campaign.setCreatedAt(LocalDateTime.now()); + campaign.setUpdatedAt(LocalDateTime.now()); + return core.toCampaignMap(adCampaignRepository.save(campaign)); + } + + public Map updateAdCampaign(String campaignId, Map payload) { + AdCampaign campaign = adCampaignRepository.findById(campaignId) + .orElseThrow(() -> new IllegalArgumentException("Ad campaign not found")); + applyCampaignPayload(campaign, payload, false); + campaign.setUpdatedAt(LocalDateTime.now()); + return core.toCampaignMap(adCampaignRepository.save(campaign)); + } + + public Map setCampaignActiveState(String campaignId, boolean active) { + AdCampaign campaign = adCampaignRepository.findById(campaignId) + .orElseThrow(() -> new IllegalArgumentException("Ad campaign not found")); + campaign.setActive(active); + campaign.setUpdatedAt(LocalDateTime.now()); + return core.toCampaignMap(adCampaignRepository.save(campaign)); + } + + public void trackAdImpression(String campaignId, String projectId, String clientIp) { + if (!core.shouldTrackEvent("impression", campaignId, projectId, clientIp)) return; + + FinanceLedgerEntry entry = new FinanceLedgerEntry(); + entry.setCreatorId(core.resolveCreatorId(projectId)); + entry.setProjectId(projectId); + entry.setType(FinanceLedgerEntry.LedgerType.AD_IMPRESSION); + entry.setGrossCents(0); + entry.setCreatorCents(0); + entry.setPlatformCents(0); + entry.setCurrency(financeAccountService.getSettings().getCurrency()); + entry.setStatus(FinanceLedgerEntry.EntryStatus.PAID); + entry.setCreatedAt(LocalDateTime.now()); + entry.setAvailableAt(LocalDateTime.now()); + entry.setCompletedAt(LocalDateTime.now()); + entry.getMetadata().put("campaignId", campaignId); + entry.getMetadata().put("tracked", "aggregate_only"); + ledgerRepository.save(entry); + } + + public String registerAdClickAndResolveUrl(String campaignId, String projectId, String clientIp) { + AdCampaign campaign = adCampaignRepository.findById(campaignId) + .orElseThrow(() -> new IllegalArgumentException("Ad campaign not found")); + + String targetUrl = core.appendAffiliateParams(campaign.getTargetUrl(), campaign.getAffiliateParam(), campaign.getAffiliateCode()); + + Project project = projectService.getProjectById(projectId); + if (project == null || !project.isAdsEnabled()) { + return targetUrl; + } + + if (!core.shouldTrackEvent("click", campaignId, projectId, clientIp)) { + return targetUrl; + } + + PlatformFinanceSettings settings = financeAccountService.getSettings(); + long gross = campaign.getBaseRevenuePerClickCents() > 0 + ? campaign.getBaseRevenuePerClickCents() + : settings.getDefaultAdRevenuePerClickCents(); + + long creatorCut = Math.round((gross * settings.getAdCreatorSplitBps()) / 10000.0); + long platformCut = gross - creatorCut; + + FinanceLedgerEntry entry = new FinanceLedgerEntry(); + entry.setCreatorId(project.getAuthorId()); + entry.setProjectId(project.getId()); + entry.setType(FinanceLedgerEntry.LedgerType.AD_CLICK); + entry.setGrossCents(gross); + entry.setCreatorCents(creatorCut); + entry.setPlatformCents(platformCut); + entry.setCurrency(settings.getCurrency()); + entry.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); + entry.setCreatedAt(LocalDateTime.now()); + entry.setAvailableAt(LocalDateTime.now()); + entry.setExpiresAt(LocalDateTime.now().plusDays(settings.getFundExpiryDays())); + entry.getMetadata().put("campaignId", campaign.getId()); + entry.getMetadata().put("providerType", campaign.getProviderType().name()); + entry.getMetadata().put("tracked", "aggregate_only"); + ledgerRepository.save(entry); + + return targetUrl; + } + + private Map resolveAdSlot(String projectId, boolean testOnly, String placementRaw) { + Project project = projectService.getProjectById(projectId); + if (project == null || !project.isAdsEnabled()) { + return Map.of( + "enabled", false, + "reason", project == null ? "PROJECT_NOT_FOUND" : "CREATOR_DISABLED" + ); + } + + List activeCampaigns = testOnly + ? adCampaignRepository.findByActiveTrueAndTestCampaignTrue() + : adCampaignRepository.findByActiveTrueAndTestCampaignFalse(); + + AdCampaign.AdPlacement placement = core.parsePlacement(placementRaw); + + List candidates = activeCampaigns.stream() + .filter(AdCampaign::isPrivacyRespecting) + .filter(AdCampaign::isNonIntrusive) + .filter(campaign -> campaign.getAllowedClassifications() == null + || campaign.getAllowedClassifications().isEmpty() + || (project.getClassification() != null && campaign.getAllowedClassifications().contains(project.getClassification().name()))) + .filter(campaign -> !testOnly || core.hasRenderableCreativeForPlacement(campaign, placement)) + .collect(Collectors.toList()); + + if (candidates.isEmpty()) { + return Map.of("enabled", false, "reason", "NO_ACTIVE_CAMPAIGNS"); + } + + AdCampaign chosen = core.weightedPick(candidates); + AdCampaign.AdCreative creative = core.chooseCreative(chosen, placement); + String imageUrl = creative != null && creative.getImageUrl() != null && !creative.getImageUrl().isBlank() + ? creative.getImageUrl() + : chosen.getImageUrl(); + + Map ad = new HashMap<>(); + ad.put("enabled", true); + ad.put("campaignId", chosen.getId()); + ad.put("providerType", chosen.getProviderType()); + ad.put("providerName", chosen.getProviderName()); + ad.put("sponsorName", chosen.getSponsorName()); + ad.put("headline", chosen.getHeadline()); + ad.put("body", chosen.getBody()); + ad.put("callToAction", chosen.getCallToAction()); + ad.put("imageUrl", imageUrl); + ad.put("placement", placement.name()); + ad.put("creativeAltText", creative != null ? creative.getAltText() : null); + ad.put("clickUrl", "/api/v1/finance/ads/click/" + chosen.getId() + "?projectId=" + project.getId()); + ad.put("testCampaign", chosen.isTestCampaign()); + ad.put("privacyLabel", "Privacy-respecting ad: no personal profile tracking."); + ad.put("creatorRevenueSharePercent", financeAccountService.getSettings().getAdCreatorSplitBps() / 100.0); + return ad; + } + + private void applyCampaignPayload(AdCampaign campaign, Map payload, boolean isCreate) { + if (payload == null) { + throw new IllegalArgumentException("Campaign payload is required."); + } + + String name = core.asString(payload.get("name")); + if (isCreate && (name == null || name.isBlank())) { + throw new IllegalArgumentException("Campaign name is required."); + } + if (name != null && !name.isBlank()) campaign.setName(name); + + String providerTypeRaw = core.asString(payload.get("providerType")); + if (providerTypeRaw != null && !providerTypeRaw.isBlank()) { + try { + campaign.setProviderType(AdCampaign.ProviderType.valueOf(providerTypeRaw)); + } catch (IllegalArgumentException ignored) { + throw new IllegalArgumentException("Invalid campaign provider type."); + } + } + + if (payload.containsKey("providerName")) campaign.setProviderName(core.asString(payload.get("providerName"))); + if (payload.containsKey("providerPlacementKey")) campaign.setProviderPlacementKey(core.asString(payload.get("providerPlacementKey"))); + if (payload.containsKey("sponsorName")) campaign.setSponsorName(core.asString(payload.get("sponsorName"))); + if (payload.containsKey("headline")) campaign.setHeadline(core.asString(payload.get("headline"))); + if (payload.containsKey("body")) campaign.setBody(core.asString(payload.get("body"))); + if (payload.containsKey("callToAction")) campaign.setCallToAction(core.asString(payload.get("callToAction"))); + if (payload.containsKey("imageUrl")) campaign.setImageUrl(core.asString(payload.get("imageUrl"))); + if (payload.containsKey("targetUrl")) campaign.setTargetUrl(core.asString(payload.get("targetUrl"))); + if (payload.containsKey("affiliateParam")) campaign.setAffiliateParam(core.asString(payload.get("affiliateParam"))); + if (payload.containsKey("affiliateCode")) campaign.setAffiliateCode(core.asString(payload.get("affiliateCode"))); + if (payload.containsKey("active")) campaign.setActive(core.asBoolean(payload.get("active"))); + if (payload.containsKey("privacyRespecting")) campaign.setPrivacyRespecting(core.asBoolean(payload.get("privacyRespecting"))); + if (payload.containsKey("nonIntrusive")) campaign.setNonIntrusive(core.asBoolean(payload.get("nonIntrusive"))); + if (payload.containsKey("testCampaign")) campaign.setTestCampaign(core.asBoolean(payload.get("testCampaign"))); + if (payload.containsKey("baseRevenuePerClickCents")) campaign.setBaseRevenuePerClickCents(Math.max(0, core.asInt(payload.get("baseRevenuePerClickCents"), campaign.getBaseRevenuePerClickCents()))); + if (payload.containsKey("weight")) campaign.setWeight(Math.max(1, core.asInt(payload.get("weight"), campaign.getWeight()))); + + if (payload.containsKey("allowedClassifications")) { + Object raw = payload.get("allowedClassifications"); + List classifications = new ArrayList<>(); + if (raw instanceof List list) { + for (Object value : list) { + if (value == null) continue; + String normalized = String.valueOf(value).trim().toUpperCase(); + if (!normalized.isBlank()) classifications.add(normalized); + } + } + campaign.setAllowedClassifications(classifications); + } + + if (payload.containsKey("creatives")) { + Object raw = payload.get("creatives"); + List creatives = new ArrayList<>(); + if (raw instanceof List list) { + for (Object entry : list) { + if (!(entry instanceof Map map)) continue; + String imageUrl = core.asString(map.get("imageUrl")); + if (imageUrl == null || imageUrl.isBlank()) continue; + AdCampaign.AdCreative creative = new AdCampaign.AdCreative(); + creative.setImageUrl(imageUrl); + creative.setAltText(core.asString(map.get("altText"))); + try { + String placementRaw = core.asString(map.get("placement")); + if (placementRaw != null && !placementRaw.isBlank()) { + creative.setPlacement(AdCampaign.AdPlacement.valueOf(placementRaw.trim().toUpperCase())); + } + } catch (Exception ignored) {} + creatives.add(creative); + } + } + campaign.setCreatives(creatives); + } + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/DonationCheckoutService.java b/backend/src/main/java/net/modtale/service/finance/DonationCheckoutService.java new file mode 100644 index 00000000..18af4bdc --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/DonationCheckoutService.java @@ -0,0 +1,168 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.DonationIntent; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.model.project.Project; +import net.modtale.model.user.User; +import net.modtale.repository.finance.DonationIntentRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import net.modtale.service.project.query.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Service +public class DonationCheckoutService { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private DonationIntentRepository donationIntentRepository; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private ProjectService projectService; + @Autowired private StripeGatewayService stripeGatewayService; + @Autowired private RevenueOpsSupport core; + + public Map getDonationConfig(String projectId) { + Project project = projectService.getProjectById(projectId); + if (project == null) { + throw new IllegalArgumentException("Project not found"); + } + + Map response = new HashMap<>(); + response.put("projectId", project.getId()); + response.put("donationsEnabled", project.isDonationsEnabled()); + response.put("suggestedDonationCents", Math.max(100, project.getSuggestedDonationCents())); + response.put("donationRecurringDefault", project.isDonationRecurringDefault()); + response.put("donationPlatformCutPercent", project.getDonationPlatformCutBps() / 100.0); + response.put("currency", financeAccountService.getSettings().getCurrency()); + response.put("minimumDonationCents", 100); + return response; + } + + public Map createDonationCheckout(String projectId, long amountCents, boolean recurring, User donor, boolean guestCheckout) { + Project project = projectService.getProjectById(projectId); + if (project == null) { + throw new IllegalArgumentException("Project not found"); + } + if (!project.isDonationsEnabled()) { + throw new IllegalStateException("Donations are disabled by this creator for this project."); + } + + PlatformFinanceSettings settings = financeAccountService.getSettings(); + long normalizedAmount = Math.max(100, Math.min(100000, amountCents)); + + long platformCut = Math.round((normalizedAmount * project.getDonationPlatformCutBps()) / 10000.0); + long creatorCut = normalizedAmount - platformCut; + + DonationIntent intent = new DonationIntent(); + intent.setProjectId(project.getId()); + intent.setCreatorId(project.getAuthorId()); + intent.setDonorUserId(donor != null ? donor.getId() : null); + intent.setGuestDonation(guestCheckout || donor == null); + intent.setAmountCents(normalizedAmount); + intent.setCreatorCents(creatorCut); + intent.setPlatformCents(platformCut); + intent.setRecurring(recurring); + intent.setCurrency(settings.getCurrency()); + intent.setStatus(DonationIntent.DonationStatus.PENDING); + intent = donationIntentRepository.save(intent); + + String projectPath = projectService.getProjectLink(project); + String successUrl = core.normalizeFrontendUrl() + projectPath + "?donation_intent=" + intent.getId() + "&donation_status=success"; + String cancelUrl = core.normalizeFrontendUrl() + projectPath + "?donation_intent=" + intent.getId() + "&donation_status=cancel"; + + StripeGatewayService.StripeResult session = stripeGatewayService.createOrSimulateDonationCheckout( + intent.getId(), + project.getTitle(), + normalizedAmount, + recurring, + successUrl, + cancelUrl, + settings.getCurrency(), + settings.isMockStripeEnabled() + ); + + if (!session.success()) { + intent.setStatus(DonationIntent.DonationStatus.FAILED); + donationIntentRepository.save(intent); + throw new IllegalStateException("Unable to create donation checkout: " + session.error()); + } + + intent.setStripeSessionId(session.id()); + intent.setCheckoutUrl(session.url()); + donationIntentRepository.save(intent); + + boolean simulated = Boolean.TRUE.equals(session.raw().get("simulated")); + if (simulated) { + completeDonationIntent(intent, Map.of("simulated", true, "status", "complete", "payment_status", "paid")); + } + + Map response = new HashMap<>(); + response.put("intentId", intent.getId()); + response.put("checkoutUrl", session.url()); + response.put("simulated", simulated); + response.put("creatorCents", creatorCut); + response.put("platformCents", platformCut); + return response; + } + + public Map confirmDonationIntent(String intentId) { + DonationIntent intent = donationIntentRepository.findById(intentId) + .orElseThrow(() -> new IllegalArgumentException("Donation intent not found")); + + if (intent.getStatus() == DonationIntent.DonationStatus.COMPLETED) { + return Map.of("ok", true, "status", "COMPLETED"); + } + + if (intent.getStatus() == DonationIntent.DonationStatus.FAILED || intent.getStatus() == DonationIntent.DonationStatus.EXPIRED) { + return Map.of("ok", false, "status", intent.getStatus().name()); + } + + Map session = stripeGatewayService.getCheckoutSession( + intent.getStripeSessionId(), + financeAccountService.getSettings().isMockStripeEnabled() + ); + String paymentStatus = session.get("payment_status") == null ? "" : String.valueOf(session.get("payment_status")); + String status = session.get("status") == null ? "" : String.valueOf(session.get("status")); + + if ("paid".equalsIgnoreCase(paymentStatus) || "complete".equalsIgnoreCase(status)) { + completeDonationIntent(intent, session); + return Map.of("ok", true, "status", "COMPLETED"); + } + + return Map.of("ok", false, "status", "PENDING"); + } + + private void completeDonationIntent(DonationIntent intent, Map sessionData) { + if (intent.getStatus() == DonationIntent.DonationStatus.COMPLETED) return; + + PlatformFinanceSettings settings = financeAccountService.getSettings(); + + intent.setStatus(DonationIntent.DonationStatus.COMPLETED); + intent.setCompletedAt(LocalDateTime.now()); + donationIntentRepository.save(intent); + + FinanceLedgerEntry entry = new FinanceLedgerEntry(); + entry.setCreatorId(intent.getCreatorId()); + entry.setProjectId(intent.getProjectId()); + entry.setType(FinanceLedgerEntry.LedgerType.DONATION); + entry.setGrossCents(intent.getAmountCents()); + entry.setCreatorCents(intent.getCreatorCents()); + entry.setPlatformCents(intent.getPlatformCents()); + entry.setCurrency(intent.getCurrency()); + entry.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); + entry.setCreatedAt(LocalDateTime.now()); + entry.setAvailableAt(LocalDateTime.now()); + entry.setExpiresAt(LocalDateTime.now().plusDays(settings.getFundExpiryDays())); + entry.setRecurring(intent.isRecurring()); + entry.setStripeReference(intent.getStripeSessionId()); + entry.setExternalReference(intent.getId()); + if (sessionData != null && sessionData.get("simulated") != null) { + entry.getMetadata().put("simulated", String.valueOf(sessionData.get("simulated"))); + } + ledgerRepository.save(entry); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/EarningsAccountService.java b/backend/src/main/java/net/modtale/service/finance/EarningsAccountService.java new file mode 100644 index 00000000..85d9fd6f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/EarningsAccountService.java @@ -0,0 +1,570 @@ +package net.modtale.service.finance; + +import net.modtale.model.dto.request.finance.UpdatePlatformFinanceSettingsRequest; +import net.modtale.model.dto.request.finance.UpdateProjectMonetizationRequest; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.model.project.Project; +import net.modtale.model.user.ApiKey; +import net.modtale.model.user.User; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import net.modtale.repository.finance.PlatformFinanceSettingsRepository; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +@Service +public class EarningsAccountService { + + @Autowired private PlatformFinanceSettingsRepository settingsRepository; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private UserRepository userRepository; + @Autowired private ProjectService projectService; + @Autowired private AccessControlService accessControlService; + @Autowired private StripeGatewayService stripeGatewayService; + @Autowired private RevenueOpsSupport core; + + @Value("${app.finance.ads.test-mode-enabled:false}") + private boolean defaultAdTestModeEnabled; + + public PlatformFinanceSettings getSettings() { + return settingsRepository.findById("platform").orElseGet(() -> { + PlatformFinanceSettings defaults = new PlatformFinanceSettings(); + defaults.setId("platform"); + defaults.setAdCreatorSplitBps(9000); + defaults.setFundExpiryDays(365); + defaults.setDonationPlatformCutBps(1000); + defaults.setDefaultAdRevenuePerClickCents(3); + defaults.setMinPayoutCents(1000); + defaults.setAdTestModeEnabled(defaultAdTestModeEnabled); + defaults.setCurrency("usd"); + defaults.setUpdatedAt(LocalDateTime.now()); + return settingsRepository.save(defaults); + }); + } + + public Map getCreatorOverview(User requester, String ownerId, String range) { + User creator = core.resolveFinanceOwner(requester, ownerId, false); + PlatformFinanceSettings settings = getSettings(); + List entries = ledgerRepository.findByCreatorId(creator.getId()); + + long available = entries.stream() + .filter(e -> e.getStatus() == FinanceLedgerEntry.EntryStatus.AVAILABLE) + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + long pending = entries.stream() + .filter(e -> e.getStatus() == FinanceLedgerEntry.EntryStatus.PENDING) + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + long paidOut = entries.stream() + .filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.PAYOUT) + .mapToLong(e -> Math.abs(e.getCreatorCents())) + .sum(); + + long expired = entries.stream() + .filter(e -> e.getStatus() == FinanceLedgerEntry.EntryStatus.EXPIRED) + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + LocalDateTime expiringSoonThreshold = LocalDateTime.now().plusDays(30); + long expiringSoon = entries.stream() + .filter(e -> e.getStatus() == FinanceLedgerEntry.EntryStatus.AVAILABLE) + .filter(e -> e.getExpiresAt() != null && e.getExpiresAt().isBefore(expiringSoonThreshold)) + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + int days = core.parseRangeDays(range); + LocalDate start = LocalDate.now().minusDays(days - 1); + LocalDate end = LocalDate.now(); + + Predicate inRange = e -> e.getCreatedAt() != null && !e.getCreatedAt().toLocalDate().isBefore(start) && !e.getCreatedAt().toLocalDate().isAfter(end); + + long periodAdRevenue = entries.stream() + .filter(inRange) + .filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.AD_CLICK || e.getType() == FinanceLedgerEntry.LedgerType.AD_IMPRESSION) + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + long periodDonationRevenue = entries.stream() + .filter(inRange) + .filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.DONATION) + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + List> earningsChart = core.buildDailySeries(entries, start, end, Set.of( + FinanceLedgerEntry.LedgerType.DONATION, + FinanceLedgerEntry.LedgerType.AD_CLICK, + FinanceLedgerEntry.LedgerType.AD_IMPRESSION + ), + FinanceLedgerEntry::getCreatorCents, + e -> e.getStatus() != FinanceLedgerEntry.EntryStatus.PENDING + ); + + List> donationsChart = core.buildDailySeries(entries, start, end, Set.of(FinanceLedgerEntry.LedgerType.DONATION), FinanceLedgerEntry::getCreatorCents, e -> true); + List> adsChart = core.buildDailySeries(entries, start, end, Set.of(FinanceLedgerEntry.LedgerType.AD_CLICK, FinanceLedgerEntry.LedgerType.AD_IMPRESSION), FinanceLedgerEntry::getCreatorCents, e -> true); + List> expiredChart = core.buildDailySeries(entries, start, end, Set.of(FinanceLedgerEntry.LedgerType.EXPIRED_TRANSFER), FinanceLedgerEntry::getPlatformCents, e -> true); + + Map revenueByProject = entries.stream() + .filter(e -> e.getProjectId() != null) + .collect(Collectors.groupingBy(FinanceLedgerEntry::getProjectId, Collectors.summingLong(FinanceLedgerEntry::getCreatorCents))); + + List> monetizationProjects = projectRepository.findByAuthorIdList(creator.getId()).stream() + .sorted(Comparator.comparing(Project::getUpdatedAt, Comparator.nullsLast(String::compareTo)).reversed()) + .map(project -> { + Map row = new HashMap<>(); + row.put("id", project.getId()); + row.put("title", project.getTitle()); + row.put("slug", project.getSlug()); + row.put("classification", project.getClassification()); + row.put("adsEnabled", project.isAdsEnabled()); + row.put("donationsEnabled", project.isDonationsEnabled()); + row.put("suggestedDonationCents", project.getSuggestedDonationCents()); + row.put("donationRecurringDefault", project.isDonationRecurringDefault()); + row.put("donationPlatformCutPercent", project.getDonationPlatformCutBps() / 100.0); + row.put("lifetimeRevenueCents", revenueByProject.getOrDefault(project.getId(), 0L)); + return row; + }) + .collect(Collectors.toList()); + + List> payouts = entries.stream() + .filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.PAYOUT) + .sorted(Comparator.comparing(FinanceLedgerEntry::getCreatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed()) + .limit(20) + .map(e -> { + Map item = new HashMap<>(); + item.put("id", e.getId()); + item.put("amountCents", Math.abs(e.getCreatorCents())); + item.put("createdAt", e.getCreatedAt()); + item.put("reference", e.getStripeReference()); + return item; + }) + .collect(Collectors.toList()); + + Map response = new HashMap<>(); + response.put("ownerId", creator.getId()); + response.put("ownerAccountType", creator.getAccountType().name()); + response.put("currency", settings.getCurrency()); + response.put("fundExpiryDays", settings.getFundExpiryDays()); + response.put("adCreatorSplitPercent", settings.getAdCreatorSplitBps() / 100.0); + response.put("defaultDonationPlatformCutPercent", settings.getDonationPlatformCutBps() / 100.0); + response.put("availableCents", Math.max(0, available)); + response.put("pendingCents", Math.max(0, pending)); + response.put("paidOutCents", Math.max(0, paidOut)); + response.put("expiredCents", Math.max(0, expired)); + response.put("expiringSoonCents", Math.max(0, expiringSoon)); + response.put("periodAdRevenueCents", periodAdRevenue); + response.put("periodDonationRevenueCents", periodDonationRevenue); + response.put("earningsChart", earningsChart); + response.put("adsChart", adsChart); + response.put("donationsChart", donationsChart); + response.put("expiredChart", expiredChart); + response.put("projects", monetizationProjects); + response.put("payouts", payouts); + response.put("stripeConnected", creator.getStripeConnectAccountId() != null && !creator.getStripeConnectAccountId().isBlank()); + response.put("stripeOnboardingComplete", creator.isStripeOnboardingComplete()); + response.put("stripePayoutsEnabled", creator.isStripePayoutsEnabled()); + response.put("minPayoutCents", settings.getMinPayoutCents()); + response.put("orgPayoutMode", creator.getOrgPayoutMode().name()); + response.put("orgPayoutShares", creator.getOrgPayoutShares()); + + return response; + } + + public Map getAdminOverview(String range) { + PlatformFinanceSettings settings = getSettings(); + int days = core.parseRangeDays(range); + LocalDateTime start = LocalDate.now().minusDays(days - 1).atStartOfDay(); + LocalDateTime end = LocalDateTime.now(); + + List entries = ledgerRepository.findByCreatedAtBetween(start, end); + + long periodPlatformRevenue = entries.stream().mapToLong(FinanceLedgerEntry::getPlatformCents).sum(); + long periodCreatorRevenue = entries.stream().mapToLong(FinanceLedgerEntry::getCreatorCents).sum(); + long periodPayouts = entries.stream() + .filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.PAYOUT) + .mapToLong(e -> Math.abs(e.getCreatorCents())) + .sum(); + + long periodExpiredReclaimed = entries.stream() + .filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.EXPIRED_TRANSFER) + .mapToLong(FinanceLedgerEntry::getPlatformCents) + .sum(); + + LocalDate chartStart = start.toLocalDate(); + LocalDate chartEnd = LocalDate.now(); + + List> platformRevenueChart = core.buildDailySeries(entries, chartStart, chartEnd, Set.of( + FinanceLedgerEntry.LedgerType.DONATION, + FinanceLedgerEntry.LedgerType.AD_CLICK, + FinanceLedgerEntry.LedgerType.EXPIRED_TRANSFER, + FinanceLedgerEntry.LedgerType.PLATFORM_CUT + ), + FinanceLedgerEntry::getPlatformCents, + e -> true + ); + + List> creatorRevenueChart = core.buildDailySeries(entries, chartStart, chartEnd, Set.of( + FinanceLedgerEntry.LedgerType.DONATION, + FinanceLedgerEntry.LedgerType.AD_CLICK, + FinanceLedgerEntry.LedgerType.AD_IMPRESSION + ), + FinanceLedgerEntry::getCreatorCents, + e -> true + ); + + Map creatorRevenueMap = entries.stream() + .filter(e -> e.getCreatorId() != null) + .collect(Collectors.groupingBy(FinanceLedgerEntry::getCreatorId, Collectors.summingLong(FinanceLedgerEntry::getCreatorCents))); + + List> topCreators = creatorRevenueMap.entrySet().stream() + .sorted((a, b) -> Long.compare(b.getValue(), a.getValue())) + .limit(25) + .map(entry -> { + User user = userRepository.findById(entry.getKey()).orElse(null); + Map item = new HashMap<>(); + item.put("creatorId", entry.getKey()); + item.put("username", user != null ? user.getUsername() : "unknown"); + item.put("revenueCents", entry.getValue()); + return item; + }) + .collect(Collectors.toList()); + + List allEntries = ledgerRepository.findAll(); + long totalCreatorAvailable = allEntries.stream() + .filter(e -> e.getStatus() == FinanceLedgerEntry.EntryStatus.AVAILABLE) + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + Map response = new HashMap<>(); + response.put("currency", settings.getCurrency()); + response.put("fundExpiryDays", settings.getFundExpiryDays()); + response.put("adCreatorSplitPercent", settings.getAdCreatorSplitBps() / 100.0); + response.put("donationPlatformCutPercent", settings.getDonationPlatformCutBps() / 100.0); + response.put("defaultAdRevenuePerClickCents", settings.getDefaultAdRevenuePerClickCents()); + response.put("minPayoutCents", settings.getMinPayoutCents()); + response.put("periodPlatformRevenueCents", periodPlatformRevenue); + response.put("periodCreatorRevenueCents", periodCreatorRevenue); + response.put("periodPayoutsCents", periodPayouts); + response.put("periodExpiredReclaimedCents", periodExpiredReclaimed); + response.put("totalCreatorAvailableCents", totalCreatorAvailable); + response.put("platformRevenueChart", platformRevenueChart); + response.put("creatorRevenueChart", creatorRevenueChart); + response.put("topCreators", topCreators); + return response; + } + + public Map updatePlatformSettings(UpdatePlatformFinanceSettingsRequest request) { + PlatformFinanceSettings settings = getSettings(); + if (request.getDefaultAdRevenuePerClickCents() != null) { + settings.setDefaultAdRevenuePerClickCents(request.getDefaultAdRevenuePerClickCents()); + } + if (request.getMinPayoutCents() != null) { + settings.setMinPayoutCents(request.getMinPayoutCents()); + } + settings.setUpdatedAt(LocalDateTime.now()); + settingsRepository.save(settings); + return Map.of( + "ok", true, + "settings", settings + ); + } + + public Map createStripeOnboardingLink(User requester, String ownerId, String returnPath) { + User creator = core.resolveFinanceOwner(requester, ownerId, true); + if (creator.getAccountType() == User.AccountType.ORGANIZATION) { + core.requireOrganizationOwner(requester, creator); + } + String accountId = creator.getStripeConnectAccountId(); + if (accountId == null || accountId.isBlank()) { + StripeGatewayService.StripeResult accountResult = stripeGatewayService.createOrSimulateConnectAccount( + creator.getEmail(), + "US", + getSettings().isMockStripeEnabled() + ); + if (!accountResult.success()) { + throw new IllegalStateException("Unable to initialize Stripe account: " + accountResult.error()); + } + accountId = accountResult.id(); + creator.setStripeConnectAccountId(accountId); + creator.setStripeAccountCountry("US"); + userRepository.save(creator); + } + + String safePath = (returnPath == null || returnPath.isBlank()) ? "/dashboard/finance" : returnPath; + StripeGatewayService.StripeResult linkResult = stripeGatewayService.createOrSimulateOnboardingLink( + accountId, + safePath, + getSettings().isMockStripeEnabled() + ); + if (!linkResult.success()) { + throw new IllegalStateException("Unable to create onboarding link: " + linkResult.error()); + } + + Map response = new HashMap<>(); + response.put("onboardingUrl", linkResult.url()); + response.put("simulated", Boolean.TRUE.equals(linkResult.raw().get("simulated"))); + response.put("accountId", accountId); + response.put("ownerId", creator.getId()); + return response; + } + + public Map refreshStripeStatus(User requester, String ownerId) { + User creator = core.resolveFinanceOwner(requester, ownerId, true); + if (creator.getStripeConnectAccountId() == null || creator.getStripeConnectAccountId().isBlank()) { + return Map.of( + "connected", false, + "onboardingComplete", false, + "payoutsEnabled", false + ); + } + + Map status = stripeGatewayService.getAccountStatus( + creator.getStripeConnectAccountId(), + getSettings().isMockStripeEnabled() + ); + + boolean detailsSubmitted = core.asBoolean(status.get("details_submitted")); + boolean payoutsEnabled = core.asBoolean(status.get("payouts_enabled")); + + creator.setStripeOnboardingComplete(detailsSubmitted); + creator.setStripePayoutsEnabled(payoutsEnabled); + if (status.get("country") != null) { + creator.setStripeAccountCountry(String.valueOf(status.get("country"))); + } + userRepository.save(creator); + + Map response = new HashMap<>(); + response.put("connected", true); + response.put("onboardingComplete", detailsSubmitted); + response.put("payoutsEnabled", payoutsEnabled); + response.put("ownerId", creator.getId()); + response.put("raw", status); + return response; + } + + public Map requestPayout(User requester, String ownerId, Long amountCentsInput) { + User creator = core.resolveFinanceOwner(requester, ownerId, true); + if (creator.getAccountType() == User.AccountType.ORGANIZATION) { + core.requireOrganizationOwner(requester, creator); + } + PlatformFinanceSettings settings = getSettings(); + + long availableBalance = ledgerRepository.findByCreatorIdAndStatus(creator.getId(), FinanceLedgerEntry.EntryStatus.AVAILABLE).stream() + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + long targetAmount = amountCentsInput == null ? availableBalance : amountCentsInput; + targetAmount = Math.min(targetAmount, availableBalance); + + if (targetAmount < settings.getMinPayoutCents()) { + throw new IllegalStateException("Minimum payout is " + settings.getMinPayoutCents() + " cents."); + } + + List payoutReferences = core.executePayoutTransfers( + creator, + targetAmount, + settings.getCurrency(), + settings.isMockStripeEnabled() + ); + + long remaining = targetAmount; + List eligibleEntries = ledgerRepository.findByCreatorIdAndStatusOrderByCreatedAtAsc(creator.getId(), FinanceLedgerEntry.EntryStatus.AVAILABLE); + List changed = new ArrayList<>(); + + for (FinanceLedgerEntry entry : eligibleEntries) { + if (remaining <= 0) break; + long value = entry.getCreatorCents(); + if (value <= 0) continue; + if (value <= remaining) { + entry.setStatus(FinanceLedgerEntry.EntryStatus.PAID); + entry.setCompletedAt(LocalDateTime.now()); + changed.add(entry); + remaining -= value; + } else { + entry.setCreatorCents(value - remaining); + changed.add(entry); + remaining = 0; + } + } + + if (!changed.isEmpty()) { + ledgerRepository.saveAll(changed); + } + + FinanceLedgerEntry payoutEntry = new FinanceLedgerEntry(); + payoutEntry.setCreatorId(creator.getId()); + payoutEntry.setType(FinanceLedgerEntry.LedgerType.PAYOUT); + payoutEntry.setGrossCents(targetAmount); + payoutEntry.setCreatorCents(-targetAmount); + payoutEntry.setPlatformCents(0); + payoutEntry.setCurrency(settings.getCurrency()); + payoutEntry.setStatus(FinanceLedgerEntry.EntryStatus.PAID); + payoutEntry.setCreatedAt(LocalDateTime.now()); + payoutEntry.setCompletedAt(LocalDateTime.now()); + payoutEntry.setStripeReference(String.join(",", payoutReferences)); + payoutEntry.getMetadata().put("payoutMode", creator.getOrgPayoutMode().name()); + payoutEntry.getMetadata().put("recipientCount", String.valueOf(payoutReferences.size())); + ledgerRepository.save(payoutEntry); + + return Map.of( + "ok", true, + "payoutReference", payoutEntry.getStripeReference(), + "payoutReferences", payoutReferences, + "ownerId", creator.getId(), + "amountCents", targetAmount, + "recipientCount", payoutReferences.size() + ); + } + + public Map updateProjectMonetization(User requester, Project project, UpdateProjectMonetizationRequest request) { + core.requireProjectMonetizationOwner(requester, project); + if (request.getAdsEnabled() != null) { + project.setAdsEnabled(request.getAdsEnabled()); + } + if (request.getDonationsEnabled() != null) { + project.setDonationsEnabled(request.getDonationsEnabled()); + } + if (request.getSuggestedDonationCents() != null) { + int clamped = Math.max(100, Math.min(100000, request.getSuggestedDonationCents())); + project.setSuggestedDonationCents(clamped); + } + if (request.getDonationRecurringDefault() != null) { + project.setDonationRecurringDefault(request.getDonationRecurringDefault()); + } + if (request.getDonationPlatformCutBps() != null) { + project.setDonationPlatformCutBps(request.getDonationPlatformCutBps()); + } + + projectRepository.save(project); + projectService.evictProjectCache(project); + + return Map.of( + "ok", true, + "projectId", project.getId(), + "adsEnabled", project.isAdsEnabled(), + "donationsEnabled", project.isDonationsEnabled(), + "suggestedDonationCents", project.getSuggestedDonationCents(), + "donationRecurringDefault", project.isDonationRecurringDefault(), + "donationPlatformCutBps", project.getDonationPlatformCutBps() + ); + } + + public List> getFinanceContexts(User requester) { + List> contexts = new ArrayList<>(); + Map personal = new LinkedHashMap<>(); + personal.put("id", requester.getId()); + personal.put("username", requester.getUsername()); + personal.put("accountType", requester.getAccountType().name()); + personal.put("isPersonal", true); + contexts.add(personal); + + for (User org : userRepository.findOrganizationsByMemberId(requester.getId())) { + if (org.getAccountType() != User.AccountType.ORGANIZATION) continue; + if (!accessControlService.hasOrgPermission(org, requester.getId(), ApiKey.ApiPermission.ORG_EDIT_METADATA)) continue; + Map row = new LinkedHashMap<>(); + row.put("id", org.getId()); + row.put("username", org.getUsername()); + row.put("accountType", org.getAccountType().name()); + row.put("isPersonal", false); + contexts.add(row); + } + return contexts; + } + + public Map getOrgPayoutPolicy(User requester, String orgId) { + User org = core.resolveFinanceOwner(requester, orgId, true); + if (org.getAccountType() != User.AccountType.ORGANIZATION) { + throw new IllegalArgumentException("Finance policy is only available for organizations."); + } + + List> shares = new ArrayList<>(); + for (User.OrgPayoutShare share : org.getOrgPayoutShares()) { + if (share.getUserId() == null || share.getUserId().isBlank()) continue; + User user = userRepository.findById(share.getUserId()).orElse(null); + Map item = new LinkedHashMap<>(); + item.put("userId", share.getUserId()); + item.put("percent", share.getPercent()); + item.put("username", user != null ? user.getUsername() : "unknown"); + item.put("stripeConnected", user != null && user.getStripeConnectAccountId() != null && !user.getStripeConnectAccountId().isBlank()); + item.put("stripePayoutsEnabled", user != null && user.isStripePayoutsEnabled()); + shares.add(item); + } + + List> members = new ArrayList<>(); + for (User.OrganizationMember member : org.getOrganizationMembers()) { + User user = userRepository.findById(member.getUserId()).orElse(null); + if (user == null) continue; + Map item = new LinkedHashMap<>(); + item.put("userId", user.getId()); + item.put("username", user.getUsername()); + item.put("stripeConnected", user.getStripeConnectAccountId() != null && !user.getStripeConnectAccountId().isBlank()); + item.put("stripePayoutsEnabled", user.isStripePayoutsEnabled()); + members.add(item); + } + + return Map.of( + "orgId", org.getId(), + "orgName", org.getUsername(), + "payoutMode", org.getOrgPayoutMode().name(), + "shares", shares, + "members", members + ); + } + + public Map updateOrgPayoutPolicy(User requester, String orgId, String payoutModeRaw, List> sharesRaw) { + User org = core.resolveFinanceOwner(requester, orgId, true); + core.requireOrganizationOwner(requester, org); + if (org.getAccountType() != User.AccountType.ORGANIZATION) { + throw new IllegalArgumentException("Finance policy is only available for organizations."); + } + + User.OrgPayoutMode mode; + try { + mode = User.OrgPayoutMode.valueOf(String.valueOf(payoutModeRaw)); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid payout mode."); + } + + List parsedShares = new ArrayList<>(); + if (sharesRaw != null) { + for (Map item : sharesRaw) { + if (item == null) continue; + String userId = item.get("userId") == null ? null : String.valueOf(item.get("userId")); + int percent = core.asInt(item.get("percent"), 0); + if (userId == null || userId.isBlank() || percent <= 0) continue; + parsedShares.add(new User.OrgPayoutShare(userId, percent)); + } + } + + if (mode == User.OrgPayoutMode.DISTRIBUTE_TO_MEMBERS) { + core.validateOrgPayoutShares(org, parsedShares); + } else { + parsedShares = new ArrayList<>(); + } + + org.setOrgPayoutMode(mode); + org.setOrgPayoutShares(parsedShares); + userRepository.save(org); + + return getOrgPayoutPolicy(requester, orgId); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/RevenueOpsSupport.java b/backend/src/main/java/net/modtale/service/finance/RevenueOpsSupport.java new file mode 100644 index 00000000..162ae35a --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/RevenueOpsSupport.java @@ -0,0 +1,417 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.AdCampaign; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.project.Project; +import net.modtale.model.user.ApiKey; +import net.modtale.model.user.User; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Predicate; +import java.util.function.ToLongFunction; +import java.util.stream.Collectors; + +@Component +public class RevenueOpsSupport { + + public static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Autowired UserRepository userRepository; + @Autowired ProjectRepository projectRepository; + @Autowired ProjectService projectService; + @Autowired AccessControlService accessControlService; + @Autowired StripeGatewayService stripeGatewayService; + + @Value("${app.frontend.url:http://localhost:5173}") + private String frontendUrl; + + private final Map adDebounce = new ConcurrentHashMap<>(); + + public List> buildDailySeries( + List source, + LocalDate start, + LocalDate end, + Set allowedTypes, + ToLongFunction mapper, + Predicate extraFilter + ) { + Map buckets = new HashMap<>(); + for (FinanceLedgerEntry entry : source) { + if (entry.getCreatedAt() == null) continue; + LocalDate day = entry.getCreatedAt().toLocalDate(); + if (day.isBefore(start) || day.isAfter(end)) continue; + if (!allowedTypes.contains(entry.getType())) continue; + if (!extraFilter.test(entry)) continue; + buckets.merge(day, mapper.applyAsLong(entry), Long::sum); + } + + List> response = new ArrayList<>(); + for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { + Map row = new HashMap<>(); + row.put("date", day.format(DATE_FMT)); + row.put("count", buckets.getOrDefault(day, 0L)); + response.add(row); + } + return response; + } + + public int parseRangeDays(String range) { + if ("7d".equalsIgnoreCase(range)) return 7; + if ("90d".equalsIgnoreCase(range)) return 90; + if ("1y".equalsIgnoreCase(range)) return 365; + return 30; + } + + public AdCampaign weightedPick(List campaigns) { + int total = campaigns.stream().mapToInt(c -> Math.max(1, c.getWeight())).sum(); + int roll = ThreadLocalRandom.current().nextInt(total); + + int cursor = 0; + for (AdCampaign campaign : campaigns) { + cursor += Math.max(1, campaign.getWeight()); + if (roll < cursor) { + return campaign; + } + } + return campaigns.get(0); + } + + public AdCampaign.AdPlacement parsePlacement(String placementRaw) { + if (placementRaw == null || placementRaw.isBlank()) return AdCampaign.AdPlacement.SIDEBAR_CARD; + try { + return AdCampaign.AdPlacement.valueOf(placementRaw.trim().toUpperCase()); + } catch (Exception ignored) { + return AdCampaign.AdPlacement.SIDEBAR_CARD; + } + } + + public AdCampaign.AdCreative chooseCreative(AdCampaign campaign, AdCampaign.AdPlacement placement) { + if (campaign.getCreatives() == null || campaign.getCreatives().isEmpty()) return null; + for (AdCampaign.AdCreative creative : campaign.getCreatives()) { + if (creative == null || creative.getImageUrl() == null || creative.getImageUrl().isBlank()) continue; + AdCampaign.AdPlacement creativePlacement = creative.getPlacement() == null + ? AdCampaign.AdPlacement.SIDEBAR_CARD + : creative.getPlacement(); + if (creativePlacement == placement) return creative; + } + for (AdCampaign.AdCreative creative : campaign.getCreatives()) { + if (creative != null && creative.getImageUrl() != null && !creative.getImageUrl().isBlank()) { + return creative; + } + } + return null; + } + + public boolean hasRenderableCreativeForPlacement(AdCampaign campaign, AdCampaign.AdPlacement placement) { + if (campaign == null || campaign.getCreatives() == null) return false; + for (AdCampaign.AdCreative creative : campaign.getCreatives()) { + if (creative == null) continue; + AdCampaign.AdPlacement creativePlacement = creative.getPlacement() == null + ? AdCampaign.AdPlacement.SIDEBAR_CARD + : creative.getPlacement(); + if (creativePlacement == placement && creative.getImageUrl() != null && !creative.getImageUrl().isBlank()) { + return true; + } + } + return false; + } + + public String appendAffiliateParams(String targetUrl, String param, String code) { + if (targetUrl == null || targetUrl.isBlank()) return normalizeFrontendUrl(); + if (code == null || code.isBlank()) return targetUrl; + String queryParam = (param == null || param.isBlank()) ? "ref" : param; + + try { + return UriComponentsBuilder.fromUriString(targetUrl) + .queryParam(queryParam, code) + .build(true) + .toUriString(); + } catch (Exception e) { + String separator = targetUrl.contains("?") ? "&" : "?"; + return targetUrl + separator + queryParam + "=" + code; + } + } + + public String resolveCreatorId(String projectId) { + if (projectId == null) return null; + Project project = projectService.getProjectById(projectId); + return project == null ? null : project.getAuthorId(); + } + + public boolean shouldTrackEvent(String type, String campaignId, String projectId, String clientIp) { + if (clientIp == null || clientIp.isBlank()) return true; + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime cutoff = now.minusMinutes(20); + adDebounce.entrySet().removeIf(entry -> entry.getValue().isBefore(cutoff)); + + String key = type + ":" + campaignId + ":" + projectId + ":" + clientIp; + LocalDateTime last = adDebounce.get(key); + if (last != null && last.isAfter(cutoff)) { + return false; + } + + adDebounce.put(key, now); + return true; + } + + public User resolveFinanceOwner(User requester, String ownerId, boolean requireOrgFinanceManage) { + if (requester == null) { + throw new SecurityException("Authentication required."); + } + + String targetId = (ownerId == null || ownerId.isBlank()) ? requester.getId() : ownerId; + if (requester.getId().equals(targetId)) { + return requester; + } + + User target = userRepository.findById(targetId) + .orElseThrow(() -> new IllegalArgumentException("Owner account not found.")); + + if (target.getAccountType() != User.AccountType.ORGANIZATION) { + throw new SecurityException("You can only manage your own personal finance account."); + } + + if (requireOrgFinanceManage && !accessControlService.hasOrgPermission(target, requester.getId(), ApiKey.ApiPermission.ORG_EDIT_METADATA)) { + throw new SecurityException("Missing organization permission for finance management."); + } + + if (!requireOrgFinanceManage && !accessControlService.hasOrgPermission(target, requester.getId(), ApiKey.ApiPermission.ORG_MEMBER_READ)) { + throw new SecurityException("Missing organization permission for finance access."); + } + + return target; + } + + public List executePayoutTransfers(User creator, long totalAmountCents, String currency, boolean forceMock) { + if (creator.getAccountType() == User.AccountType.ORGANIZATION + && creator.getOrgPayoutMode() == User.OrgPayoutMode.DISTRIBUTE_TO_MEMBERS) { + List shares = creator.getOrgPayoutShares() == null ? new ArrayList<>() : creator.getOrgPayoutShares(); + validateOrgPayoutShares(creator, shares); + List refs = new ArrayList<>(); + + long allocated = 0; + for (int i = 0; i < shares.size(); i++) { + User.OrgPayoutShare share = shares.get(i); + User member = userRepository.findById(share.getUserId()) + .orElseThrow(() -> new IllegalStateException("Organization member not found: " + share.getUserId())); + ensureStripePayoutReady(member); + + long amount = (i == shares.size() - 1) + ? (totalAmountCents - allocated) + : Math.round((totalAmountCents * share.getPercent()) / 100.0); + allocated += amount; + if (amount <= 0) continue; + + StripeGatewayService.StripeResult result = stripeGatewayService.createOrSimulateTransfer( + member.getStripeConnectAccountId(), + amount, + currency, + "Modtale organization payout (" + creator.getUsername() + ")", + Map.of( + "organizationId", creator.getId(), + "memberUserId", member.getId(), + "source", "modtale_finance" + ), + forceMock + ); + if (!result.success()) { + throw new IllegalStateException("Stripe payout failed for " + member.getUsername() + ": " + result.error()); + } + refs.add(result.id()); + } + + if (refs.isEmpty()) { + throw new IllegalStateException("No payout recipients were resolved from organization payout shares."); + } + + return refs; + } + + ensureStripePayoutReady(creator); + StripeGatewayService.StripeResult payoutResult = stripeGatewayService.createOrSimulateTransfer( + creator.getStripeConnectAccountId(), + totalAmountCents, + currency, + "Modtale creator payout", + Map.of("creatorId", creator.getId(), "source", "modtale_finance"), + forceMock + ); + + if (!payoutResult.success()) { + throw new IllegalStateException("Stripe payout failed: " + payoutResult.error()); + } + + return List.of(payoutResult.id()); + } + + public void ensureStripePayoutReady(User accountOwner) { + if (accountOwner.getStripeConnectAccountId() == null || accountOwner.getStripeConnectAccountId().isBlank()) { + throw new IllegalStateException("Connect Stripe first before requesting payouts."); + } + if (stripeGatewayService.isEnabled() && !accountOwner.isStripePayoutsEnabled()) { + throw new IllegalStateException("Stripe onboarding is incomplete for " + accountOwner.getUsername() + ". Refresh Stripe status after onboarding."); + } + } + + public void validateOrgPayoutShares(User org, List shares) { + if (shares == null || shares.isEmpty()) { + throw new IllegalArgumentException("At least one payout share is required for distributed payouts."); + } + + Set memberIds = org.getOrganizationMembers().stream() + .map(User.OrganizationMember::getUserId) + .collect(Collectors.toSet()); + + int totalPercent = 0; + for (User.OrgPayoutShare share : shares) { + if (share.getUserId() == null || share.getUserId().isBlank()) { + throw new IllegalArgumentException("All payout shares must include a userId."); + } + if (!memberIds.contains(share.getUserId())) { + throw new IllegalArgumentException("Payout share includes a non-member user."); + } + if (share.getPercent() <= 0) { + throw new IllegalArgumentException("Payout share percentages must be greater than zero."); + } + totalPercent += share.getPercent(); + } + + if (totalPercent != 100) { + throw new IllegalArgumentException("Organization payout shares must total exactly 100%."); + } + } + + public void requireProjectMonetizationOwner(User requester, Project project) { + if (requester == null) { + throw new SecurityException("Authentication required."); + } + if (accessControlService.isSuperAdmin(requester)) { + return; + } + if (project == null || project.getAuthorId() == null || project.getAuthorId().isBlank()) { + throw new SecurityException("Project ownership could not be verified."); + } + + User owner = userRepository.findById(project.getAuthorId()).orElse(null); + if (owner == null) { + throw new SecurityException("Project owner was not found."); + } + + if (owner.getAccountType() == User.AccountType.ORGANIZATION) { + requireOrganizationOwner(requester, owner); + return; + } + + if (!owner.getId().equals(requester.getId())) { + throw new SecurityException("Only the project owner can update monetization policies."); + } + } + + public void requireOrganizationOwner(User requester, User organization) { + if (requester == null || organization == null) { + throw new SecurityException("Organization ownership could not be verified."); + } + if (accessControlService.isSuperAdmin(requester)) { + return; + } + if (organization.getAccountType() != User.AccountType.ORGANIZATION) { + throw new SecurityException("Target account is not an organization."); + } + + User.OrganizationMember requesterMembership = organization.getOrganizationMembers().stream() + .filter(member -> requester.getId().equals(member.getUserId())) + .findFirst() + .orElse(null); + if (requesterMembership == null) { + throw new SecurityException("Only organization owners can update monetization policies."); + } + + if (requesterMembership.getRoleId() != null) { + User.OrganizationRole role = organization.getOrganizationRoles().stream() + .filter(r -> requesterMembership.getRoleId().equals(r.getId())) + .findFirst() + .orElse(null); + if (role != null && role.isOwner()) { + return; + } + } + + String legacyRole = requesterMembership.getRole(); + if (legacyRole != null && "OWNER".equalsIgnoreCase(legacyRole)) { + return; + } + + throw new SecurityException("Only organization owners can update monetization policies."); + } + + public Map toCampaignMap(AdCampaign campaign) { + Map item = new LinkedHashMap<>(); + item.put("id", campaign.getId()); + item.put("name", campaign.getName()); + item.put("active", campaign.isActive()); + item.put("testCampaign", campaign.isTestCampaign()); + item.put("providerType", campaign.getProviderType()); + item.put("providerName", campaign.getProviderName()); + item.put("providerPlacementKey", campaign.getProviderPlacementKey()); + item.put("sponsorName", campaign.getSponsorName()); + item.put("headline", campaign.getHeadline()); + item.put("body", campaign.getBody()); + item.put("callToAction", campaign.getCallToAction()); + item.put("imageUrl", campaign.getImageUrl()); + item.put("creatives", campaign.getCreatives()); + item.put("targetUrl", campaign.getTargetUrl()); + item.put("affiliateParam", campaign.getAffiliateParam()); + item.put("affiliateCode", campaign.getAffiliateCode()); + item.put("baseRevenuePerClickCents", campaign.getBaseRevenuePerClickCents()); + item.put("weight", campaign.getWeight()); + item.put("privacyRespecting", campaign.isPrivacyRespecting()); + item.put("nonIntrusive", campaign.isNonIntrusive()); + item.put("allowedClassifications", campaign.getAllowedClassifications()); + item.put("createdAt", campaign.getCreatedAt()); + item.put("updatedAt", campaign.getUpdatedAt()); + return item; + } + + public String asString(Object value) { + return value == null ? null : String.valueOf(value); + } + + public int asInt(Object value, int fallback) { + if (value == null) return fallback; + try { + return Integer.parseInt(String.valueOf(value)); + } catch (Exception e) { + return fallback; + } + } + + public boolean asBoolean(Object value) { + if (value instanceof Boolean b) return b; + if (value == null) return false; + return Boolean.parseBoolean(String.valueOf(value)); + } + + public String normalizeFrontendUrl() { + if (frontendUrl == null || frontendUrl.isBlank()) return "http://localhost:5173"; + return frontendUrl.endsWith("/") ? frontendUrl.substring(0, frontendUrl.length() - 1) : frontendUrl; + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/RevenueReportingService.java b/backend/src/main/java/net/modtale/service/finance/RevenueReportingService.java new file mode 100644 index 00000000..1c0c97e3 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/RevenueReportingService.java @@ -0,0 +1,102 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.DonationIntent; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.repository.finance.DonationIntentRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class RevenueReportingService { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private DonationIntentRepository donationIntentRepository; + @Autowired private RevenueOpsSupport core; + + public List> getPublicDailyRevenue(int days) { + int safeDays = Math.max(1, Math.min(365, days)); + LocalDate start = LocalDate.now().minusDays(safeDays - 1); + LocalDateTime startAt = start.atStartOfDay(); + LocalDateTime endAt = LocalDateTime.now(); + + List entries = ledgerRepository.findByCreatedAtBetween(startAt, endAt); + Map buckets = new HashMap<>(); + + for (FinanceLedgerEntry entry : entries) { + if (entry.getCreatedAt() == null) continue; + LocalDate date = entry.getCreatedAt().toLocalDate(); + long[] sums = buckets.computeIfAbsent(date, key -> new long[3]); + sums[0] += Math.max(0, entry.getGrossCents()); + sums[1] += Math.max(0, entry.getCreatorCents()); + sums[2] += Math.max(0, entry.getPlatformCents()); + } + + List> response = new ArrayList<>(); + for (LocalDate day = start; !day.isAfter(LocalDate.now()); day = day.plusDays(1)) { + long[] sums = buckets.getOrDefault(day, new long[3]); + Map item = new HashMap<>(); + item.put("date", day.format(RevenueOpsSupport.DATE_FMT)); + item.put("grossCents", sums[0]); + item.put("creatorCents", sums[1]); + item.put("platformCents", sums[2]); + response.add(item); + } + + return response; + } + + @Scheduled(cron = "0 30 0 * * *") + public void expireCreatorFunds() { + PlatformFinanceSettings settings = financeAccountService.getSettings(); + LocalDateTime now = LocalDateTime.now(); + + List expiringEntries = ledgerRepository.findByStatusAndExpiresAtBefore(FinanceLedgerEntry.EntryStatus.AVAILABLE, now); + if (expiringEntries.isEmpty()) return; + + List updates = new ArrayList<>(); + List transferEntries = new ArrayList<>(); + + for (FinanceLedgerEntry entry : expiringEntries) { + if (entry.getCreatorCents() <= 0) continue; + + entry.setStatus(FinanceLedgerEntry.EntryStatus.EXPIRED); + entry.setCompletedAt(now); + updates.add(entry); + + FinanceLedgerEntry transfer = new FinanceLedgerEntry(); + transfer.setType(FinanceLedgerEntry.LedgerType.EXPIRED_TRANSFER); + transfer.setProjectId(entry.getProjectId()); + transfer.setGrossCents(entry.getCreatorCents()); + transfer.setCreatorCents(0); + transfer.setPlatformCents(entry.getCreatorCents()); + transfer.setCurrency(settings.getCurrency()); + transfer.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); + transfer.setCreatedAt(now); + transfer.setAvailableAt(now); + transfer.setExpiresAt(now.plusYears(50)); + transfer.setExternalReference(entry.getId()); + transfer.getMetadata().put("reason", "creator_funds_expired_after_365_days"); + transferEntries.add(transfer); + } + + if (!updates.isEmpty()) ledgerRepository.saveAll(updates); + if (!transferEntries.isEmpty()) ledgerRepository.saveAll(transferEntries); + + donationIntentRepository.findByStatusAndExpiresAtBefore(DonationIntent.DonationStatus.PENDING, now) + .forEach(intent -> { + intent.setStatus(DonationIntent.DonationStatus.EXPIRED); + donationIntentRepository.save(intent); + }); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/StripeGatewayService.java b/backend/src/main/java/net/modtale/service/finance/StripeGatewayService.java new file mode 100644 index 00000000..0bc4f666 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/StripeGatewayService.java @@ -0,0 +1,225 @@ +package net.modtale.service.finance; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Service +public class StripeGatewayService { + + public record StripeResult(boolean success, String id, String url, String error, Map raw) { + } + + private final WebClient webClient; + + @Value("${app.finance.stripe.secret-key:}") + private String stripeSecretKey; + + @Value("${app.frontend.url:http://localhost:5173}") + private String frontendUrl; + + public StripeGatewayService() { + this.webClient = WebClient.builder() + .baseUrl("https://api.stripe.com/v1") + .build(); + } + + public boolean isEnabled() { + return stripeSecretKey != null && !stripeSecretKey.isBlank(); + } + + public StripeResult createOrSimulateConnectAccount(String email, String country, boolean forceMock) { + if (forceMock) { + return new StripeResult(true, "sim_acct_" + System.currentTimeMillis(), null, null, Map.of("simulated", true)); + } + if (!isEnabled()) { + return new StripeResult(false, null, null, "Stripe secret key is not configured.", Map.of()); + } + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("type", "express"); + if (email != null && !email.isBlank()) form.add("email", email); + if (country != null && !country.isBlank()) form.add("country", country.toUpperCase()); + + return postForm("/accounts", form); + } + + public StripeResult createOrSimulateOnboardingLink(String accountId, String returnPath, boolean forceMock) { + if (forceMock) { + String url = normalizeFrontendUrl() + (returnPath.startsWith("/") ? returnPath : "/" + returnPath); + return new StripeResult(true, "sim_link_" + System.currentTimeMillis(), url, null, Map.of("simulated", true)); + } + if (!isEnabled()) { + return new StripeResult(false, null, null, "Stripe secret key is not configured.", Map.of()); + } + + String returnUrl = normalizeFrontendUrl() + (returnPath.startsWith("/") ? returnPath : "/" + returnPath); + String refreshUrl = normalizeFrontendUrl() + "/dashboard/finance?stripe=refresh"; + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("account", accountId); + form.add("refresh_url", refreshUrl); + form.add("return_url", returnUrl); + form.add("type", "account_onboarding"); + + StripeResult result = postForm("/account_links", form); + if (!result.success()) return result; + return new StripeResult(true, result.id(), (String) result.raw().get("url"), null, result.raw()); + } + + public Map getAccountStatus(String accountId, boolean forceMock) { + if (forceMock) { + return Map.of( + "details_submitted", true, + "charges_enabled", true, + "payouts_enabled", true, + "country", "US", + "simulated", true + ); + } + if (!isEnabled()) { + return Map.of("error", "Stripe secret key is not configured."); + } + + try { + Map result = webClient.get() + .uri("/accounts/{id}", accountId) + .headers(headers -> headers.setBasicAuth(stripeSecretKey, "")) + .retrieve() + .bodyToMono(Map.class) + .block(); + return result == null ? Map.of() : result; + } catch (Exception e) { + return Map.of("error", e.getMessage()); + } + } + + public StripeResult createOrSimulateDonationCheckout( + String intentId, + String projectTitle, + long amountCents, + boolean recurring, + String successUrl, + String cancelUrl, + String currency, + boolean forceMock + ) { + if (forceMock) { + return new StripeResult(true, "sim_cs_" + System.currentTimeMillis(), normalizeFrontendUrl() + "/dashboard/finance?donation=intent-" + intentId, null, Map.of("simulated", true)); + } + if (!isEnabled()) { + return new StripeResult(false, null, null, "Stripe secret key is not configured.", Map.of()); + } + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("mode", recurring ? "subscription" : "payment"); + form.add("success_url", successUrl); + form.add("cancel_url", cancelUrl); + form.add("line_items[0][price_data][currency]", currency); + form.add("line_items[0][price_data][product_data][name]", "Support " + projectTitle + " on Modtale"); + form.add("line_items[0][price_data][unit_amount]", String.valueOf(amountCents)); + if (recurring) { + form.add("line_items[0][price_data][recurring][interval]", "month"); + } + form.add("line_items[0][quantity]", "1"); + form.add("metadata[intentId]", intentId); + form.add("metadata[project]", projectTitle); + form.add("metadata[source]", "modtale_donation"); + + StripeResult result = postForm("/checkout/sessions", form); + if (!result.success()) return result; + return new StripeResult(true, result.id(), (String) result.raw().get("url"), null, result.raw()); + } + + public Map getCheckoutSession(String sessionId, boolean forceMock) { + if (forceMock) { + return Map.of("id", sessionId, "status", "open", "payment_status", "unpaid", "simulated", true); + } + if (!isEnabled()) { + return Map.of("error", "Stripe secret key is not configured."); + } + + try { + Map result = webClient.get() + .uri("/checkout/sessions/{id}", sessionId) + .headers(headers -> headers.setBasicAuth(stripeSecretKey, "")) + .retrieve() + .bodyToMono(Map.class) + .block(); + return result == null ? Map.of() : result; + } catch (Exception e) { + return Map.of("error", e.getMessage()); + } + } + + public StripeResult createOrSimulateTransfer(String destinationAccountId, long amountCents, String currency, String description, Map metadata, boolean forceMock) { + if (forceMock) { + return new StripeResult(true, "sim_tr_" + System.currentTimeMillis(), null, null, Map.of( + "simulated", true, + "createdAt", LocalDateTime.now().toString(), + "destination", destinationAccountId, + "amount", amountCents + )); + } + if (!isEnabled()) { + return new StripeResult(false, null, null, "Stripe secret key is not configured.", Map.of()); + } + + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("amount", String.valueOf(amountCents)); + form.add("currency", currency); + form.add("destination", destinationAccountId); + if (description != null && !description.isBlank()) { + form.add("description", description); + } + if (metadata != null) { + metadata.forEach((k, v) -> { + if (k != null && v != null) { + form.add("metadata[" + k + "]", v); + } + }); + } + + return postForm("/transfers", form); + } + + private StripeResult postForm(String path, MultiValueMap form) { + try { + Map result = webClient.post() + .uri(path) + .headers(headers -> headers.setBasicAuth(stripeSecretKey, "")) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(form)) + .retrieve() + .bodyToMono(Map.class) + .block(); + + if (result == null) { + return new StripeResult(false, null, null, "No response from Stripe", Map.of()); + } + + String id = valueAsString(result.get("id")); + String url = valueAsString(result.get("url")); + return new StripeResult(true, id, url, null, result); + } catch (Exception e) { + return new StripeResult(false, null, null, e.getMessage(), Map.of("error", e.getMessage())); + } + } + + private String valueAsString(Object value) { + return value == null ? null : String.valueOf(value); + } + + private String normalizeFrontendUrl() { + if (frontendUrl == null || frontendUrl.isBlank()) return "http://localhost:5173"; + return frontendUrl.endsWith("/") ? frontendUrl.substring(0, frontendUrl.length() - 1) : frontendUrl; + } +} diff --git a/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java b/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java index b134f68a..cd564387 100644 --- a/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java +++ b/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java @@ -100,6 +100,12 @@ else if (!newSlug.equals(existing.getSlug())) { existing.setTypes(updated.getTypes()); existing.setAllowModpacks(updated.isAllowModpacks()); existing.setAllowComments(updated.isAllowComments()); + existing.setAdsEnabled(updated.isAdsEnabled()); + existing.setDonationsEnabled(updated.isDonationsEnabled()); + if (updated.getSuggestedDonationCents() > 0) { + existing.setSuggestedDonationCents(updated.getSuggestedDonationCents()); + } + existing.setDonationRecurringDefault(updated.isDonationRecurringDefault()); existing.setHmWikiEnabled(updated.isHmWikiEnabled()); existing.setHmWikiSlug(updated.getHmWikiSlug() != null ? updated.getHmWikiSlug().trim() : null); existing.setGalleryCarouselEnabled(updated.isGalleryCarouselEnabled()); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 4375e958..4286dce4 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -141,6 +141,12 @@ app.limits.modpack-gen-per-hour=10 app.limits.reports-per-day=10 app.limits.rescans-per-day=5 +app.finance.stripe.secret-key=${STRIPE_SECRET_KEY:} +app.finance.stripe.publishable-key=${STRIPE_PUBLISHABLE_KEY:} +app.finance.stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET:} +app.finance.stripe.mock-enabled=${STRIPE_MOCK_ENABLED:false} +app.finance.ads.test-mode-enabled=${AD_TEST_ENABLED:false} + app.security.auto-approve-delay-minutes-min=${SECURITY_AUTO_APPROVE_DELAY_MINUTES_MIN:2} app.security.auto-approve-delay-minutes-max=${SECURITY_AUTO_APPROVE_DELAY_MINUTES_MAX:12} app.security.known-risk-delay-minutes-min=${SECURITY_KNOWN_RISK_DELAY_MINUTES_MIN:15} diff --git a/frontend/cloudbuild.yml b/frontend/cloudbuild.yml index 0a589510..f01cab53 100644 --- a/frontend/cloudbuild.yml +++ b/frontend/cloudbuild.yml @@ -20,4 +20,3 @@ substitutions: options: defaultLogsBucketBehavior: REGIONAL_USER_OWNED_BUCKET - \ No newline at end of file diff --git a/frontend/src/modules/admin/components/FinanceAdmin.tsx b/frontend/src/modules/admin/components/FinanceAdmin.tsx new file mode 100644 index 00000000..3c3130c1 --- /dev/null +++ b/frontend/src/modules/admin/components/FinanceAdmin.tsx @@ -0,0 +1,452 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { Building2, Check, Coins, HandCoins, Play, Save, Square, TestTube2 } from 'lucide-react'; +import { financeClient } from '@/modules/finance/api/financeClient'; +import { LineChart } from '@/components/ui/charts/LineChart'; +import { StatusModal } from '@/components/ui/StatusModal'; +import { theme } from '@/styles/theme'; + +const inputNoNativeUi = `${theme.components.inputField} appearance-none [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none`; + +const Card = ({ title, value, icon: Icon, color }: any) => ( +
+
+
+ +
+
{title}
+
+
{value}
+
+); + +interface FinanceAdminProps { + isSuperAdmin: boolean; +} + +export function FinanceAdmin({ isSuperAdmin }: FinanceAdminProps) { + const defaultCreatives = [ + { placement: 'SIDEBAR_CARD', imageUrl: '' }, + { placement: 'WIDE_BANNER', imageUrl: '' }, + { placement: 'TALL_BANNER', imageUrl: '' } + ]; + const [range, setRange] = useState('30d'); + const [loading, setLoading] = useState(true); + const [data, setData] = useState(null); + const [status, setStatus] = useState<{ type: 'success' | 'error' | 'warning' | 'info'; title: string; msg: string } | null>(null); + const [campaigns, setCampaigns] = useState([]); + const [testProjectId, setTestProjectId] = useState(''); + const [testPlacement, setTestPlacement] = useState<'SIDEBAR_CARD' | 'WIDE_BANNER' | 'TALL_BANNER'>('SIDEBAR_CARD'); + const [testAdResult, setTestAdResult] = useState(null); + + const [settings, setSettings] = useState({ + defaultAdRevenuePerClickCents: 3, + minPayoutCents: 1000 + }); + + const [draftCampaign, setDraftCampaign] = useState({ + id: '', + name: '', + sponsorName: '', + headline: '', + body: '', + callToAction: 'Learn more', + targetUrl: '', + imageUrl: '', + creatives: defaultCreatives, + affiliateCode: '', + baseRevenuePerClickCents: 3, + weight: 100, + testCampaign: false, + active: true + }); + + const currency = (data?.currency || 'usd').toUpperCase(); + + const formatMoney = (cents: number) => new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currency.length === 3 ? currency : 'USD' + }).format((cents || 0) / 100); + + const loadCampaigns = async () => { + if (!isSuperAdmin) return; + try { + const rows = await financeClient.getAdCampaigns(); + setCampaigns(rows || []); + } catch (e: any) { + setStatus({ type: 'error', title: 'Campaign Load Failed', msg: e?.response?.data || 'Could not load ad campaigns.' }); + } + }; + + const load = async (selectedRange = range) => { + setLoading(true); + try { + const overview = await financeClient.getAdminOverview(selectedRange); + setData(overview); + setSettings({ + defaultAdRevenuePerClickCents: Number(overview?.defaultAdRevenuePerClickCents || 3), + minPayoutCents: Number(overview?.minPayoutCents || 1000) + }); + await loadCampaigns(); + } catch (e: any) { + setStatus({ type: 'error', title: 'Load Failed', msg: e?.response?.data || 'Could not load finance admin data.' }); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + load(range); + }, [range]); + + const chartDatasets = useMemo(() => { + const mapSeries = (series: any[]) => (series || []).map(point => ({ date: point.date, value: Number(point.count || 0) })); + return { + platform: [{ id: 'platform', label: 'Platform Revenue', color: '#2563eb', data: mapSeries(data?.platformRevenueChart) }], + creator: [{ id: 'creator', label: 'Creator Revenue', color: '#16a34a', data: mapSeries(data?.creatorRevenueChart) }] + }; + }, [data]); + + const saveSettings = async () => { + try { + const payload = { + defaultAdRevenuePerClickCents: Math.max(0, Math.round(settings.defaultAdRevenuePerClickCents)), + minPayoutCents: Math.max(100, Math.round(settings.minPayoutCents)) + }; + await financeClient.updateAdminSettings(payload); + setStatus({ type: 'success', title: 'Settings Saved', msg: 'Platform finance settings updated.' }); + await load(range); + } catch (e: any) { + setStatus({ type: 'error', title: 'Save Failed', msg: e?.response?.data || 'Could not save finance settings.' }); + } + }; + + const populateDraft = (campaign: any) => { + const incomingCreatives = Array.isArray(campaign.creatives) ? campaign.creatives : []; + const mergedCreatives = defaultCreatives.map((base) => { + const found = incomingCreatives.find((c: any) => c?.placement === base.placement); + return { placement: base.placement, imageUrl: found?.imageUrl || '' }; + }); + setDraftCampaign({ + id: campaign.id || '', + name: campaign.name || '', + sponsorName: campaign.sponsorName || '', + headline: campaign.headline || '', + body: campaign.body || '', + callToAction: campaign.callToAction || 'Learn more', + targetUrl: campaign.targetUrl || '', + imageUrl: campaign.imageUrl || '', + creatives: mergedCreatives, + affiliateCode: campaign.affiliateCode || '', + baseRevenuePerClickCents: Number(campaign.baseRevenuePerClickCents || 0), + weight: Number(campaign.weight || 100), + testCampaign: !!campaign.testCampaign, + active: !!campaign.active + }); + }; + + const resetDraft = () => { + setDraftCampaign({ + id: '', + name: '', + sponsorName: '', + headline: '', + body: '', + callToAction: 'Learn more', + targetUrl: '', + imageUrl: '', + creatives: defaultCreatives, + affiliateCode: '', + baseRevenuePerClickCents: 3, + weight: 100, + testCampaign: false, + active: true + }); + }; + + const saveCampaign = async () => { + try { + const payload = { + name: draftCampaign.name, + sponsorName: draftCampaign.sponsorName, + headline: draftCampaign.headline, + body: draftCampaign.body, + callToAction: draftCampaign.callToAction, + targetUrl: draftCampaign.targetUrl, + imageUrl: draftCampaign.imageUrl, + creatives: (draftCampaign as any).creatives, + affiliateCode: draftCampaign.affiliateCode, + baseRevenuePerClickCents: Math.max(0, Math.round(draftCampaign.baseRevenuePerClickCents)), + weight: Math.max(1, Math.round(draftCampaign.weight)), + testCampaign: draftCampaign.testCampaign, + active: draftCampaign.active, + providerType: 'CUSTOM_AFFILIATE', + privacyRespecting: true, + nonIntrusive: true + }; + + if (draftCampaign.id) { + await financeClient.updateAdCampaign(draftCampaign.id, payload); + setStatus({ type: 'success', title: 'Campaign Updated', msg: 'Ad campaign updated successfully.' }); + } else { + await financeClient.createAdCampaign(payload); + setStatus({ type: 'success', title: 'Campaign Created', msg: 'Ad campaign started successfully.' }); + } + resetDraft(); + await loadCampaigns(); + } catch (e: any) { + setStatus({ type: 'error', title: 'Campaign Save Failed', msg: e?.response?.data || 'Could not save campaign.' }); + } + }; + + const toggleCampaignState = async (campaignId: string, active: boolean) => { + try { + if (active) await financeClient.startAdCampaign(campaignId); + else await financeClient.pauseAdCampaign(campaignId); + await loadCampaigns(); + } catch (e: any) { + setStatus({ type: 'error', title: 'Campaign Update Failed', msg: e?.response?.data || 'Could not update campaign state.' }); + } + }; + + const runTestAd = async () => { + if (!testProjectId.trim()) { + setStatus({ type: 'warning', title: 'Project Required', msg: 'Enter a project id to run a test ad campaign.' }); + return; + } + + try { + const res = await financeClient.getTestAdSlot(testProjectId.trim(), testPlacement); + setTestAdResult(res); + setStatus({ type: 'info', title: 'Test Ad Loaded', msg: res?.enabled ? 'Test ad slot resolved.' : `No test ad returned (${res?.reason || 'unknown reason'}).` }); + } catch (e: any) { + setStatus({ type: 'error', title: 'Test Ad Failed', msg: e?.response?.data || 'Could not run test ad slot.' }); + } + }; + + if (loading) return
Loading finance administration...
; + + return ( +
+ {status && setStatus(null)} />} + +
+

Platform Finance

+

Revenue attribution, creator payouts, and monetization controls.

+
+ +
+ {['30d', '90d', '1y'].map(option => ( + + ))} +
+ +
+ + + + +
+ +
+
+

Platform Daily Revenue

+
+
+
+

Creator Daily Revenue

+
+
+
+ +
+

Platform Settings

+
+ + + +
+ + +
+ + {isSuperAdmin ? ( + <> +
+

Start Ad Campaign

+

Configure campaign content and delivery. Delivery weight controls how often this campaign is selected relative to other active campaigns.

+ +
+ setDraftCampaign(prev => ({ ...prev, name: e.target.value }))} placeholder="Campaign name" className={theme.components.inputField} /> + setDraftCampaign(prev => ({ ...prev, sponsorName: e.target.value }))} placeholder="Sponsor" className={theme.components.inputField} /> + setDraftCampaign(prev => ({ ...prev, headline: e.target.value }))} placeholder="Headline" className={theme.components.inputField} /> + setDraftCampaign(prev => ({ ...prev, callToAction: e.target.value }))} placeholder="Call to action" className={theme.components.inputField} /> + setDraftCampaign(prev => ({ ...prev, targetUrl: e.target.value }))} placeholder="Target URL" className={theme.components.inputField} /> + setDraftCampaign(prev => ({ ...prev, affiliateCode: e.target.value }))} placeholder="Affiliate code" className={theme.components.inputField} /> + setDraftCampaign(prev => ({ ...prev, baseRevenuePerClickCents: Number(e.target.value) }))} placeholder="Revenue per click (cents)" className={inputNoNativeUi} /> +
+ +
+
Creative Images By Placement
+ {(draftCampaign as any).creatives.map((creative: any, idx: number) => ( +
+
+ {creative.placement === 'SIDEBAR_CARD' ? 'Sidebar Card' : creative.placement === 'WIDE_BANNER' ? 'Wide Banner' : 'Tall Banner'} +
+ setDraftCampaign(prev => { + const next = [...(prev as any).creatives]; + next[idx] = { ...next[idx], imageUrl: e.target.value }; + return { ...prev, creatives: next }; + })} + placeholder="Image URL" + className={theme.components.inputField} + /> +
+ ))} +

Use different assets for each placement type. Empty fields fall back to the campaign legacy image.

+
+ +
+
+ Delivery Weight + {draftCampaign.weight} +
+ setDraftCampaign(prev => ({ ...prev, weight: Number(e.target.value) }))} + className={inputNoNativeUi} + /> +

Higher values make this campaign appear more often compared with others.

+
+ + + +