Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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 @@ -715,7 +715,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 @@ -837,21 +837,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 @@ -116,6 +116,10 @@ The Spring Boot backend relies on environment variables. You can set these in yo
| `R2_ENDPOINT` | Storage Endpoint URL | `https://<accountid>.r2.cloudflarestorage.com` |
| `R2_PUBLIC_DOMAIN` | Optional public storage URL | `https://cdn.example.test` |
| `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` |
| `PRE_AUTH_SECRET` | Shared random MFA pre-auth signing secret; required for consistent token validation across multiple instances | Set through your deployment secret manager |
| `STATUS_DISCORD_WEBHOOK_URL` | Optional Discord webhook for the continually updated status mirror | `https://discord.com/api/webhooks/...` |
| `STATUS_CHECKER_ENABLED` | Opt into the legacy embedded backend checker | `false` |
Expand Down Expand Up @@ -146,6 +150,28 @@ To test social logins, provide each provider's client ID and secret (for example
client ID by default and requires `HYTALE_CLIENT_SECRET`; its production callback
is `https://api.modtale.net/login/oauth2/code/hytale`.

### 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 @@ -306,12 +306,19 @@ public SecurityFilterChain securityFilterChain(
"/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/**", "/api/v1/lists/**").permitAll()
.requestMatchers(HttpMethod.POST,
"/api/v1/projects/external/identify",
"/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