Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -602,7 +602,7 @@ jobs:
else
PUBLIC_BACKEND_URL=$B_URL
fi

gcloud run services update ${{ env.BACKEND_SERVICE }} \
--region $REGION \
--update-env-vars "FRONTEND_URL=$FINAL_FRONTEND_URL,BACKEND_URL=$PUBLIC_BACKEND_URL"
Expand Down Expand Up @@ -674,21 +674,21 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Component | Status | Target Service | Service URL |" >> $GITHUB_STEP_SUMMARY
echo "|---|---|---|---|" >> $GITHUB_STEP_SUMMARY

if [ -n "$PUBLIC_API_URL" ]; then
echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($PUBLIC_API_URL) |" >> $GITHUB_STEP_SUMMARY
elif [ -n "$B_URL" ]; then
echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($B_URL/api/v1) |" >> $GITHUB_STEP_SUMMARY
else
echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY
fi

if [ -n "$F_URL" ]; then
echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | [App URL]($F_URL) |" >> $GITHUB_STEP_SUMMARY
else
echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY
fi

echo "" >> $GITHUB_STEP_SUMMARY
echo "---" >> $GITHUB_STEP_SUMMARY
echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,13 +93,39 @@ The Spring Boot backend relies on environment variables. You can set these in yo
| `R2_SECRET_KEY` | Storage Secret Key | `your_dev_secret_key` |
| `R2_ENDPOINT` | Storage Endpoint URL | `https://<accountid>.r2.cloudflarestorage.com` |
| `WARDEN_ENABLED` | **Must be false locally** | `false` |
| `STRIPE_SECRET_KEY` | Stripe server-side API key (`sk_test_...`) | `sk_test_...` |
| `STRIPE_PUBLISHABLE_KEY` | Stripe client key (`pk_test_...`) | `pk_test_...` |
| `STRIPE_WEBHOOK_SECRET` | Stripe webhook signing secret (`whsec_...`) | `whsec_...` |
| `STRIPE_MOCK_ENABLED` | Optional Stripe mock mode for local testing | `false` |
| `STATUS_DISCORD_WEBHOOK_URL` | Optional Discord webhook for status-change alerts | `https://discord.com/api/webhooks/...` |

> **Note on Warden:** The "Warden" malware and security scanner is proprietary to protect our threat-detection logic. You **must** set `WARDEN_ENABLED=false` to run the backend locally. This enables a "Mock Mode" where file uploads bypass the scanner and automatically return a mock "CLEAN" status.

**(Optional) OAuth Variables:**
To test social logins (GitHub, Discord, Google), provide their respective Client IDs and Secrets (e.g., `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`).

### Stripe Integration Setup (Step-by-Step)

1. Create a Stripe account and switch to **Test mode** in the Stripe dashboard.
2. Create/get API keys:
- `STRIPE_SECRET_KEY` from **Developers -> API keys** (`sk_test_...`)
- `STRIPE_PUBLISHABLE_KEY` from the same screen (`pk_test_...`)
3. Configure backend env vars before starting Spring Boot:
- `STRIPE_SECRET_KEY=...`
- `STRIPE_PUBLISHABLE_KEY=...`
- `STRIPE_MOCK_ENABLED=false`
4. Start backend (`./gradlew bootRun`) and frontend (`npm run dev`).
5. In Stripe dashboard, ensure your account can use Checkout + Connect in test mode.
6. Ensure `STRIPE_MOCK_ENABLED=false` in backend configuration for real integration testing.
7. Connect a creator Stripe account from the in-app Finance Manager (`Connect / Continue Stripe`) and then click `Refresh Stripe Status`.
8. Test one-time donations:
- Open a project with donations enabled.
- Start donation checkout and complete payment with Stripe test cards.
- Return to project page; donation is only counted after Stripe confirms paid/completed.
9. Optional local-only testing without real Stripe API:
- Set `STRIPE_MOCK_ENABLED=true` in backend configuration.
- Mock mode now simulates checkout links/status only and does **not** auto-mark donations as paid.

### 3. Run the Backend

Open a terminal in the `backend/` directory and use the Gradle wrapper.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -303,11 +303,18 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
"/api/v1/status",
"/api/v1/version/**",
"/api/v1/analytics/platform/stats",
"/api/v1/wiki/**"
"/api/v1/wiki/**",
"/api/v1/finance/public/**",
"/api/v1/finance/projects/*/donation-config",
"/api/v1/finance/projects/*/donations/checkout-url",
"/api/v1/finance/donations/confirm",
"/api/v1/finance/ads/slot/**",
"/api/v1/finance/ads/click/**"
).permitAll()
.requestMatchers(HttpMethod.HEAD, "/api/v1/projects/**", "/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**").permitAll()
.requestMatchers(HttpMethod.POST,
"/api/v1/users/batch"
"/api/v1/users/batch",
"/api/v1/finance/ads/impression"
).permitAll()
.requestMatchers("/api/v1/analytics/platform/full").access((authentication, context) -> {
boolean isApiKeyUser = authentication.get().getAuthorities().stream()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package net.modtale.controller.finance;

import jakarta.servlet.http.HttpServletRequest;
import net.modtale.service.finance.AdCampaignService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/finance")
public class AdController {

@Autowired private AdCampaignService financeAdsService;

@GetMapping("/ads/slot/{projectId}")
public ResponseEntity<?> getAdSlot(
@PathVariable String projectId,
@RequestParam(required = false) String placement
) {
return ResponseEntity.ok(financeAdsService.getAdSlotForProject(projectId, placement));
}

@PostMapping("/ads/impression")
public ResponseEntity<?> trackAdImpression(@RequestBody Map<String, String> payload, HttpServletRequest request) {
String campaignId = payload.get("campaignId");
String projectId = payload.get("projectId");
if (campaignId == null || projectId == null) return ResponseEntity.badRequest().build();

String ip = getClientIp(request);
financeAdsService.trackAdImpression(campaignId, projectId, ip);
return ResponseEntity.ok(Map.of("ok", true));
}

@GetMapping("/ads/click/{campaignId}")
public ResponseEntity<Void> clickAd(
@PathVariable String campaignId,
@RequestParam String projectId,
HttpServletRequest request
) {
String ip = getClientIp(request);
String url = financeAdsService.registerAdClickAndResolveUrl(campaignId, projectId, ip);

HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(url));
return new ResponseEntity<>(headers, HttpStatus.FOUND);
}

private String getClientIp(HttpServletRequest request) {
String xfHeader = request.getHeader("X-Forwarded-For");
return xfHeader == null ? request.getRemoteAddr() : xfHeader.split(",")[0];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package net.modtale.controller.finance;

import net.modtale.model.dto.request.finance.UpdateProjectMonetizationRequest;
import net.modtale.model.project.Project;
import net.modtale.model.user.User;
import net.modtale.service.finance.EarningsAccountService;
import net.modtale.service.project.query.ProjectService;
import net.modtale.service.security.access.AccessControlService;
import net.modtale.service.user.account.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/v1/finance")
public class CreatorRevenueController {

@Autowired private EarningsAccountService financeAccountService;
@Autowired private AccountService accountService;
@Autowired private ProjectService projectService;
@Autowired private AccessControlService accessControlService;

@GetMapping("/creator/overview")
@PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)")
public ResponseEntity<?> getCreatorOverview(
@RequestParam(defaultValue = "30d") String range,
@RequestParam(required = false) String ownerId
) {
User user = accountService.getCurrentUser();
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
try {
return ResponseEntity.ok(financeAccountService.getCreatorOverview(user, ownerId, range));
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}

@GetMapping("/creator/contexts")
@PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)")
public ResponseEntity<?> getFinanceContexts() {
User user = accountService.getCurrentUser();
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
return ResponseEntity.ok(financeAccountService.getFinanceContexts(user));
}

@PostMapping("/creator/stripe/onboarding-link")
@PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_EDIT_BASIC', authentication)")
public ResponseEntity<?> createStripeOnboardingLink(@RequestBody(required = false) Map<String, String> payload) {
User user = accountService.getCurrentUser();
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();

String returnPath = payload != null ? payload.get("returnPath") : null;
String ownerId = payload != null ? payload.get("ownerId") : null;
try {
return ResponseEntity.ok(financeAccountService.createStripeOnboardingLink(user, ownerId, returnPath));
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}

@PostMapping("/creator/stripe/refresh-status")
@PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)")
public ResponseEntity<?> refreshStripeStatus(@RequestBody(required = false) Map<String, String> payload) {
User user = accountService.getCurrentUser();
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
String ownerId = payload != null ? payload.get("ownerId") : null;
try {
return ResponseEntity.ok(financeAccountService.refreshStripeStatus(user, ownerId));
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}

@PostMapping("/creator/payouts/request")
@PreAuthorize("@apiSecurity.hasPersonalPerm('PROFILE_READ', authentication)")
public ResponseEntity<?> requestPayout(@RequestBody(required = false) Map<String, Object> payload) {
User user = accountService.getCurrentUser();
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();

Long amountCents = null;
String ownerId = null;
if (payload != null && payload.get("amountCents") != null) {
try {
amountCents = Long.parseLong(String.valueOf(payload.get("amountCents")));
} catch (Exception ignored) {}
}
if (payload != null && payload.get("ownerId") != null) {
ownerId = String.valueOf(payload.get("ownerId"));
}

try {
return ResponseEntity.ok(financeAccountService.requestPayout(user, ownerId, amountCents));
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
} catch (IllegalStateException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}

@GetMapping("/creator/orgs/{orgId}/payout-policy")
@PreAuthorize("@apiSecurity.hasOrgPerm(#orgId, 'ORG_EDIT_METADATA', authentication)")
public ResponseEntity<?> getOrgPayoutPolicy(@PathVariable String orgId) {
User user = accountService.getCurrentUser();
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
try {
return ResponseEntity.ok(financeAccountService.getOrgPayoutPolicy(user, orgId));
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}

@PutMapping("/creator/orgs/{orgId}/payout-policy")
@PreAuthorize("@apiSecurity.hasOrgPerm(#orgId, 'ORG_EDIT_METADATA', authentication)")
public ResponseEntity<?> updateOrgPayoutPolicy(@PathVariable String orgId, @RequestBody Map<String, Object> 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<Map<String, Object>> shares = new ArrayList<>();
if (payload != null && payload.get("shares") instanceof List<?> rawShares) {
for (Object item : rawShares) {
if (item instanceof Map<?, ?> rawMap) {
@SuppressWarnings("unchecked")
Map<String, Object> typed = (Map<String, Object>) rawMap;
shares.add(typed);
}
}
}

try {
return ResponseEntity.ok(financeAccountService.updateOrgPayoutPolicy(user, orgId, payoutMode, shares));
} catch (SecurityException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}

@PutMapping("/projects/{projectId}/settings")
@PreAuthorize("@apiSecurity.hasProjectPerm(#projectId, 'PROJECT_EDIT_METADATA', authentication)")
public ResponseEntity<?> updateProjectMonetization(
@PathVariable String projectId,
@RequestBody UpdateProjectMonetizationRequest request
) {
User user = accountService.getCurrentUser();
if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();

Project project = projectService.getRawProjectById(projectId);
if (project == null) return ResponseEntity.notFound().build();
if (!accessControlService.hasProjectPermission(project, user, "PROJECT_EDIT_METADATA")) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}

return ResponseEntity.ok(financeAccountService.updateProjectMonetization(user, project, request));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package net.modtale.controller.finance;

import net.modtale.model.user.User;
import net.modtale.service.finance.DonationCheckoutService;
import net.modtale.service.user.account.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1/finance")
public class DonationController {

@Autowired private DonationCheckoutService financeDonationService;
@Autowired private AccountService accountService;

@GetMapping("/projects/{projectId}/donation-config")
public ResponseEntity<?> getDonationConfig(@PathVariable String projectId) {
try {
return ResponseEntity.ok(financeDonationService.getDonationConfig(projectId));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}

@GetMapping("/projects/{projectId}/donations/checkout-url")
public ResponseEntity<?> createDonationCheckout(
@PathVariable String projectId,
@RequestParam long amountCents,
@RequestParam(defaultValue = "false") boolean recurring,
@RequestParam(defaultValue = "false") boolean guestCheckout
) {
try {
User donor = accountService.getCurrentUser();
return ResponseEntity.ok(financeDonationService.createDonationCheckout(projectId, amountCents, recurring, donor, guestCheckout));
} catch (IllegalStateException | IllegalArgumentException e) {
return ResponseEntity.badRequest().body(e.getMessage());
}
}

@GetMapping("/donations/confirm")
public ResponseEntity<?> confirmDonation(@RequestParam String intentId) {
try {
return ResponseEntity.ok(financeDonationService.confirmDonationIntent(intentId));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
}
Loading
Loading