From 9346da421c81285f92da35b3f328152151b0e0e7 Mon Sep 17 00:00:00 2001 From: Villagers654 <110007851+Villagers654@users.noreply.github.com> Date: Fri, 22 May 2026 20:44:57 -0400 Subject: [PATCH 1/8] Initial work on monetization --- .../config/security/SecurityConfig.java | 13 +- .../controller/finance/FinanceController.java | 356 +++++ .../controller/project/ProjectController.java | 15 +- .../net/modtale/mapper/ProjectMapper.java | 5 + .../modtale/model/dto/project/ProjectDTO.java | 15 + .../UpdatePlatformFinanceSettingsRequest.java | 23 + .../UpdateProjectMonetizationRequest.java | 49 + .../request/project/UpdateProjectRequest.java | 12 + .../net/modtale/model/finance/AdCampaign.java | 275 ++++ .../modtale/model/finance/DonationIntent.java | 161 ++ .../model/finance/FinanceLedgerEntry.java | 204 +++ .../finance/PlatformFinanceSettings.java | 94 ++ .../net/modtale/model/project/Project.java | 17 +- .../java/net/modtale/model/user/User.java | 49 +- .../finance/AdCampaignRepository.java | 12 + .../finance/DonationIntentRepository.java | 13 + .../finance/FinanceLedgerEntryRepository.java | 15 + .../PlatformFinanceSettingsRepository.java | 7 + .../service/finance/FinanceService.java | 1390 +++++++++++++++++ .../service/finance/StripeGatewayService.java | 206 +++ .../service/project/MetadataService.java | 6 + .../src/main/resources/application.properties | 7 +- .../modules/admin/components/FinanceAdmin.tsx | 452 ++++++ .../src/modules/admin/views/AdminPanel.tsx | 22 +- .../components/BrowseAdPlacement.tsx | 62 + .../src/modules/discovery/views/Browse.tsx | 9 + .../src/modules/finance/api/financeClient.ts | 49 + .../modules/project/components/Sidebar.tsx | 9 +- .../components/ads/SponsoredAdCard.tsx | 62 + .../dialogs/DonationPromptModal.tsx | 103 ++ frontend/src/modules/project/tabs/Files.tsx | 4 +- .../modules/project/views/ProjectDetails.tsx | 131 +- frontend/src/modules/user/views/Dashboard.tsx | 7 +- .../src/modules/user/views/FinanceManager.tsx | 478 ++++++ frontend/src/types.ts | 12 + frontend/src/utils/routes.ts | 3 +- 36 files changed, 4321 insertions(+), 26 deletions(-) create mode 100644 backend/src/main/java/net/modtale/controller/finance/FinanceController.java create mode 100644 backend/src/main/java/net/modtale/model/dto/request/finance/UpdatePlatformFinanceSettingsRequest.java create mode 100644 backend/src/main/java/net/modtale/model/dto/request/finance/UpdateProjectMonetizationRequest.java create mode 100644 backend/src/main/java/net/modtale/model/finance/AdCampaign.java create mode 100644 backend/src/main/java/net/modtale/model/finance/DonationIntent.java create mode 100644 backend/src/main/java/net/modtale/model/finance/FinanceLedgerEntry.java create mode 100644 backend/src/main/java/net/modtale/model/finance/PlatformFinanceSettings.java create mode 100644 backend/src/main/java/net/modtale/repository/finance/AdCampaignRepository.java create mode 100644 backend/src/main/java/net/modtale/repository/finance/DonationIntentRepository.java create mode 100644 backend/src/main/java/net/modtale/repository/finance/FinanceLedgerEntryRepository.java create mode 100644 backend/src/main/java/net/modtale/repository/finance/PlatformFinanceSettingsRepository.java create mode 100644 backend/src/main/java/net/modtale/service/finance/FinanceService.java create mode 100644 backend/src/main/java/net/modtale/service/finance/StripeGatewayService.java create mode 100644 frontend/src/modules/admin/components/FinanceAdmin.tsx create mode 100644 frontend/src/modules/discovery/components/BrowseAdPlacement.tsx create mode 100644 frontend/src/modules/finance/api/financeClient.ts create mode 100644 frontend/src/modules/project/components/ads/SponsoredAdCard.tsx create mode 100644 frontend/src/modules/project/components/dialogs/DonationPromptModal.tsx create mode 100644 frontend/src/modules/user/views/FinanceManager.tsx 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 85e92508..185f00d1 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -241,11 +241,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/view/**").access((authentication, context) -> { boolean isApiKeyUser = authentication.get().getAuthorities().stream() @@ -438,4 +445,4 @@ public AuthenticationFailureHandler oauthFailureHandler() { response.sendRedirect((cleanUrl != null ? cleanUrl : "") + "/?oauth_error=" + errorParam); }; } -} \ No newline at end of file +} diff --git a/backend/src/main/java/net/modtale/controller/finance/FinanceController.java b/backend/src/main/java/net/modtale/controller/finance/FinanceController.java new file mode 100644 index 00000000..edc5ddb1 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/FinanceController.java @@ -0,0 +1,356 @@ +package net.modtale.controller.finance; + +import jakarta.servlet.http.HttpServletRequest; +import net.modtale.model.dto.request.finance.UpdatePlatformFinanceSettingsRequest; +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.FinanceService; +import net.modtale.service.project.ProjectService; +import net.modtale.service.security.AccessControlService; +import net.modtale.service.user.AccountService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.CacheControl; +import org.springframework.http.HttpHeaders; +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.net.URI; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@RestController +@RequestMapping("/api/v1/finance") +public class FinanceController { + + @Autowired private FinanceService financeService; + @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(financeService.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(financeService.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(financeService.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(financeService.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(financeService.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(financeService.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(financeService.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(financeService.updateProjectMonetization(user, project, request)); + } + + @GetMapping("/projects/{projectId}/donation-config") + public ResponseEntity getDonationConfig(@PathVariable String projectId) { + try { + return ResponseEntity.ok(financeService.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 + ) { + try { + return ResponseEntity.ok(financeService.createDonationCheckout(projectId, amountCents, recurring)); + } catch (IllegalStateException | IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/donations/confirm") + public ResponseEntity confirmDonation(@RequestParam String intentId) { + try { + return ResponseEntity.ok(financeService.confirmDonationIntent(intentId)); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @GetMapping("/ads/slot/{projectId}") + public ResponseEntity getAdSlot( + @PathVariable String projectId, + @RequestParam(required = false) String placement + ) { + return ResponseEntity.ok(financeService.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); + financeService.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 = financeService.registerAdClickAndResolveUrl(campaignId, projectId, ip); + + HttpHeaders headers = new HttpHeaders(); + headers.setLocation(URI.create(url)); + return new ResponseEntity<>(headers, HttpStatus.FOUND); + } + + @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(financeService.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(financeService.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(financeService.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(financeService.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(financeService.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(financeService.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(financeService.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(financeService.getTestAdSlotForProject(projectId, placement)); + } + + @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(); + + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(secondsToMidnight, TimeUnit.SECONDS).cachePublic()) + .body(Map.of( + "currency", financeService.getSettings().getCurrency(), + "days", Math.max(1, Math.min(365, days)), + "data", financeService.getPublicDailyRevenue(days) + )); + } + + @GetMapping("/public/settings") + public ResponseEntity getPublicMonetizationSettings() { + return ResponseEntity.ok(Map.of( + "adCreatorSplitPercent", financeService.getSettings().getAdCreatorSplitBps() / 100.0, + "donationPlatformCutPercent", financeService.getSettings().getDonationPlatformCutBps() / 100.0, + "fundExpiryDays", financeService.getSettings().getFundExpiryDays() + )); + } + + private String getClientIp(HttpServletRequest request) { + String xfHeader = request.getHeader("X-Forwarded-For"); + return xfHeader == null ? request.getRemoteAddr() : xfHeader.split(",")[0]; + } + + private boolean isSuperAdmin(User user) { + return accessControlService.isSuperAdmin(user); + } +} diff --git a/backend/src/main/java/net/modtale/controller/project/ProjectController.java b/backend/src/main/java/net/modtale/controller/project/ProjectController.java index e3dc2482..21db9705 100644 --- a/backend/src/main/java/net/modtale/controller/project/ProjectController.java +++ b/backend/src/main/java/net/modtale/controller/project/ProjectController.java @@ -147,6 +147,9 @@ public ResponseEntity updateProject(@PathVariable String id, @RequestBody Upd User user = accountService.getCurrentUser(); if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); try { + Project existingProject = projectService.getRawProjectById(id); + if (existingProject == null) return ResponseEntity.notFound().build(); + Project updated = new Project(); updated.setTitle(requestPayload.getTitle()); updated.setSlug(requestPayload.getSlug()); @@ -156,10 +159,14 @@ public ResponseEntity updateProject(@PathVariable String id, @RequestBody Upd updated.setLinks(requestPayload.getLinks()); updated.setRepositoryUrl(requestPayload.getRepositoryUrl()); updated.setLicense(requestPayload.getLicense()); - if (requestPayload.getAllowModpacks() != null) updated.setAllowModpacks(requestPayload.getAllowModpacks()); - if (requestPayload.getAllowComments() != null) updated.setAllowComments(requestPayload.getAllowComments()); - if (requestPayload.getHmWikiEnabled() != null) updated.setHmWikiEnabled(requestPayload.getHmWikiEnabled()); - updated.setHmWikiSlug(requestPayload.getHmWikiSlug()); + updated.setAllowModpacks(requestPayload.getAllowModpacks() != null ? requestPayload.getAllowModpacks() : existingProject.isAllowModpacks()); + updated.setAllowComments(requestPayload.getAllowComments() != null ? requestPayload.getAllowComments() : existingProject.isAllowComments()); + updated.setAdsEnabled(requestPayload.getAdsEnabled() != null ? requestPayload.getAdsEnabled() : existingProject.isAdsEnabled()); + updated.setDonationsEnabled(requestPayload.getDonationsEnabled() != null ? requestPayload.getDonationsEnabled() : existingProject.isDonationsEnabled()); + updated.setSuggestedDonationCents(requestPayload.getSuggestedDonationCents() != null ? requestPayload.getSuggestedDonationCents() : existingProject.getSuggestedDonationCents()); + updated.setDonationRecurringDefault(requestPayload.getDonationRecurringDefault() != null ? requestPayload.getDonationRecurringDefault() : existingProject.isDonationRecurringDefault()); + updated.setHmWikiEnabled(requestPayload.getHmWikiEnabled() != null ? requestPayload.getHmWikiEnabled() : existingProject.isHmWikiEnabled()); + updated.setHmWikiSlug(requestPayload.getHmWikiSlug() != null ? requestPayload.getHmWikiSlug() : existingProject.getHmWikiSlug()); if (updated.getDescription() != null && updated.getDescription().length() > 250) return ResponseEntity.badRequest().body("Short Summary cannot exceed 250 characters."); if (updated.getAbout() != null && updated.getAbout().length() > 50000) return ResponseEntity.badRequest().body("Full Description cannot exceed 50,000 characters."); diff --git a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java index 5e5ce241..d66568ff 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -133,6 +133,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.setStatus(project.getStatus()); 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 41bb0131..df5f2323 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 ProjectStatus status; @@ -111,6 +116,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 23c13804..91383b39 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 @@ -14,6 +14,10 @@ public class UpdateProjectRequest { private String license; private Boolean allowModpacks; private Boolean allowComments; + private Boolean adsEnabled; + private Boolean donationsEnabled; + private Integer suggestedDonationCents; + private Boolean donationRecurringDefault; private Boolean hmWikiEnabled; private String hmWikiSlug; @@ -37,6 +41,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 String getHmWikiSlug() { return hmWikiSlug; } 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..97a4c5cc --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/DonationIntent.java @@ -0,0 +1,161 @@ +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 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 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..64bfd31c --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/PlatformFinanceSettings.java @@ -0,0 +1,94 @@ +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 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 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 2f1b0fb3..c4f8ed7c 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -127,6 +127,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; @@ -220,6 +225,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; } @@ -259,4 +274,4 @@ public Project() {} public void setCanEdit(boolean canEdit) { this.canEdit = canEdit; } public boolean isOwner() { return isOwner; } public void setIsOwner(boolean isOwner) { this.isOwner = isOwner; } -} \ No newline at end of file +} 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 6d3e62af..c780df7c 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -79,6 +79,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.tier = ApiKey.Tier.USER; this.createdAt = LocalDate.now().toString(); @@ -103,6 +110,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; @@ -202,6 +214,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; } @@ -293,4 +323,21 @@ public ConnectedAccount(OAuthProvider provider, String providerId, String userna public LocalDateTime getGitlabTokenExpiresAt() { return gitlabTokenExpiresAt; } public void setGitlabTokenExpiresAt(LocalDateTime gitlabTokenExpiresAt) { this.gitlabTokenExpiresAt = gitlabTokenExpiresAt; } -} \ No newline at end of file + + 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/FinanceService.java b/backend/src/main/java/net/modtale/service/finance/FinanceService.java new file mode 100644 index 00000000..92d4bfd0 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/FinanceService.java @@ -0,0 +1,1390 @@ +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.AdCampaign; +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.ApiKey; +import net.modtale.model.user.User; +import net.modtale.repository.finance.AdCampaignRepository; +import net.modtale.repository.finance.DonationIntentRepository; +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.ProjectService; +import net.modtale.service.security.AccessControlService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +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.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +@Service +public class FinanceService { + + private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Autowired private PlatformFinanceSettingsRepository settingsRepository; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private DonationIntentRepository donationIntentRepository; + @Autowired private AdCampaignRepository adCampaignRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private UserRepository userRepository; + @Autowired private ProjectService projectService; + @Autowired private AccessControlService accessControlService; + @Autowired private StripeGatewayService stripeGatewayService; + + @Value("${app.frontend.url:http://localhost:5173}") + private String frontendUrl; + + @Value("${app.finance.ads.test-mode-enabled:false}") + private boolean defaultAdTestModeEnabled; + + private final Map adDebounce = new ConcurrentHashMap<>(); + + 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 = 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 = 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 = 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 = buildDailySeries(entries, start, end, Set.of(FinanceLedgerEntry.LedgerType.DONATION), FinanceLedgerEntry::getCreatorCents, e -> true); + List> adsChart = buildDailySeries(entries, start, end, Set.of(FinanceLedgerEntry.LedgerType.AD_CLICK, FinanceLedgerEntry.LedgerType.AD_IMPRESSION), FinanceLedgerEntry::getCreatorCents, e -> true); + List> expiredChart = 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 = 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 = 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 = 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 = resolveFinanceOwner(requester, ownerId, true); + if (creator.getAccountType() == User.AccountType.ORGANIZATION) { + requireOrganizationOwner(requester, creator); + } + String accountId = creator.getStripeConnectAccountId(); + if (accountId == null || accountId.isBlank()) { + StripeGatewayService.StripeResult accountResult = stripeGatewayService.createOrSimulateConnectAccount(creator.getEmail(), "US"); + 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); + 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 = 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()); + + boolean detailsSubmitted = asBoolean(status.get("details_submitted")); + boolean payoutsEnabled = 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 = resolveFinanceOwner(requester, ownerId, true); + if (creator.getAccountType() == User.AccountType.ORGANIZATION) { + 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 = executePayoutTransfers(creator, targetAmount, settings); + + 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) { + 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 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", getSettings().getCurrency()); + response.put("minimumDonationCents", 100); + return response; + } + + public Map createDonationCheckout(String projectId, long amountCents, boolean recurring) { + 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 = 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.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 = normalizeFrontendUrl() + projectPath + "?donation_intent=" + intent.getId() + "&donation_status=success"; + String cancelUrl = normalizeFrontendUrl() + projectPath + "?donation_intent=" + intent.getId() + "&donation_status=cancel"; + + StripeGatewayService.StripeResult session = stripeGatewayService.createOrSimulateDonationCheckout( + intent.getId(), + project.getTitle(), + normalizedAmount, + recurring, + successUrl, + cancelUrl, + settings.getCurrency() + ); + + 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()); + 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 = 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); + } + + 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> 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 = 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 = resolveFinanceOwner(requester, orgId, true); + 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 = 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) { + validateOrgPayoutShares(org, parsedShares); + } else { + parsedShares = new ArrayList<>(); + } + + org.setOrgPayoutMode(mode); + org.setOrgPayoutShares(parsedShares); + userRepository.save(org); + + return getOrgPayoutPolicy(requester, orgId); + } + + public List> getAdCampaigns() { + return adCampaignRepository.findAll().stream() + .sorted(Comparator.comparing(AdCampaign::getUpdatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed()) + .map(this::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 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 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 toCampaignMap(adCampaignRepository.save(campaign)); + } + + 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 = 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 || hasRenderableCreativeForPlacement(campaign, placement)) + .collect(Collectors.toList()); + + if (candidates.isEmpty()) { + return Map.of("enabled", false, "reason", "NO_ACTIVE_CAMPAIGNS"); + } + + AdCampaign chosen = weightedPick(candidates); + AdCampaign.AdCreative creative = 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", getSettings().getAdCreatorSplitBps() / 100.0); + return ad; + } + + public void trackAdImpression(String campaignId, String projectId, String clientIp) { + if (!shouldTrackEvent("impression", campaignId, projectId, clientIp)) return; + + FinanceLedgerEntry entry = new FinanceLedgerEntry(); + entry.setCreatorId(resolveCreatorId(projectId)); + entry.setProjectId(projectId); + entry.setType(FinanceLedgerEntry.LedgerType.AD_IMPRESSION); + entry.setGrossCents(0); + entry.setCreatorCents(0); + entry.setPlatformCents(0); + entry.setCurrency(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 = appendAffiliateParams(campaign.getTargetUrl(), campaign.getAffiliateParam(), campaign.getAffiliateCode()); + + Project project = projectService.getProjectById(projectId); + if (project == null || !project.isAdsEnabled()) { + return targetUrl; + } + + if (!shouldTrackEvent("click", campaignId, projectId, clientIp)) { + return targetUrl; + } + + PlatformFinanceSettings settings = 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; + } + + 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(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 = 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); + }); + } + + private List> buildDailySeries( + List source, + LocalDate start, + LocalDate end, + Set allowedTypes, + java.util.function.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; + } + + private int parseRangeDays(String range) { + if ("7d".equalsIgnoreCase(range)) return 7; + if ("90d".equalsIgnoreCase(range)) return 90; + if ("1y".equalsIgnoreCase(range)) return 365; + return 30; + } + + private 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); + } + + private 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; + } + } + + private 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; + } + + private 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; + } + + private 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; + } + } + + private String resolveCreatorId(String projectId) { + if (projectId == null) return null; + Project project = projectService.getProjectById(projectId); + return project == null ? null : project.getAuthorId(); + } + + private 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; + } + + private 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; + } + + private List executePayoutTransfers(User creator, long totalAmountCents, PlatformFinanceSettings settings) { + 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, + settings.getCurrency(), + "Modtale organization payout (" + creator.getUsername() + ")", + Map.of( + "organizationId", creator.getId(), + "memberUserId", member.getId(), + "source", "modtale_finance" + ) + ); + 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, + settings.getCurrency(), + "Modtale creator payout", + Map.of("creatorId", creator.getId(), "source", "modtale_finance") + ); + + if (!payoutResult.success()) { + throw new IllegalStateException("Stripe payout failed: " + payoutResult.error()); + } + + return List.of(payoutResult.id()); + } + + private 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."); + } + } + + private 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%."); + } + } + + private 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."); + } + } + + private 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."); + } + + private void applyCampaignPayload(AdCampaign campaign, Map payload, boolean isCreate) { + if (payload == null) { + throw new IllegalArgumentException("Campaign payload is required."); + } + + String name = 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 = 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(asString(payload.get("providerName"))); + if (payload.containsKey("providerPlacementKey")) campaign.setProviderPlacementKey(asString(payload.get("providerPlacementKey"))); + if (payload.containsKey("sponsorName")) campaign.setSponsorName(asString(payload.get("sponsorName"))); + if (payload.containsKey("headline")) campaign.setHeadline(asString(payload.get("headline"))); + if (payload.containsKey("body")) campaign.setBody(asString(payload.get("body"))); + if (payload.containsKey("callToAction")) campaign.setCallToAction(asString(payload.get("callToAction"))); + if (payload.containsKey("imageUrl")) campaign.setImageUrl(asString(payload.get("imageUrl"))); + if (payload.containsKey("targetUrl")) campaign.setTargetUrl(asString(payload.get("targetUrl"))); + if (payload.containsKey("affiliateParam")) campaign.setAffiliateParam(asString(payload.get("affiliateParam"))); + if (payload.containsKey("affiliateCode")) campaign.setAffiliateCode(asString(payload.get("affiliateCode"))); + if (payload.containsKey("active")) campaign.setActive(asBoolean(payload.get("active"))); + if (payload.containsKey("privacyRespecting")) campaign.setPrivacyRespecting(asBoolean(payload.get("privacyRespecting"))); + if (payload.containsKey("nonIntrusive")) campaign.setNonIntrusive(asBoolean(payload.get("nonIntrusive"))); + if (payload.containsKey("testCampaign")) campaign.setTestCampaign(asBoolean(payload.get("testCampaign"))); + if (payload.containsKey("baseRevenuePerClickCents")) campaign.setBaseRevenuePerClickCents(Math.max(0, asInt(payload.get("baseRevenuePerClickCents"), campaign.getBaseRevenuePerClickCents()))); + if (payload.containsKey("weight")) campaign.setWeight(Math.max(1, 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 = asString(map.get("imageUrl")); + if (imageUrl == null || imageUrl.isBlank()) continue; + AdCampaign.AdCreative creative = new AdCampaign.AdCreative(); + creative.setImageUrl(imageUrl); + creative.setAltText(asString(map.get("altText"))); + try { + String placementRaw = 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); + } + } + + private 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; + } + + private String asString(Object value) { + return value == null ? null : String.valueOf(value); + } + + private int asInt(Object value, int fallback) { + if (value == null) return fallback; + try { + return Integer.parseInt(String.valueOf(value)); + } catch (Exception e) { + return fallback; + } + } + + private boolean asBoolean(Object value) { + if (value instanceof Boolean b) return b; + if (value == null) return false; + return Boolean.parseBoolean(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/finance/StripeGatewayService.java b/backend/src/main/java/net/modtale/service/finance/StripeGatewayService.java new file mode 100644 index 00000000..9ce7bbc9 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/StripeGatewayService.java @@ -0,0 +1,206 @@ +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) { + if (!isEnabled()) { + return new StripeResult(true, "sim_acct_" + System.currentTimeMillis(), null, null, Map.of("simulated", true)); + } + + 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) { + if (!isEnabled()) { + String url = normalizeFrontendUrl() + (returnPath.startsWith("/") ? returnPath : "/" + returnPath); + return new StripeResult(true, "sim_link_" + System.currentTimeMillis(), url, null, Map.of("simulated", true)); + } + + 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) { + if (!isEnabled()) { + return Map.of( + "details_submitted", true, + "charges_enabled", true, + "payouts_enabled", true, + "country", "US", + "simulated", true + ); + } + + 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 + ) { + if (!isEnabled()) { + return new StripeResult(true, "sim_cs_" + System.currentTimeMillis(), normalizeFrontendUrl() + "/dashboard/finance?donation=intent-" + intentId, null, Map.of("simulated", true)); + } + + 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) { + if (!isEnabled()) { + return Map.of("id", sessionId, "status", "complete", "payment_status", "paid", "simulated", true); + } + + 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) { + if (!isEnabled()) { + return new StripeResult(true, "sim_tr_" + System.currentTimeMillis(), null, null, Map.of( + "simulated", true, + "createdAt", LocalDateTime.now().toString(), + "destination", destinationAccountId, + "amount", amountCents + )); + } + + 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/MetadataService.java b/backend/src/main/java/net/modtale/service/project/MetadataService.java index c30f388e..1b3d03e6 100644 --- a/backend/src/main/java/net/modtale/service/project/MetadataService.java +++ b/backend/src/main/java/net/modtale/service/project/MetadataService.java @@ -76,6 +76,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); if (updated.getLinks() != null) existing.setLinks(updated.getLinks()); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index eabe47fc..fbaf255d 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -128,4 +128,9 @@ app.limits.modpack-gen-per-hour=10 app.limits.reports-per-day=10 app.limits.rescans-per-day=5 -app.oauth.enabled=${OAUTH_ENABLED:true} \ No newline at end of file +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.ads.test-mode-enabled=true + +app.oauth.enabled=${OAUTH_ENABLED:true} 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.

+
+ + + +