diff --git a/.github/scripts/should-run-tests-workflow.sh b/.github/scripts/should-run-tests-workflow.sh index ae0b4b6db..c8e627002 100755 --- a/.github/scripts/should-run-tests-workflow.sh +++ b/.github/scripts/should-run-tests-workflow.sh @@ -5,6 +5,7 @@ event_name="${GITHUB_EVENT_NAME:-}" repo="${GITHUB_REPOSITORY:-}" repo_owner="${GITHUB_REPOSITORY_OWNER:-${repo%%/*}}" ref_name="${GITHUB_REF_NAME:-}" +head_sha="${GITHUB_SHA:-}" should_run=true reason="This workflow run owns the work." @@ -13,27 +14,41 @@ gh_available() { command -v gh >/dev/null 2>&1 && [[ -n "${GH_TOKEN:-}" ]] } -open_pr_count_for_branch() { - gh api --method GET "repos/$repo/pulls" \ +open_pr_can_own_tests() { + local numbers number state mergeable pr_head + numbers="$(gh api --method GET "repos/$repo/pulls" \ -f state=open \ -f head="$repo_owner:$ref_name" \ - --jq 'length' + --jq '.[].number')" || return 1 + while IFS= read -r number; do + [[ -z "$number" ]] && continue + [[ "$number" =~ ^[0-9]+$ ]] || return 1 + state="$(gh api "repos/$repo/pulls/$number" --jq '[.mergeable, .head.sha] | @tsv')" || return 1 + IFS=$'\t' read -r mergeable pr_head <<< "$state" + if [[ "$mergeable" == "true" && "$pr_head" == "$head_sha" ]]; then + echo true + return 0 + fi + done <<< "$numbers" + echo false } # PR runs always own their tests. A queued push may itself skip because a PR # exists, so its presence cannot prove that the commit has test coverage. +# Conflicted PRs do not trigger pull_request workflows. Unknown mergeability +# or a different head must therefore retain the push run's test coverage. if [[ "$event_name" == "push" ]]; then - if gh_available && [[ -n "$repo" && -n "$repo_owner" && -n "$ref_name" ]]; then - if open_pr_count="$(open_pr_count_for_branch)"; then - if [[ "$open_pr_count" =~ ^[0-9]+$ && "$open_pr_count" -gt 0 ]]; then + if gh_available && [[ -n "$repo" && -n "$repo_owner" && -n "$ref_name" && "$head_sha" =~ ^[[:xdigit:]]{40}$ ]]; then + if pr_can_own_tests="$(open_pr_can_own_tests)"; then + if [[ "$pr_can_own_tests" == "true" ]]; then should_run=false - reason="Skipping push workflow because this branch has an open PR; the pull_request run owns this commit." + reason="Skipping push workflow because an open, mergeable PR has this exact head; the pull_request run owns this commit." fi else echo "::warning::Could not check for open pull requests; running tests to avoid missing coverage." fi else - echo "::warning::GitHub CLI or token unavailable; running tests to avoid missing coverage." + echo "::warning::GitHub CLI, token, or commit context unavailable; running tests to avoid missing coverage." fi fi diff --git a/.github/tests/workflow-scripts.test.mjs b/.github/tests/workflow-scripts.test.mjs index fe61436f9..3493a656a 100644 --- a/.github/tests/workflow-scripts.test.mjs +++ b/.github/tests/workflow-scripts.test.mjs @@ -25,6 +25,7 @@ function run(script, overrides = {}) { GITHUB_REPOSITORY: 'Modtale/modtale', GITHUB_REPOSITORY_OWNER: 'Modtale', GITHUB_REF_NAME: 'audit', + GITHUB_SHA: 'a'.repeat(40), GH_TOKEN: 'test-token', ...overrides, }, @@ -42,14 +43,46 @@ test('PR creation and synchronization always retain their own test coverage', () } }); -test('push skips only when the GitHub API confirms an open PR', () => { +function mockPullRequestApi() { const bin = path.join(directory, 'bin'); fs.mkdirSync(bin); const gh = path.join(bin, 'gh'); - fs.writeFileSync(gh, '#!/bin/sh\nprintf "1\\n"\n', { mode: 0o755 }); - const env = { GITHUB_EVENT_NAME: 'push', PATH: `${bin}${path.delimiter}${process.env.PATH}` }; + fs.writeFileSync(gh, `#!/bin/sh +case "$*" in + *"/pulls/"*) + [ "\${MOCK_PR_ERROR:-}" = "yes" ] && exit 1 + printf '%s\\t%s\\n' "\${MOCK_MERGEABLE:-true}" "\${MOCK_HEAD:-$GITHUB_SHA}" + ;; + *) printf '%s\\n' "\${MOCK_PR_NUMBERS-25}" ;; +esac +`, { mode: 0o755 }); + return { GITHUB_EVENT_NAME: 'push', PATH: `${bin}${path.delimiter}${process.env.PATH}` }; +} + +test('push skips only when an open PR is positively mergeable at the same head', () => { + const env = mockPullRequestApi(); assert.match(run('should-run-tests-workflow.sh', env), /^should_run=false$/m); - fs.writeFileSync(gh, '#!/bin/sh\nexit 1\n', { mode: 0o755 }); +}); + +test('conflicted, unknown, stale, and absent PRs keep push tests enabled', () => { + const env = mockPullRequestApi(); + for (const scenario of [ + { MOCK_MERGEABLE: 'false' }, + { MOCK_MERGEABLE: 'null' }, + { MOCK_MERGEABLE: 'unexpected' }, + { MOCK_HEAD: 'b'.repeat(40) }, + { MOCK_PR_NUMBERS: '' }, + { MOCK_PR_NUMBERS: 'not-a-number' }, + { MOCK_PR_ERROR: 'yes' }, + { GITHUB_SHA: '' }, + ]) { + assert.match(run('should-run-tests-workflow.sh', { ...env, ...scenario }), /^should_run=true$/m); + } +}); + +test('PR-list API failure keeps push tests enabled', () => { + const env = mockPullRequestApi(); + fs.writeFileSync(path.join(directory, 'bin', 'gh'), '#!/bin/sh\nexit 1\n', { mode: 0o755 }); assert.match(run('should-run-tests-workflow.sh', env), /^should_run=true$/m); }); diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index af4d46bb1..f09be6a35 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -1,4 +1,4 @@ -name: Modtale CI/CD +name: Modtale CI/CD on: push: @@ -105,7 +105,7 @@ jobs: fi echo "GIT_BRANCH_NAME=$BRANCH_NAME" >> $GITHUB_ENV echo "BRANCH_SLUG=$BRANCH_SLUG" >> $GITHUB_ENV - + if [ "$BRANCH_NAME" = "main" ]; then echo "ENV_TYPE=prod" >> $GITHUB_ENV echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV @@ -130,7 +130,7 @@ jobs: echo "WARDEN_ENABLED=true" >> $GITHUB_ENV echo "OAUTH_ENABLED=true" >> $GITHUB_ENV echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV - + elif [ "$BRANCH_NAME" = "develop" ]; then echo "ENV_TYPE=dev" >> $GITHUB_ENV echo "BUILD_SERVICE_ACCOUNT=${{ vars.GCP_BUILD_SERVICE_ACCOUNT }}" >> $GITHUB_ENV @@ -155,7 +155,7 @@ jobs: echo "WARDEN_ENABLED=true" >> $GITHUB_ENV echo "OAUTH_ENABLED=true" >> $GITHUB_ENV echo "WARDEN_SECRET_NAME=WARDEN_API_KEY" >> $GITHUB_ENV - + else SLUG="$BRANCH_SLUG" BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME="${{ vars.GCP_BRANCH_PREVIEW_SOURCE_R2_BUCKET_NAME }}" @@ -511,7 +511,7 @@ jobs: else echo "Reusing the existing backend image for a configuration-only rollout." fi - + ARGS=( "--image" "gcr.io/$PROJECT_ID/modtale-backend:${{ env.TAG }}" "--region" "$REGION" @@ -766,7 +766,8 @@ jobs: : "${RUNTIME_SERVICE_ACCOUNT:?Set the GitHub environment runtime service account variable for this environment.}" bash ../.github/scripts/build-container.sh frontend "$TAG" \ - --build-arg "PUBLIC_API_URL=$API_URL" --build-arg "SSR_API_URL=$SSR_API_URL" + --build-arg "PUBLIC_API_URL=$API_URL" --build-arg "SSR_API_URL=$SSR_API_URL" \ + --build-arg "PUBLIC_FINANCE_DEMO=${{ env.ENV_TYPE == 'preview' && 'true' || 'false' }}" FRONTEND_ARGS=( "--image" "gcr.io/$PROJECT_ID/modtale-frontend:$TAG" @@ -838,6 +839,12 @@ jobs: echo "FINAL_FRONTEND_URL=${{ env.FRONTEND_DOMAIN }}" >> $GITHUB_ENV fi + - name: Verify synthetic finance preview route + if: env.ENV_TYPE == 'preview' && (steps.filter.outputs.frontend == 'true' || env.FRONTEND_EXISTS == 'false') + run: | + curl --fail --silent --show-error --retry 4 --retry-delay 3 "$FINAL_FRONTEND_URL/finance-preview" -o /tmp/finance-preview.html + grep -Fq 'Finance demo' /tmp/finance-preview.html + - name: Update Backend CORS and Self-Awareness id: update_backend_self_awareness # Only preview URLs are unknown during the initial backend deploy. Production @@ -857,7 +864,7 @@ jobs: else PUBLIC_BACKEND_URL=$B_URL fi - + if [ "$MODTALE_SECRET_BUNDLES_ENABLED" = "true" ]; then python3 .github/scripts/secret_bundle_ci.py deploy -- \ --update-env-vars "FRONTEND_URL=$FINAL_FRONTEND_URL,BACKEND_URL=$PUBLIC_BACKEND_URL" >/dev/null @@ -953,7 +960,7 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "| Component | Status | Target Service | Service URL |" >> $GITHUB_STEP_SUMMARY echo "|---|---|---|---|" >> $GITHUB_STEP_SUMMARY - + if [ -n "$PUBLIC_API_URL" ]; then echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | [API Endpoint]($PUBLIC_API_URL) |" >> $GITHUB_STEP_SUMMARY elif [ -n "$B_URL" ]; then @@ -961,13 +968,13 @@ jobs: else echo "| **Backend API** | $BACKEND_STATUS | \`${{ env.BACKEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY fi - + if [ -n "$F_URL" ]; then echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | [App URL]($F_URL) |" >> $GITHUB_STEP_SUMMARY else echo "| **Frontend App** | $FRONTEND_STATUS | \`${{ env.FRONTEND_SERVICE }}\` | *N/A* |" >> $GITHUB_STEP_SUMMARY fi - + echo "" >> $GITHUB_STEP_SUMMARY echo "---" >> $GITHUB_STEP_SUMMARY echo "*View deployment details in [Google Cloud Console](https://console.cloud.google.com/run?project=${{ env.PROJECT_ID }}).* " >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3e9796427..59530ee21 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -46,6 +46,9 @@ jobs: with: node-version: 22.12.0 + - name: Test sandbox runner safety guards + run: python3 -m unittest discover -s backend/scripts/tests -v + - name: Test workflow and fixture scripts run: node --test .github/tests/*.test.mjs mock-db/tests/*.test.mjs @@ -146,6 +149,117 @@ jobs: - name: Run backend tests run: ./gradlew test + finance-frontend-integration: + name: Finance Frontend Session and Browser Tests + needs: detect-changes + if: needs.detect-changes.outputs.backend == 'true' || needs.detect-changes.outputs.frontend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 22.12.0 + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install frontend dependencies + working-directory: frontend + run: npm ci + - name: Install isolated Chromium test browser + working-directory: frontend + env: + PLAYWRIGHT_BROWSERS_PATH: "0" + run: npx playwright install --with-deps chromium + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 26 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Exercise frontend cookies and finance routes on loopback + working-directory: backend + run: ./gradlew financeFrontendIntegrationTest + - name: Upload session integration reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: finance-frontend-session-reports + path: | + backend/build/reports/tests/financeFrontendIntegrationTest + backend/build/test-results/financeFrontendIntegrationTest + backend/build/test-results/finance-frontend-ui.xml + backend/build/test-results/finance-browser-ui.xml + backend/build/finance-browser-artifacts + if-no-files-found: error + + finance-transactions: + name: Finance Transaction Tests + needs: detect-changes + if: needs.detect-changes.outputs.backend == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + permissions: + contents: read + env: + FINANCE_TEST_MONGO_URI: mongodb://127.0.0.1:27018/?replicaSet=finance-test + steps: + - name: Check out repository + uses: actions/checkout@v4 + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 26 + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v3 + - name: Start isolated MongoDB replica set + run: | + docker run -d --name finance-mongo -p 127.0.0.1:27018:27018 mongo:8.0.32 --replSet finance-test --port 27018 --bind_ip_all + for i in {1..60}; do + if docker exec finance-mongo mongosh --port 27018 --quiet --eval 'db.adminCommand({ping: 1}).ok' >/dev/null 2>&1; then break; fi + sleep 1 + done + docker exec finance-mongo mongosh --port 27018 --quiet --eval 'rs.initiate({_id: "finance-test", members: [{_id: 0, host: "127.0.0.1:27018"}]})' + for i in {1..60}; do + if docker exec finance-mongo mongosh --port 27018 --quiet --eval 'if (!db.hello().isWritablePrimary) quit(1)' >/dev/null 2>&1; then exit 0; fi + sleep 1 + done + exit 1 + - name: Verify ledger and withdrawal concurrency + working-directory: backend + run: | + ./gradlew test --tests 'net.modtale.service.finance.*' + python3 - <<'PYTHON' + import pathlib, xml.etree.ElementTree as ET + reports = sorted(pathlib.Path('build/test-results/test').glob('TEST-net.modtale.service.finance.*.xml')) + assert reports, 'Finance test reports are missing' + total = 0 + for report in reports: + suite = ET.parse(report).getroot() + counts = {key: int(suite.get(key, 0)) for key in ('tests', 'failures', 'errors', 'skipped')} + print(suite.get('name'), counts) + assert counts['tests'] > 0 and not any(counts[key] for key in ('failures', 'errors', 'skipped')), 'Finance CI must execute every test without skips' + total += counts['tests'] + print(f'Finance replica validation: {total} tests executed, zero failures/errors/skips') + PYTHON + - name: Upload finance test reports + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: finance-test-reports + path: | + backend/build/reports/tests/test + backend/build/test-results/test + if-no-files-found: error + - name: Stop disposable database + if: always() + run: docker rm -f finance-mongo || true + launcher: name: Launcher Tests needs: detect-changes diff --git a/.gitignore b/.gitignore index 11a4b46b0..d0dcfc76e 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,7 @@ mock-db/node_modules/ # Local campaign briefs, drafts, and review exports /media/2026-09-update/ + +# Python test runner bytecode +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 150f6218c..6f7547f2b 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,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` | @@ -150,6 +154,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. diff --git a/backend/build.gradle b/backend/build.gradle index a92a331e1..26bb5c66e 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -66,10 +66,9 @@ dependencies { } } -tasks.named('test') { - useJUnitPlatform() - // Mockito's documented Java 21+ setup avoids unsupported dynamic self-attachment. - // Test-only instrumentation uses the same Spring-managed Mockito version. +// Mockito's documented Java 21+ setup avoids unsupported dynamic self-attachment. +// Test-only instrumentation uses the same Spring-managed Mockito version. +tasks.withType(Test).configureEach { jvmArgumentProviders.add(new CommandLineArgumentProvider() { @InputFiles @PathSensitive(PathSensitivity.RELATIVE) @@ -82,6 +81,21 @@ tasks.named('test') { }) } +tasks.named('test') { + useJUnitPlatform() + exclude '**/FinanceFrontendIntegrationTest.class' +} + +tasks.register('financeFrontendIntegrationTest', Test) { + description = 'Runs JSDOM and Chromium finance clients against an isolated loopback servlet fixture.' + group = 'verification' + testClassesDirs = sourceSets.test.output.classesDirs + classpath = sourceSets.test.runtimeClasspath + useJUnitPlatform() + include '**/FinanceFrontendIntegrationTest.class' + outputs.upToDateWhen { false } +} + tasks.register('statusServiceJar', org.springframework.boot.gradle.tasks.bundling.BootJar) { group = 'build' description = 'Builds the detached Modtale status service jar.' diff --git a/backend/scripts/stripe-sandbox-smoke.py b/backend/scripts/stripe-sandbox-smoke.py new file mode 100644 index 000000000..6b8c08080 --- /dev/null +++ b/backend/scripts/stripe-sandbox-smoke.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +"""Explicit opt-in provider contract smoke test; never accepts live credentials or real customer data.""" +import argparse +import base64 +import json +import os +from pathlib import Path +import re +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +import uuid + +API_VERSION = "2026-08-26.dahlia" +API = "https://api.stripe.com/v1/" + + +def validate_environment(env): + if env.get("MODTALE_STRIPE_SANDBOX_OPT_IN") != "true": + raise ValueError("Set MODTALE_STRIPE_SANDBOX_OPT_IN=true to explicitly allow fictional Stripe test objects.") + key = env.get("STRIPE_SECRET_KEY", "") + if not key.startswith(("sk_test_", "rk_test_", "rkcs_")): + raise ValueError("Only recognized Stripe test credentials are accepted. Live or unknown credentials are refused.") + account = env.get("STRIPE_PLATFORM_ACCOUNT_ID", "") + if not re.fullmatch(r"acct_[A-Za-z0-9]+", account): + raise ValueError("STRIPE_PLATFORM_ACCOUNT_ID must identify the expected sandbox account.") + return key, account + + +def safe_code(value): + return value if isinstance(value, str) and re.fullmatch(r"[a-z][a-z0-9_]{0,100}", value) else None + + +class SandboxRun: + def __init__(self, key, account, directory): + self.key, self.account, self.directory = key, account, directory + self.report = {"run_id": "modtale-" + str(uuid.uuid4()), "expected_account_id": account, + "api_version": API_VERSION, "started_at": int(time.time()), "scope": "fictional Stripe provider contracts", "checks": []} + self.counter = 0 + + def save(self): + temporary = self.directory / "report.tmp" + with temporary.open("w", encoding="utf-8") as out: + json.dump(self.report, out, indent=2) + temporary.replace(self.directory / "report.json") + + def record(self, check, passed, **safe): + self.report["checks"].append({"check": check, "passed": passed, **safe}) + self.save() + if not passed and "limitation" not in safe: + raise ValueError("A provider contract assertion failed. See the sanitized check report; this run has stopped.") + + def request(self, method, path, fields=None, expected=(200,)): + # No configurable host or redirect: credentials are sent only to Stripe's API. + if not re.fullmatch(r"[A-Za-z0-9_/?=&.%\[\]-]+", path) or path.startswith("/") or ".." in path: + raise ValueError("Invalid provider path.") + self.counter += 1 + data = urllib.parse.urlencode(fields or {}).encode() if method == "POST" else None + headers = {"Authorization": "Basic " + base64.b64encode((self.key + ":").encode()).decode(), "Stripe-Version": API_VERSION} + if method == "POST": + headers.update({"Content-Type": "application/x-www-form-urlencoded", "Idempotency-Key": self.report["run_id"] + "-" + str(self.counter)}) + self.report["last_request"] = {"method": method, "path": path, "ordinal": self.counter, "status": "SUBMITTED"} + self.save() + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, *args, **kwargs): return None + opener = urllib.request.build_opener(NoRedirect) + try: + with opener.open(urllib.request.Request(API + path, data=data, headers=headers, method=method), timeout=30) as response: + status, body = response.status, json.load(response) + except urllib.error.HTTPError as failure: + status = failure.code + try: body = json.loads(failure.read()) + except (ValueError, UnicodeError): body = {} + except (OSError, TimeoutError): + self.report["last_request"]["status"] = "OUTCOME_UNKNOWN" + self.save() + raise ValueError("Provider response unavailable. Inspect this saved run before any retry; no automatic retry was made.") from None + if not isinstance(body, dict): raise ValueError("Provider response is not the expected JSON object; stopping.") + self.report["last_request"]["status"] = status + self.save() + if status not in expected: + error = body.get("error", {}) + self.record("provider_request", False, http_status=status, error_type=safe_code(error.get("type")), error_code=safe_code(error.get("code"))) + raise ValueError("Stripe rejected a test request. See the sanitized report; no raw provider body or credentials were saved.") + return status, body + + @staticmethod + def test_object(value, prefix): + if value.get("livemode") is not False or not re.fullmatch(re.escape(prefix) + r"[A-Za-z0-9_]+", str(value.get("id", ""))): + raise ValueError("Provider object failed test-mode or identity validation; stopping.") + return value["id"] + + def verify_account(self): + status, account = self.request("GET", "account", expected=(200, 403)) + if status == 200: + if account.get("id") != self.account or account.get("object") != "account": + raise ValueError("Provider account does not match the configured sandbox; stopping.") + self.record("platform_account_binding", True) + elif self.key.startswith("rkcs_"): + self.record("platform_account_binding", False, limitation="Anonymous sandbox lacks account-read permission. Connect and payout validation remain blocked.") + else: + raise ValueError("Could not verify platform account; stopping before test mutations.") + def execute(self): + self.verify_account() + checkout = {"mode": "payment", "success_url": "https://example.invalid/modtale-test/success", "cancel_url": "https://example.invalid/modtale-test/cancel", + "line_items[0][price_data][currency]": "usd", "line_items[0][price_data][product_data][name]": "Modtale fictional support test", + "line_items[0][price_data][unit_amount]": "500", "line_items[0][quantity]": "1", "metadata[intentId]": self.report["run_id"], + "payment_intent_data[metadata][intentId]": self.report["run_id"]} + _, session = self.request("POST", "checkout/sessions", checkout) + session_id = self.test_object(session, "cs_") + if session.get("payment_status") != "unpaid" or session.get("amount_total") != 500: + raise ValueError("Checkout fixture did not preserve unpaid status and exact amount.") + self.record("hosted_checkout_creation", True, object_id=session_id, limitation="Hosted payment completion and application callback are not exercised by this API test.") + _, expired = self.request("POST", "checkout/sessions/" + session_id + "/expire") + self.record("checkout_expiration", expired.get("status") == "expired", object_id=session_id) + checkout.pop("payment_intent_data[metadata][intentId]") + checkout.update({"mode": "subscription", "line_items[0][price_data][recurring][interval]": "month", "subscription_data[metadata][intentId]": self.report["run_id"]}) + _, monthly = self.request("POST", "checkout/sessions", checkout) + monthly_id = self.test_object(monthly, "cs_") + self.record("monthly_checkout_creation", monthly.get("mode") == "subscription" and monthly.get("payment_status") == "unpaid", object_id=monthly_id) + self.request("POST", "checkout/sessions/" + monthly_id + "/expire") + _, payment = self.request("POST", "payment_intents", {"amount": "500", "currency": "usd", "payment_method": "pm_card_visa", + "payment_method_types[]": "card", "confirm": "true", "metadata[modtale_sandbox_run]": self.report["run_id"]}) + payment_id = self.test_object(payment, "pi_") + if payment.get("status") != "succeeded" or payment.get("amount_received") != 500: + raise ValueError("Fictional payment did not succeed with exact cents.") + self.record("fictional_payment", True, object_id=payment_id) + expanded_status, expanded = self.request("GET", "payment_intents/" + payment_id + "?expand%5B%5D=latest_charge.balance_transaction", expected=(200, 403)) + if expanded_status == 200: self.test_object(expanded, "pi_") + charge = expanded.get("latest_charge") + transaction = charge.get("balance_transaction") if isinstance(charge, dict) else None + if isinstance(transaction, dict): + conserved = isinstance(transaction.get("amount"), int) and isinstance(transaction.get("fee"), int) and transaction.get("net") == transaction["amount"] - transaction["fee"] + self.record("actual_charge_fee", conserved, object_id=transaction.get("id"), currency=transaction.get("currency"), amount_cents=transaction.get("amount"), fee_cents=transaction.get("fee"), net_cents=transaction.get("net"), availability=transaction.get("status")) + else: + self.record("actual_charge_fee", False, limitation="Actual balance transaction is pending or unavailable. No estimated fee is substituted.") + for amount, label in [(100, "partial_refund"), (400, "remaining_refund")]: + _, refund = self.request("POST", "refunds", {"payment_intent": payment_id, "amount": str(amount), "metadata[modtale_sandbox_run]": self.report["run_id"]}) + self.record(label, refund.get("status") == "succeeded" and refund.get("amount") == amount and refund.get("payment_intent") == payment_id, object_id=refund.get("id")) + status, decline = self.request("POST", "payment_intents", {"amount": "500", "currency": "usd", "payment_method": "pm_card_chargeDeclined", "payment_method_types[]": "card", "confirm": "true"}, expected=(402,)) + self.record("declined_fictional_payment", status == 402 and decline.get("error", {}).get("type") == "card_error") + self.report["finished_at"] = int(time.time()) + self.report["remaining"] = ["Application authenticated Checkout and persisted webhook fulfillment", "Recurring renewal and portal cancellation", "Connect onboarding and transfers with full test permissions", "Authentic webhook delivery", "Live approvals and fee policies"] + self.save() + + + def advance_clock(self, clock_id, timestamp): + self.request("POST", "test_helpers/test_clocks/" + clock_id + "/advance", {"frozen_time": str(timestamp)}) + deadline = time.monotonic() + 180 + while time.monotonic() < deadline: + _, clock = self.request("GET", "test_helpers/test_clocks/" + clock_id) + self.test_object(clock, "clock_") + if clock.get("status") == "ready": return + if clock.get("status") != "advancing": raise ValueError("Test clock entered an unexpected state.") + time.sleep(2) + raise ValueError("Test clock is still advancing. Review its saved ID before any further mutation.") + + def verify_invoice_cash(self, invoice_id, label): + if not re.fullmatch(r"in_[A-Za-z0-9]+", str(invoice_id)): raise ValueError("Missing expected invoice identity.") + _, invoice = self.request("GET", "invoices/" + invoice_id) + self.test_object(invoice, "in_") + _, payments = self.request("GET", "invoice_payments?invoice=" + invoice_id + "&status=paid&limit=100") + rows = payments.get("data", []) + if payments.get("has_more") is not False or len(rows) != 1: raise ValueError("Expected one complete cash invoice payment for this fixture.") + payment = rows[0].get("payment", {}) + if payment.get("type") != "payment_intent": raise ValueError("Invoice was not paid by an actual test PaymentIntent.") + pi = payment.get("payment_intent") + if not re.fullmatch(r"pi_[A-Za-z0-9]+", str(pi)): raise ValueError("Invoice payment identity is invalid.") + _, intent = self.request("GET", "payment_intents/" + pi) + self.test_object(intent, "pi_") + parent = invoice.get("parent", {}) + passed = (invoice.get("id") == invoice_id and invoice.get("status") == "paid" and invoice.get("currency") == "usd" and invoice.get("amount_paid") == 500 + and parent.get("type") == "subscription_details" and parent.get("subscription_details", {}).get("metadata", {}).get("intentId") == self.report["run_id"] + and rows[0].get("invoice") == invoice_id and rows[0].get("amount_paid") == 500 + and intent.get("id") == pi and intent.get("currency") == "usd" and intent.get("status") == "succeeded" and intent.get("amount_received") == 500) + self.record(label, passed, invoice_id=invoice_id, payment_id=pi) + if not passed: raise ValueError("Expected exact paid cash invoice was not verified.") + + def execute_billing(self, use_clock=True): + self.verify_account() + clock_id = None + if use_clock: + now = int(time.time()) + _, clock = self.request("POST", "test_helpers/test_clocks", {"frozen_time": str(now), "name": self.report["run_id"]}) + clock_id = self.test_object(clock, "clock_"); self.record("test_clock", True, object_id=clock_id) + _, method = self.request("POST", "payment_methods", {"type": "card", "card[token]": "tok_visa"}) + method_id = self.test_object(method, "pm_") + customer_fields = {"payment_method": method_id, "invoice_settings[default_payment_method]": method_id, "metadata[modtale_sandbox_run]": self.report["run_id"]} + if clock_id: customer_fields["test_clock"] = clock_id + _, customer = self.request("POST", "customers", customer_fields) + customer_id = self.test_object(customer, "cus_"); self.record("fictional_customer", True, object_id=customer_id) + _, product = self.request("POST", "products", {"name": "Modtale fictional recurring support", "metadata[modtale_sandbox_run]": self.report["run_id"]}) + product_id = self.test_object(product, "prod_") + _, price = self.request("POST", "prices", {"product": product_id, "unit_amount": "500", "currency": "usd", "recurring[interval]": "month"}) + price_id = self.test_object(price, "price_") + _, subscription = self.request("POST", "subscriptions", {"customer": customer_id, "items[0][price]": price_id, "metadata[intentId]": self.report["run_id"]}) + subscription_id = self.test_object(subscription, "sub_") + self.record("recurring_subscription", subscription.get("status") == "active", object_id=subscription_id) + first_invoice = subscription.get("latest_invoice") + self.verify_invoice_cash(first_invoice, "initial_recurring_cash_payment") + if use_clock: + items = subscription.get("items", {}).get("data", []) + if len(items) != 1 or not isinstance(items[0].get("current_period_end"), int): raise ValueError("Unexpected subscription item period shape.") + self.advance_clock(clock_id, items[0]["current_period_end"] + 7200) + _, renewed = self.request("GET", "subscriptions/" + subscription_id) + self.test_object(renewed, "sub_") + renewal_invoice = renewed.get("latest_invoice") + if renewal_invoice == first_invoice: raise ValueError("Clock advance did not generate a new renewal invoice.") + self.verify_invoice_cash(renewal_invoice, "renewal_cash_payment") + _, configuration = self.request("POST", "billing_portal/configurations", {"features[subscription_cancel][enabled]": "true", "features[subscription_cancel][mode]": "at_period_end", "features[payment_method_update][enabled]": "true"}) + configuration_id = self.test_object(configuration, "bpc_") + _, portal = self.request("POST", "billing_portal/sessions", {"customer": customer_id, "configuration": configuration_id, "return_url": "https://example.invalid/modtale-test/return"}) + self.test_object(portal, "bps_") + self.record("billing_portal_session", portal.get("configuration") == configuration_id and portal.get("customer") == customer_id and isinstance(portal.get("url"), str), configuration_id=configuration_id, + limitation="Portal URL is intentionally not saved. This tests session creation; interactive cancellation remains separate.") + if use_clock: + _, canceled = self.request("POST", "subscriptions/" + subscription_id, {"cancel_at_period_end": "true"}) + self.test_object(canceled, "sub_") + self.record("cancellation_scheduled", canceled.get("cancel_at_period_end") is True and canceled.get("status") == "active", object_id=subscription_id) + self.advance_clock(clock_id, canceled["items"]["data"][0]["current_period_end"] + 7200) + _, ended = self.request("GET", "subscriptions/" + subscription_id) + self.test_object(ended, "sub_") + self.record("cancellation_at_period_end", ended.get("status") == "canceled", object_id=subscription_id) + else: + _, ended = self.request("DELETE", "subscriptions/" + subscription_id + "?invoice_now=false&prorate=false") + self.test_object(ended, "sub_") + self.record("immediate_test_cancellation", ended.get("status") == "canceled", object_id=subscription_id) + self.record("clock_based_renewal", False, limitation="Not attempted in the initial-only scenario. This does not substitute for test-clock renewal and period-end cancellation coverage.") + self.report["finished_at"] = int(time.time()) + self.report["remaining"] = ["Interactive hosted Checkout and portal completion", "Authenticated application and persisted webhook fulfillment", "Connect and payout verification", "Live approvals and actual later fees"] + if not use_clock: self.report["remaining"].append("Clock-based renewal and period-end cancellation") + self.save() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state-dir", required=True, type=Path, help="New private directory outside the repository; contains sanitized audit results, never API keys") + parser.add_argument("--scenario", choices=("payments", "billing", "billing-initial"), default="payments") + args = parser.parse_args() + try: + key, account = validate_environment(os.environ) + directory = args.state_dir.resolve() + repo = Path(__file__).resolve().parents[2] + if directory == repo or repo in directory.parents: raise ValueError("Keep sandbox run state outside the repository.") + if directory.exists(): raise ValueError("Run directory already exists. Review its outcome; this runner never blindly repeats an uncertain run.") + os.umask(0o077); directory.mkdir(mode=0o700, parents=True) + run = SandboxRun(key, account, directory); run.report["scenario"] = args.scenario; run.save() + if args.scenario in ("billing", "billing-initial"): run.execute_billing(use_clock=args.scenario == "billing") + else: run.execute() + run.report["result"] = "COMPLETED_WITH_LIMITATIONS" if any(not check["passed"] for check in run.report["checks"]) else "PASSED" + run.save() + print(json.dumps(run.report, indent=2)) + except ValueError as error: + print(str(error), file=sys.stderr); return 1 + return 0 + +if __name__ == "__main__": sys.exit(main()) diff --git a/backend/scripts/tests/test_stripe_sandbox_smoke.py b/backend/scripts/tests/test_stripe_sandbox_smoke.py new file mode 100644 index 000000000..2047fb892 --- /dev/null +++ b/backend/scripts/tests/test_stripe_sandbox_smoke.py @@ -0,0 +1,131 @@ +import importlib.util +import pathlib +import unittest +import tempfile +import json +import io +from urllib.error import HTTPError +from unittest.mock import patch +spec=importlib.util.spec_from_file_location('runner',pathlib.Path(__file__).parents[1]/'stripe-sandbox-smoke.py') +runner=importlib.util.module_from_spec(spec); spec.loader.exec_module(runner) +class SandboxGuardTest(unittest.TestCase): + def env(self, key='sk_test_fixture'): + return {'MODTALE_STRIPE_SANDBOX_OPT_IN':'true','STRIPE_SECRET_KEY':key,'STRIPE_PLATFORM_ACCOUNT_ID':'acct_fixture'} + def test_opt_in_required(self): + env=self.env();env.pop('MODTALE_STRIPE_SANDBOX_OPT_IN') + with self.assertRaises(ValueError): runner.validate_environment(env) + def test_live_unknown_empty_keys_refused(self): + for key in ['sk_live_fixture','rk_live_fixture','unknown','']: + with self.subTest(key=key), self.assertRaises(ValueError): runner.validate_environment(self.env(key)) + def test_only_recognized_test_keys(self): + for key in ['sk_test_fixture','rk_test_fixture','rkcs_fixture']: + self.assertEqual(key,runner.validate_environment(self.env(key))[0]) + def test_api_version_matches_production_gateway(self): + path=pathlib.Path(runner.__file__).parents[1]/'src/main/java/net/modtale/service/finance/StripeGatewayService.java' + self.assertIn('API_VERSION = "'+runner.API_VERSION+'"',path.read_text()) + def test_exact_account_required(self): + env=self.env();env['STRIPE_PLATFORM_ACCOUNT_ID']='acct_fixture/path' + with self.assertRaises(ValueError): runner.validate_environment(env) + def test_provider_object_mode_is_explicit(self): + for live in [None,True,'false',0]: + with self.assertRaises(ValueError): runner.SandboxRun.test_object({'id':'pi_fixture','livemode':live},'pi_') + def test_provider_object_id_is_bounded(self): + for object_id in ['cs_fixture','pi_bad/path','pi_']: + with self.assertRaises(ValueError): runner.SandboxRun.test_object({'id':object_id,'livemode':False},'pi_') + def test_no_arbitrary_error_data_in_audit(self): + for value in ['secret key foo',{'value':'secret'},'https://claim.example','secret\nmore']: + self.assertIsNone(runner.safe_code(value)) + self.assertEqual('permission_error',runner.safe_code('permission_error')) + def test_known_test_object_accepted(self): + self.assertEqual('pi_fixture',runner.SandboxRun.test_object({'id':'pi_fixture','livemode':False},'pi_')) +class SandboxTransportGuardTest(unittest.TestCase): + def make_run(self, directory): return runner.SandboxRun('sk_test_fixture_never_print', 'acct_fixture', pathlib.Path(directory)) + def test_failed_contract_assertion_stops_after_saving_failure(self): + with tempfile.TemporaryDirectory() as directory: + run=self.make_run(directory) + with self.assertRaises(ValueError):run.record('unexpected_payment_status',False) + self.assertFalse(json.loads((pathlib.Path(directory)/'report.json').read_text())['checks'][0]['passed']) + def test_explicit_permission_limitation_stays_visible_without_claiming_pass(self): + with tempfile.TemporaryDirectory() as directory: + run=self.make_run(directory);run.record('restricted_permission',False,limitation='Owner action required') + self.assertFalse(run.report['checks'][0]['passed']) + def test_invalid_paths_never_open_network(self): + with tempfile.TemporaryDirectory() as directory: + run=self.make_run(directory) + for path in ['https://elsewhere.test','/account','../account','account#fragment']: + with patch.object(runner.urllib.request,'build_opener') as opener, self.assertRaises(ValueError): run.request('GET',path) + opener.assert_not_called() + def test_provider_body_and_key_are_never_saved_on_failure(self): + with tempfile.TemporaryDirectory() as directory: + run=self.make_run(directory) + body=json.dumps({'error':{'type':'invalid_request_error','message':'sk_test_fixture_never_print','code':'sk_test_fixture_never_print not a code'}}).encode() + with patch.object(runner.urllib.request,'build_opener') as factory: + factory.return_value.open.side_effect=HTTPError('https://api.stripe.com/v1/account',403,'Forbidden',{},io.BytesIO(body)) + with self.assertRaises(ValueError): run.request('GET','account') + audit=(pathlib.Path(directory)/'report.json').read_text() + self.assertNotIn('sk_test_fixture_never_print',audit);self.assertIn('invalid_request_error',audit) + def test_lost_response_is_indeterminate_and_never_retried(self): + with tempfile.TemporaryDirectory() as directory: + run=self.make_run(directory) + with patch.object(runner.urllib.request,'build_opener') as factory: + factory.return_value.open.side_effect=TimeoutError() + with self.assertRaises(ValueError): run.request('POST','payment_intents',{'amount':'500'}) + self.assertEqual(1,factory.return_value.open.call_count) + self.assertEqual('OUTCOME_UNKNOWN',run.report['last_request']['status']) + def test_non_anonymous_account_denial_stops_before_mutation(self): + with tempfile.TemporaryDirectory() as directory: + run=self.make_run(directory) + with patch.object(run,'request',return_value=(403,{})) as request: + with self.assertRaises(ValueError):run.execute() + self.assertEqual(1,request.call_count) + def test_wrong_account_stops_before_mutation(self): + with tempfile.TemporaryDirectory() as directory: + run=self.make_run(directory) + with patch.object(run,'request',return_value=(200,{'object':'account','id':'acct_wrong'})) as request: + with self.assertRaises(ValueError):run.execute() + self.assertEqual(1,request.call_count) + def test_pending_fee_is_not_a_pass_or_substituted_estimate(self): + source=pathlib.Path(runner.__file__).read_text() + self.assertIn('No estimated fee is substituted',source) +class InitialBillingContractTest(unittest.TestCase): + def fixture(self, run): + def request(method,path,fields=None,expected=(200,)): + if path=='account':return 200,{'id':'acct_fixture','object':'account'} + if path=='payment_methods':return 200,{'id':'pm_fixture','livemode':False} + if path=='customers': + self.assertNotIn('test_clock',fields);self.assertNotIn('email',fields);self.assertNotIn('name',fields) + return 200,{'id':'cus_fixture','livemode':False} + if path=='products':return 200,{'id':'prod_fixture','livemode':False} + if path=='prices':return 200,{'id':'price_fixture','livemode':False} + if path=='subscriptions':return 200,{'id':'sub_fixture','livemode':False,'status':'active','latest_invoice':'in_fixture'} + if path=='invoices/in_fixture':return 200,{'id':'in_fixture','livemode':False,'status':'paid','currency':'usd','amount_paid':500,'parent':{'type':'subscription_details','subscription_details':{'metadata':{'intentId':run.report['run_id']}}}} + if path=='invoice_payments?invoice=in_fixture&status=paid&limit=100':return 200,{'has_more':False,'data':[{'invoice':'in_fixture','amount_paid':500,'payment':{'type':'payment_intent','payment_intent':'pi_fixture'}}]} + if path=='payment_intents/pi_fixture':return 200,{'id':'pi_fixture','livemode':False,'currency':'usd','status':'succeeded','amount_received':500} + if path=='billing_portal/configurations':return 200,{'id':'bpc_fixture','livemode':False} + if path=='billing_portal/sessions':return 200,{'id':'bps_fixture','livemode':False,'configuration':'bpc_fixture','customer':'cus_fixture','url':'https://billing.stripe.test/never-save-session-url'} + if path=='subscriptions/sub_fixture?invoice_now=false&prorate=false': + self.assertEqual('DELETE',method);return 200,{'id':'sub_fixture','livemode':False,'status':'canceled'} + raise AssertionError('Unexpected provider path: '+path) + return request + def test_initial_billing_never_calls_clock_or_creates_manual_renewal(self): + with tempfile.TemporaryDirectory() as directory: + run=runner.SandboxRun('sk_test_fixture','acct_fixture',pathlib.Path(directory)) + with patch.object(run,'request',side_effect=self.fixture(run)) as request: + run.execute_billing(use_clock=False) + self.assertFalse(any('test_clocks' in call.args[1] for call in request.call_args_list)) + self.assertFalse(any(call.args[:2]==('POST','invoices') for call in request.call_args_list)) + checks={row['check']:row for row in run.report['checks']} + self.assertTrue(checks['initial_recurring_cash_payment']['passed']);self.assertTrue(checks['immediate_test_cancellation']['passed']) + self.assertFalse(checks['clock_based_renewal']['passed']) + self.assertNotIn('never-save-session-url',(pathlib.Path(directory)/'report.json').read_text()) + def test_denied_ordinary_billing_api_stops_without_trying_another_route(self): + with tempfile.TemporaryDirectory() as directory: + run=runner.SandboxRun('sk_test_fixture','acct_fixture',pathlib.Path(directory)) + fixture=self.fixture(run) + def request(method,path,*args,**kwargs): + if path=='subscriptions':raise ValueError('Permission denied fixture') + return fixture(method,path,*args,**kwargs) + with patch.object(run,'request',side_effect=request) as transport: + with self.assertRaises(ValueError):run.execute_billing(use_clock=False) + self.assertEqual('subscriptions',transport.call_args.args[1]) +if __name__=='__main__':unittest.main() diff --git a/backend/src/main/java/net/modtale/config/security/ApiCsrfRequestMatcher.java b/backend/src/main/java/net/modtale/config/security/ApiCsrfRequestMatcher.java index 8ba5a8a42..bca688a0f 100644 --- a/backend/src/main/java/net/modtale/config/security/ApiCsrfRequestMatcher.java +++ b/backend/src/main/java/net/modtale/config/security/ApiCsrfRequestMatcher.java @@ -16,7 +16,8 @@ final class ApiCsrfRequestMatcher implements RequestMatcher { "/api/v1/auth/forgot-password", "/api/v1/auth/reset-password", "/api/v1/users/batch", - "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/api/v1/projects/external/identify" + "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/api/v1/projects/external/identify", + "/api/v1/finance/webhooks/stripe" ); @Override 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 9b2ae8cfc..9d953e372 100644 --- a/backend/src/main/java/net/modtale/config/security/SecurityConfig.java +++ b/backend/src/main/java/net/modtale/config/security/SecurityConfig.java @@ -306,12 +306,20 @@ public SecurityFilterChain securityFilterChain( "/api/v1/status", "/api/v1/version/**", "/api/v1/analytics/platform/stats", - "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/api/v1/wiki/**" + "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/api/v1/wiki/**", + "/api/v1/finance/public/**", + "/api/v1/finance/projects/*/donation-config", + "/api/v1/finance/ads/slot/**", + "/api/v1/finance/ads/click/**" ).permitAll() .requestMatchers(HttpMethod.HEAD, "/api/v1/news", "/api/v1/news/**", "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/api/v1/projects/**", "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/api/v1/tags", "/api/v1/files/**", "/api/v1/user/profile/**", "/api/v1/og/**", "/api/v1/lists/**").permitAll() .requestMatchers(HttpMethod.POST, "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/api/v1/projects/external/identify", - "/api/v1/users/batch" + "/api/v1/users/batch", + "/api/v1/finance/projects/*/donations/checkout-url", + "/api/v1/finance/donations/confirm", + "/api/v1/finance/webhooks/stripe", + "/api/v1/finance/ads/impression" ).permitAll() .requestMatchers("/api/v1/analytics/platform/full").access((authentication, context) -> { boolean isApiKeyUser = authentication.get().getAuthorities().stream() diff --git a/backend/src/main/java/net/modtale/controller/finance/AdController.java b/backend/src/main/java/net/modtale/controller/finance/AdController.java new file mode 100644 index 000000000..e4b28bae8 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/AdController.java @@ -0,0 +1,57 @@ +package net.modtale.controller.finance; + +import jakarta.servlet.http.HttpServletRequest; +import net.modtale.service.finance.AdCampaignService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/finance") +public class AdController { + + @Autowired private AdCampaignService financeAdsService; + + @GetMapping("/ads/slot/{projectId}") + public ResponseEntity<?> getAdSlot( + @PathVariable String projectId, + @RequestParam(required = false) String placement + ) { + return ResponseEntity.ok(financeAdsService.getAdSlotForProject(projectId, placement)); + } + + @PostMapping("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/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) { + // Trust only the server's configured forwarded-header handling. + return request.getRemoteAddr(); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/AdSettlementAdminController.java b/backend/src/main/java/net/modtale/controller/finance/AdSettlementAdminController.java new file mode 100644 index 000000000..bc27b658d --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/AdSettlementAdminController.java @@ -0,0 +1,24 @@ +package net.modtale.controller.finance; + +import net.modtale.model.dto.request.finance.StageAdSettlementRequest; +import net.modtale.service.finance.AdSettlementStagingService; +import net.modtale.service.user.account.AccountService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/finance/admin/ad-settlements") +@PreAuthorize("!hasAuthority('ROLE_API') && @apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") +public class AdSettlementAdminController { + private final AdSettlementStagingService staging; + private final AccountService accounts; + public AdSettlementAdminController(AdSettlementStagingService staging, AccountService accounts) { this.staging = staging; this.accounts = accounts; } + @GetMapping public Object list() { return staging.list(accounts.getCurrentUser()); } + @GetMapping("/{id}") public Object get(@PathVariable String id) { return staging.get(accounts.getCurrentUser(), id); } + @PostMapping public Object create(@RequestBody StageAdSettlementRequest request) { return staging.create(accounts.getCurrentUser(), request); } + @PostMapping("/{id}/amendments") public Object amend(@PathVariable String id, @RequestBody AdSettlementStagingService.Amendment request) { return staging.amend(accounts.getCurrentUser(), id, request); } + @PostMapping("/{id}/reviews") public Object review(@PathVariable String id, @RequestBody AdSettlementStagingService.Review request) { return staging.review(accounts.getCurrentUser(), id, request); } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<?> invalid(IllegalArgumentException error) { return ResponseEntity.badRequest().body(error.getMessage()); } + @ExceptionHandler(SecurityException.class) public ResponseEntity<?> forbidden(SecurityException error) { return ResponseEntity.status(403).body(error.getMessage()); } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/CreatorRevenueController.java b/backend/src/main/java/net/modtale/controller/finance/CreatorRevenueController.java new file mode 100644 index 000000000..6d26af032 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/CreatorRevenueController.java @@ -0,0 +1,171 @@ +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("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/creator/overview") + @PreAuthorize("!hasAuthority('ROLE_API') && @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("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/creator/contexts") + @PreAuthorize("!hasAuthority('ROLE_API') && @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("!hasAuthority('ROLE_API') && @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, payload == null ? null : payload.get("country"))); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalStateException | IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PostMapping("/creator/stripe/refresh-status") + @PreAuthorize("!hasAuthority('ROLE_API') && @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("!hasAuthority('ROLE_API') && @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 invalid) { return ResponseEntity.badRequest().body("Payout amount must be an integer number of cents."); } + } + String requestKey = payload != null && payload.get("requestKey") instanceof String key ? key : null; + if (payload != null && payload.get("ownerId") != null) { + ownerId = String.valueOf(payload.get("ownerId")); + } + + try { + return ResponseEntity.ok(financeAccountService.requestPayout(user, ownerId, amountCents, requestKey)); + } catch (SecurityException e) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage()); + } catch (IllegalStateException | IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/creator/orgs/{orgId}/payout-policy") + @PreAuthorize("!hasAuthority('ROLE_API') && @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("!hasAuthority('ROLE_API') && @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("!hasAuthority('ROLE_API') && @apiSecurity.hasProjectPerm(#projectId, 'PROJECT_EDIT_METADATA', authentication)") + public ResponseEntity<?> updateProjectMonetization( + @PathVariable String projectId, + @RequestBody UpdateProjectMonetizationRequest request + ) { + User user = accountService.getCurrentUser(); + if (user == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + + Project project = projectService.getRawProjectById(projectId); + if (project == null) return ResponseEntity.notFound().build(); + if (!accessControlService.hasProjectPermission(project, user, "PROJECT_EDIT_METADATA")) { + return ResponseEntity.status(HttpStatus.FORBIDDEN).build(); + } + + return ResponseEntity.ok(financeAccountService.updateProjectMonetization(user, project, request)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/DisputeReconciliationController.java b/backend/src/main/java/net/modtale/controller/finance/DisputeReconciliationController.java new file mode 100644 index 000000000..798ee76ba --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/DisputeReconciliationController.java @@ -0,0 +1,26 @@ +package net.modtale.controller.finance; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.math.BigDecimal; +import net.modtale.service.finance.PaymentAdjustmentService; +import net.modtale.service.user.account.AccountService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/finance/dispute-reconciliation") +@PreAuthorize("!hasAuthority('ROLE_API') && @apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") +public class DisputeReconciliationController { + private final PaymentAdjustmentService adjustments; + private final AccountService accounts; + public DisputeReconciliationController(PaymentAdjustmentService adjustments, AccountService accounts) { this.adjustments = adjustments; this.accounts = accounts; } + public record Decision(@NotBlank String caseId, @NotBlank @Pattern(regexp = "[a-f0-9]{64}") String expectedEvidenceDigest, + @NotNull @DecimalMin("0") @Digits(integer = 12, fraction = 0) BigDecimal creatorFeeCents, @NotBlank @Size(max = 1000) String reason) {} + @GetMapping public Object list() { return adjustments.getDisputeCases(); } + @GetMapping("/{caseId}/decisions") public Object decisions(@PathVariable String caseId) { return adjustments.getDisputeDecisions(caseId); } + @PostMapping("/{caseId}/refresh") public Object refresh(@PathVariable String caseId) { return adjustments.refreshCase(caseId); } + @PostMapping("/resolve") public Object resolve(@Valid @RequestBody Decision body) { + return adjustments.resolveCase(body.caseId(), body.expectedEvidenceDigest(), body.creatorFeeCents().longValueExact(), accounts.getCurrentUser(), body.reason()); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/DonationController.java b/backend/src/main/java/net/modtale/controller/finance/DonationController.java new file mode 100644 index 000000000..3a9f0f617 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/DonationController.java @@ -0,0 +1,54 @@ +package net.modtale.controller.finance; + +import net.modtale.model.user.User; +import jakarta.validation.Valid; +import net.modtale.model.dto.request.finance.CreateSupportCheckoutRequest; +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.security.access.prepost.PreAuthorize; +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(); + } + } + + @PostMapping("/projects/{projectId}/donations/checkout-url") + @PreAuthorize("!hasAuthority('ROLE_API')") + public ResponseEntity<?> createDonationCheckout( + @PathVariable String projectId, + @Valid @RequestBody CreateSupportCheckoutRequest request + ) { + try { + User donor = accountService.getCurrentUser(); + return ResponseEntity.ok(financeDonationService.createDonationCheckout(projectId, request.amountCents().longValueExact(), request.recurring(), donor, request.guestCheckout(), request.expectedPlatformCutBps().intValueExact())); + } catch (DonationCheckoutService.SupportTermsChangedException changed) { + return ResponseEntity.status(409).body(java.util.Map.of("code", "SUPPORT_TERMS_CHANGED", "message", changed.getMessage())); + } catch (IllegalStateException | IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PostMapping("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/donations/confirm") + @PreAuthorize("!hasAuthority('ROLE_API')") + public ResponseEntity<?> confirmDonation(@RequestBody java.util.Map<String, String> payload) { + try { + return ResponseEntity.ok(financeDonationService.confirmDonationIntent(payload.get("intentId"))); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/PayoutReconciliationController.java b/backend/src/main/java/net/modtale/controller/finance/PayoutReconciliationController.java new file mode 100644 index 000000000..75dca66ea --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/PayoutReconciliationController.java @@ -0,0 +1,24 @@ +package net.modtale.controller.finance; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import net.modtale.service.user.account.AccountService; +import net.modtale.service.finance.CreatorPayoutService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/finance/payout-reconciliation") +@PreAuthorize("!hasAuthority('ROLE_API') && @apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") +public class PayoutReconciliationController { + private final CreatorPayoutService payouts; + private final AccountService accounts; + public PayoutReconciliationController(CreatorPayoutService payouts, AccountService accounts) { this.payouts = payouts; this.accounts = accounts; } + public record Confirmation(@NotBlank String requestId, @Min(0) int recipientIndex, + @NotBlank @Pattern(regexp = "tr_[A-Za-z0-9]+") String transferId, @NotBlank @Size(max = 1000) String reason) {} + @GetMapping public ResponseEntity<?> list() { return ResponseEntity.ok(payouts.getReviewRequests()); } + @PostMapping("/confirm-existing-transfer") public ResponseEntity<?> confirm(@Valid @RequestBody Confirmation body) { + return ResponseEntity.ok(CreatorPayoutService.toResponse(payouts.reconcileKnownTransfer(body.requestId(), body.recipientIndex(), body.transferId(), accounts.getCurrentUser(), body.reason()))); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/ProviderCostEvidenceController.java b/backend/src/main/java/net/modtale/controller/finance/ProviderCostEvidenceController.java new file mode 100644 index 000000000..340987592 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/ProviderCostEvidenceController.java @@ -0,0 +1,20 @@ +package net.modtale.controller.finance; + +import jakarta.validation.Valid; +import net.modtale.service.finance.ProviderCostEvidenceService; +import net.modtale.service.user.account.AccountService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/finance/provider-costs") +@PreAuthorize("!hasAuthority('ROLE_API') && @apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") +public class ProviderCostEvidenceController { + private final ProviderCostEvidenceService costs; + private final AccountService accounts; + public ProviderCostEvidenceController(ProviderCostEvidenceService costs, AccountService accounts) { this.costs = costs; this.accounts = accounts; } + @GetMapping public Object list() { return costs.list(accounts.getCurrentUser()); } + @PostMapping("/import-stripe") public Object importStripe(@Valid @RequestBody ProviderCostEvidenceService.ImportRequest request) { + return costs.retrieveAndImport(accounts.getCurrentUser(), request); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/RecurringSupportController.java b/backend/src/main/java/net/modtale/controller/finance/RecurringSupportController.java new file mode 100644 index 000000000..85ea75fcf --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/RecurringSupportController.java @@ -0,0 +1,35 @@ +package net.modtale.controller.finance; + +import java.util.Map; +import net.modtale.model.user.User; +import net.modtale.service.finance.RecurringSupportService; +import net.modtale.service.user.account.AccountService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/finance/support") +@PreAuthorize("isAuthenticated() && !hasAuthority('ROLE_API')") +public class RecurringSupportController { + private final RecurringSupportService support; + private final AccountService accounts; + public RecurringSupportController(RecurringSupportService support, AccountService accounts) { this.support = support; this.accounts = accounts; } + + @GetMapping("/subscriptions") + public ResponseEntity<?> subscriptions() { + User user = accounts.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + return ResponseEntity.ok(support.listForDonor(user)); + } + + @PostMapping("/subscriptions/{subscriptionId}/billing-portal") + public ResponseEntity<?> billingPortal(@PathVariable String subscriptionId) { + User user = accounts.getCurrentUser(); + if (user == null) return ResponseEntity.status(401).build(); + try { return ResponseEntity.ok(Map.of("url", support.openBillingPortal(user, subscriptionId))); } + catch (SecurityException forbidden) { return ResponseEntity.status(403).build(); } + catch (IllegalArgumentException missing) { return ResponseEntity.notFound().build(); } + catch (IllegalStateException unavailable) { return ResponseEntity.status(503).body(unavailable.getMessage()); } + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/RevenueAdminController.java b/backend/src/main/java/net/modtale/controller/finance/RevenueAdminController.java new file mode 100644 index 000000000..b2b8a9cb6 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/RevenueAdminController.java @@ -0,0 +1,91 @@ +package net.modtale.controller.finance; + +import net.modtale.model.dto.request.finance.UpdatePlatformFinanceSettingsRequest; +import net.modtale.service.finance.EarningsAccountService; +import net.modtale.service.finance.AdCampaignService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/v1/finance") +public class RevenueAdminController { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private AdCampaignService financeAdsService; + @Autowired private net.modtale.service.finance.PaymentAdjustmentService adjustments; + + @GetMapping("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/admin/disputes") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> getDisputes() { return ResponseEntity.ok(adjustments.getDisputeCases()); } + + @GetMapping("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/admin/overview") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> getAdminOverview(@RequestParam(defaultValue = "30d") String range) { + return ResponseEntity.ok(financeAccountService.getAdminOverview(range)); + } + + @PutMapping("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/admin/settings") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> updateAdminSettings(@RequestBody UpdatePlatformFinanceSettingsRequest request) { + return ResponseEntity.ok(financeAccountService.updatePlatformSettings(request)); + } + + @GetMapping("/admin/ads/campaigns") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> getAdCampaigns() { + return ResponseEntity.ok(financeAdsService.getAdCampaigns()); + } + + @PostMapping("/admin/ads/campaigns") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> createAdCampaign(@RequestBody Map<String, Object> payload) { + try { + return ResponseEntity.ok(financeAdsService.createAdCampaign(payload)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PutMapping("/admin/ads/campaigns/{campaignId}") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> updateAdCampaign(@PathVariable String campaignId, @RequestBody Map<String, Object> payload) { + try { + return ResponseEntity.ok(financeAdsService.updateAdCampaign(campaignId, payload)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PostMapping("/admin/ads/campaigns/{campaignId}/start") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> startAdCampaign(@PathVariable String campaignId) { + try { + return ResponseEntity.ok(financeAdsService.setCampaignActiveState(campaignId, true)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @PostMapping("/admin/ads/campaigns/{campaignId}/pause") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> pauseAdCampaign(@PathVariable String campaignId) { + try { + return ResponseEntity.ok(financeAdsService.setCampaignActiveState(campaignId, false)); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(e.getMessage()); + } + } + + @GetMapping("/admin/ads/test-slot/{projectId}") + @PreAuthorize("@apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") + public ResponseEntity<?> getTestAdSlot( + @PathVariable String projectId, + @RequestParam(required = false) String placement + ) { + return ResponseEntity.ok(financeAdsService.getTestAdSlotForProject(projectId, placement)); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/RevenuePublicController.java b/backend/src/main/java/net/modtale/controller/finance/RevenuePublicController.java new file mode 100644 index 000000000..bb9fe4639 --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/RevenuePublicController.java @@ -0,0 +1,50 @@ +package net.modtale.controller.finance; + +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.service.finance.EarningsAccountService; +import net.modtale.service.finance.RevenueReportingService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.CacheControl; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +@RestController +@RequestMapping("/api/v1/finance") +public class RevenuePublicController { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private RevenueReportingService financeReportingService; + + @GetMapping("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/public/daily-revenue") + public ResponseEntity<?> getPublicDailyRevenue(@RequestParam(defaultValue = "90") int days) { + long secondsToMidnight = Duration.between(LocalDateTime.now(), LocalDateTime.now().toLocalDate().plusDays(1).atStartOfDay()).getSeconds(); + PlatformFinanceSettings settings = financeAccountService.getSettings(); + + return ResponseEntity.ok() + .cacheControl(CacheControl.maxAge(secondsToMidnight, TimeUnit.SECONDS).cachePublic()) + .body(Map.of( + "currency", settings.getCurrency(), + "days", Math.max(1, Math.min(365, days)), + "data", financeReportingService.getPublicDailyRevenue(days) + )); + } + + @GetMapping("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/public/settings") + public ResponseEntity<?> getPublicMonetizationSettings() { + PlatformFinanceSettings settings = financeAccountService.getSettings(); + return ResponseEntity.ok(Map.of( + "adCreatorSplitPercent", settings.getAdCreatorSplitBps() / 100.0, + "donationPlatformCutPercent", settings.getDonationPlatformCutBps() / 100.0, + "fundExpiryDays", 0, + "fundsExpire", false + )); + } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/StripeReadinessController.java b/backend/src/main/java/net/modtale/controller/finance/StripeReadinessController.java new file mode 100644 index 000000000..6eb18658b --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/StripeReadinessController.java @@ -0,0 +1,15 @@ +package net.modtale.controller.finance; + +import net.modtale.service.finance.StripeReadinessService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/admin/finance/stripe-readiness") +@PreAuthorize("!hasAuthority('ROLE_API') && @apiSecurity.hasAdminPermission('PLATFORM_FINANCE_MANAGE', authentication)") +public class StripeReadinessController { + private final StripeReadinessService readiness; + public StripeReadinessController(StripeReadinessService readiness) { this.readiness = readiness; } + @GetMapping public StripeReadinessService.Report configuration() { return readiness.configuration(); } + @PostMapping("/verify") public StripeReadinessService.Report verify() { return readiness.verifyProviderConfiguration(); } +} diff --git a/backend/src/main/java/net/modtale/controller/finance/StripeWebhookController.java b/backend/src/main/java/net/modtale/controller/finance/StripeWebhookController.java new file mode 100644 index 000000000..72780c6bf --- /dev/null +++ b/backend/src/main/java/net/modtale/controller/finance/StripeWebhookController.java @@ -0,0 +1,91 @@ +package net.modtale.controller.finance; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.Instant; +import java.util.Map; +import net.modtale.service.finance.StripeWebhookEvents; +import net.modtale.model.finance.PaymentWebhookReceipt; +import net.modtale.repository.finance.PaymentWebhookReceiptRepository; +import net.modtale.service.finance.DonationCheckoutService; +import net.modtale.service.finance.StripeGatewayService; +import net.modtale.service.finance.RecurringSupportService; +import net.modtale.service.finance.PaymentAdjustmentService; +import net.modtale.service.finance.StripeWebhookSignature; +import net.modtale.service.finance.FinanceSourceKey; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/v1/finance/webhooks") +public class StripeWebhookController { + private static final ObjectMapper JSON = new ObjectMapper(); + private final DonationCheckoutService donations; + private final PaymentWebhookReceiptRepository receipts; + private final StripeGatewayService gateway; + private final RecurringSupportService recurring; + private final PaymentAdjustmentService adjustments; + private final String webhookSecret; + + public StripeWebhookController(DonationCheckoutService donations, PaymentWebhookReceiptRepository receipts, + StripeGatewayService gateway, RecurringSupportService recurring, PaymentAdjustmentService adjustments, @Value("${app.finance.stripe.webhook-secret:}") String webhookSecret) { + this.donations = donations; + this.receipts = receipts; + this.gateway = gateway; + this.recurring = recurring; + this.adjustments = adjustments; + this.webhookSecret = webhookSecret; + } + + @PostMapping("/stripe") + public ResponseEntity<?> receive(@RequestBody byte[] body, + @RequestHeader(value = "Stripe-Signature", required = false) String signature) { + if (webhookSecret == null || webhookSecret.isBlank()) return ResponseEntity.status(503).build(); + if (body.length > 262144) return ResponseEntity.status(413).build(); + if (!StripeWebhookSignature.verify(body, signature, webhookSecret, Instant.now().getEpochSecond())) { + return ResponseEntity.badRequest().build(); + } + Map<String, Object> event; + try { + event = JSON.readValue(body, new TypeReference<>() {}); + } catch (Exception invalidJson) { + return ResponseEntity.badRequest().build(); + } + if (!(event.get("id") instanceof String eventId) || !eventId.startsWith("evt_") + || !(event.get("type") instanceof String type)) return ResponseEntity.badRequest().build(); + // Existing obligations keep reconciling even when new checkout creation is paused. + if (!(event.get("livemode") instanceof Boolean live) || !gateway.isReconciliationEnabled() || live == gateway.isTestMode()) return ResponseEntity.status(503).build(); + if (!StripeGatewayService.API_VERSION.equals(event.get("api_version"))) return ResponseEntity.status(503).build(); + String accountId = gateway.getPlatformAccountId(); + if (event.containsKey("account") && !accountId.equals(event.get("account"))) return ResponseEntity.status(503).build(); + String receiptId = FinanceSourceKey.stripe(!live, accountId, "event:" + eventId); + if (receipts.existsById(receiptId)) return ResponseEntity.ok(Map.of("received", true)); + if (StripeWebhookEvents.REQUIRED.contains(type)) { + if (!(event.get("data") instanceof Map<?, ?> data) || !(data.get("object") instanceof Map<?, ?> object)) { + return ResponseEntity.badRequest().build(); + } + @SuppressWarnings("unchecked") Map<String, Object> session = (Map<String, Object>) object; + try { + if (type.startsWith("charge.dispute.") && session.get("id") instanceof String disputeId && session.get("charge") instanceof String chargeId) adjustments.synchronizeDispute(disputeId, chargeId); + else if ("charge.refunded".equals(type) && session.get("id") instanceof String chargeId) adjustments.synchronizeCharge(chargeId, eventId + ":" + chargeId); + else if (type.startsWith("refund.") && session.get("charge") instanceof String chargeId) adjustments.synchronizeCharge(chargeId, eventId + ":" + chargeId); + else if (type.startsWith("checkout.session.")) donations.handlePaidCheckout(session); + else if ("invoice.paid".equals(type)) recurring.handlePaidInvoice(session); + else if (type.startsWith("customer.subscription.") && session.get("id") instanceof String id) recurring.refreshSubscription(id); + else if ("invoice.payment_failed".equals(type) && session.get("parent") instanceof Map<?, ?> parent + && parent.get("subscription_details") instanceof Map<?, ?> details && details.get("subscription") instanceof String id) recurring.refreshSubscription(id); + } catch (IllegalArgumentException notRecordedYet) { + return ResponseEntity.status(503).build(); + } + } + // Mark only after fulfillment: crashes/retries cannot lose a payment between receipt and credit. + try { + receipts.insert(new PaymentWebhookReceipt(receiptId, type, Instant.now())); + } catch (DuplicateKeyException alreadyProcessed) { + // The deterministic payment ledger ID also deduplicates separate events for one session. + } + return ResponseEntity.ok(Map.of("received", true)); + } +} diff --git a/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java b/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java index aaafc4532..a1264f6f1 100644 --- a/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/net/modtale/exception/GlobalExceptionHandler.java @@ -11,12 +11,32 @@ import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.multipart.MaxUploadSizeExceededException; +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.web.HttpRequestMethodNotSupportedException; +import org.springframework.web.HttpMediaTypeNotSupportedException; @RestControllerAdvice public class GlobalExceptionHandler { private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(HttpMessageNotReadableException.class) + public ResponseEntity<ProblemDetail> handleUnreadableBody(HttpMessageNotReadableException ex) { + return ErrorMessageUtils.badRequest("The request body is invalid or missing required values."); + } + + @ExceptionHandler(HttpRequestMethodNotSupportedException.class) + public ResponseEntity<ProblemDetail> handleUnsupportedMethod(HttpRequestMethodNotSupportedException ex) { + var headers = new org.springframework.http.HttpHeaders(); + if (ex.getSupportedHttpMethods() != null) headers.setAllow(ex.getSupportedHttpMethods()); + return new ResponseEntity<>(ProblemDetail.forStatusAndDetail(HttpStatus.METHOD_NOT_ALLOWED, "This HTTP method is not supported for this endpoint."), headers, HttpStatus.METHOD_NOT_ALLOWED); + } + + @ExceptionHandler(HttpMediaTypeNotSupportedException.class) + public ResponseEntity<ProblemDetail> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) { + return ErrorMessageUtils.response(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "The request content type is not supported for this endpoint."); + } + @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity<ProblemDetail> handleBadRequests(IllegalArgumentException ex) { logger.error("IllegalArgumentException:", ex); diff --git a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java index 26d7eb781..284a526ba 100644 --- a/backend/src/main/java/net/modtale/mapper/ProjectMapper.java +++ b/backend/src/main/java/net/modtale/mapper/ProjectMapper.java @@ -238,6 +238,11 @@ public static ProjectDTO toDTO(Project project, boolean isSummary, String curren dto.setTypes(project.getTypes()); dto.setAllowModpacks(project.isAllowModpacks()); dto.setAllowComments(project.isAllowComments()); + dto.setAdsEnabled(project.isAdsEnabled()); + dto.setDonationsEnabled(project.isDonationsEnabled()); + dto.setSuggestedDonationCents(project.getSuggestedDonationCents()); + dto.setDonationRecurringDefault(project.isDonationRecurringDefault()); + dto.setDonationPlatformCutBps(project.getDonationPlatformCutBps()); dto.setHmWikiEnabled(project.isHmWikiEnabled()); dto.setHmWikiSlug(project.getHmWikiSlug()); dto.setGalleryCarouselEnabled(project.isGalleryCarouselEnabled()); diff --git a/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java b/backend/src/main/java/net/modtale/model/dto/project/ProjectDTO.java index f4ebdbc45..e2b5ae50a 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 @@ -37,6 +37,11 @@ public class ProjectDTO { private List<String> childProjectIds; private boolean allowModpacks; private boolean allowComments; + private boolean adsEnabled; + private boolean donationsEnabled; + private int suggestedDonationCents; + private boolean donationRecurringDefault; + private int donationPlatformCutBps; private boolean hmWikiEnabled; private String hmWikiSlug; private boolean galleryCarouselEnabled; @@ -110,6 +115,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/CreateSupportCheckoutRequest.java b/backend/src/main/java/net/modtale/model/dto/request/finance/CreateSupportCheckoutRequest.java new file mode 100644 index 000000000..1b4a28f28 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/finance/CreateSupportCheckoutRequest.java @@ -0,0 +1,12 @@ +package net.modtale.model.dto.request.finance; + +import java.math.BigDecimal; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import jakarta.validation.constraints.NotNull; + +/** Preserve the submitted numeric value until validation; never truncate fractional cents. */ +public record CreateSupportCheckoutRequest(@NotNull @DecimalMin("100") @DecimalMax("100000") @Digits(integer = 6, fraction = 0) BigDecimal amountCents, + boolean recurring, boolean guestCheckout, + @NotNull @DecimalMin("0") @DecimalMax("10000") @Digits(integer = 5, fraction = 0) BigDecimal expectedPlatformCutBps) {} diff --git a/backend/src/main/java/net/modtale/model/dto/request/finance/StageAdSettlementRequest.java b/backend/src/main/java/net/modtale/model/dto/request/finance/StageAdSettlementRequest.java new file mode 100644 index 000000000..ca827fee6 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/dto/request/finance/StageAdSettlementRequest.java @@ -0,0 +1,20 @@ +package net.modtale.model.dto.request.finance; + +import java.time.LocalDate; +import java.math.BigDecimal; + +public record StageAdSettlementRequest(String provider, String providerAccount, String reportId, + String depositId, String currency, LocalDate from, LocalDate through, + BigDecimal reportedCollectedCents, String reportSha256) { + public StageAdSettlementRequest { + if (reportedCollectedCents != null) reportedCollectedCents = reportedCollectedCents.stripTrailingZeros(); + } + public StageAdSettlementRequest(String provider, String providerAccount, String reportId, String depositId, + String currency, LocalDate from, LocalDate through, long reportedCollectedCents, String reportSha256) { + this(provider, providerAccount, reportId, depositId, currency, from, through, BigDecimal.valueOf(reportedCollectedCents), reportSha256); + } + public long exactCents() { + try { return reportedCollectedCents.longValueExact(); } + catch (ArithmeticException | NullPointerException invalid) { throw new IllegalArgumentException("Reported collected cents must be a whole integer."); } + } +} 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 000000000..866df0de7 --- /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 000000000..a23e45387 --- /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 6f34207b1..06f71d099 100644 --- a/backend/src/main/java/net/modtale/model/dto/request/project/UpdateProjectRequest.java +++ b/backend/src/main/java/net/modtale/model/dto/request/project/UpdateProjectRequest.java @@ -33,6 +33,10 @@ public class UpdateProjectRequest { private Boolean customLicenseOpenSource; private Boolean allowModpacks; private Boolean allowComments; + private Boolean adsEnabled; + private Boolean donationsEnabled; + private Integer suggestedDonationCents; + private Boolean donationRecurringDefault; private Boolean hmWikiEnabled; private Boolean galleryCarouselEnabled; @@ -64,6 +68,14 @@ public class UpdateProjectRequest { public void setAllowModpacks(Boolean allowModpacks) { this.allowModpacks = allowModpacks; } public Boolean getAllowComments() { return allowComments; } public void setAllowComments(Boolean allowComments) { this.allowComments = allowComments; } + public Boolean getAdsEnabled() { return adsEnabled; } + public void setAdsEnabled(Boolean adsEnabled) { this.adsEnabled = adsEnabled; } + public Boolean getDonationsEnabled() { return donationsEnabled; } + public void setDonationsEnabled(Boolean donationsEnabled) { this.donationsEnabled = donationsEnabled; } + public Integer getSuggestedDonationCents() { return suggestedDonationCents; } + public void setSuggestedDonationCents(Integer suggestedDonationCents) { this.suggestedDonationCents = suggestedDonationCents; } + public Boolean getDonationRecurringDefault() { return donationRecurringDefault; } + public void setDonationRecurringDefault(Boolean donationRecurringDefault) { this.donationRecurringDefault = donationRecurringDefault; } public Boolean getHmWikiEnabled() { return hmWikiEnabled; } public void setHmWikiEnabled(Boolean hmWikiEnabled) { this.hmWikiEnabled = hmWikiEnabled; } public Boolean getGalleryCarouselEnabled() { return galleryCarouselEnabled; } diff --git a/backend/src/main/java/net/modtale/model/finance/AdCampaign.java b/backend/src/main/java/net/modtale/model/finance/AdCampaign.java new file mode 100644 index 000000000..24a6cff06 --- /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<AdCreative> 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<String> 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<AdCreative> getCreatives() { + return creatives; + } + + public void setCreatives(List<AdCreative> 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<String> getAllowedClassifications() { + return allowedClassifications; + } + + public void setAllowedClassifications(List<String> 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/AdSettlementDepositClaim.java b/backend/src/main/java/net/modtale/model/finance/AdSettlementDepositClaim.java new file mode 100644 index 000000000..88a319c91 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/AdSettlementDepositClaim.java @@ -0,0 +1,8 @@ +package net.modtale.model.finance; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Prevents one claimed deposit being allocated across unrelated staged reports. It is not proof of receipt. */ +@Document(collection = "ad_settlement_deposit_claims") +public record AdSettlementDepositClaim(@Id String id, String stageId) {} diff --git a/backend/src/main/java/net/modtale/model/finance/AdSettlementStage.java b/backend/src/main/java/net/modtale/model/finance/AdSettlementStage.java new file mode 100644 index 000000000..afb132140 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/AdSettlementStage.java @@ -0,0 +1,23 @@ +package net.modtale.model.finance; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Review evidence only. This collection is never a funding source for creator wallets. */ +@Document(collection = "ad_settlement_stages") +public record AdSettlementStage(@Id String id, String provider, String providerAccount, String reportId, + String depositId, String currency, int revision, String status, List<Snapshot> snapshots, + List<AuditEvent> audit, Instant updatedAt) { + public record Activity(String projectId, String creatorId, String title, long pageviews, long launcherDownloads, + long frontendDownloads, long apiDownloads, long provisionalPoints, long provisionalCreatorCents, + String eligibilityNote) {} + public record Snapshot(int revision, LocalDate from, LocalDate through, long reportedCollectedCents, + String reportSha256, String inputDigest, String activityDigest, int creatorShareBps, + long provisionalCreatorPoolCents, long provisionalPlatformCents, long unallocatedCreatorCents, + String activityRule, List<Activity> projects, Instant capturedAt) {} + public record AuditEvent(String operationId, String action, int revision, String actorId, String reason, + String inputDigest, Instant createdAt) {} +} diff --git a/backend/src/main/java/net/modtale/model/finance/CreatorPayoutRequest.java b/backend/src/main/java/net/modtale/model/finance/CreatorPayoutRequest.java new file mode 100644 index 000000000..d3f506664 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/CreatorPayoutRequest.java @@ -0,0 +1,91 @@ +package net.modtale.model.finance; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Durable reservation and immutable recipient snapshot for an idempotent transfer operation. */ +@Document(collection = "creator_payout_requests") +public class CreatorPayoutRequest { + public enum Status { RESERVED, PROCESSING, TRANSFERRED, REQUIRES_REVIEW, CANCELLED } + public static class Recipient { + private String userId; + private String accountId; + private long amountCents; + private String transferId; + private Instant authorizedAt; + private String correlationId; + private String confirmedBy; + private String confirmationReason; + private Instant confirmedAt; + public String getCorrelationId() { return correlationId; } + public void setCorrelationId(String value) { correlationId = value; } + public String getConfirmedBy() { return confirmedBy; } + public void setConfirmedBy(String value) { confirmedBy = value; } + public String getConfirmationReason() { return confirmationReason; } + public void setConfirmationReason(String value) { confirmationReason = value; } + public Instant getConfirmedAt() { return confirmedAt; } + public void setConfirmedAt(Instant value) { confirmedAt = value; } + public String getUserId() { return userId; } + public void setUserId(String id) { userId = id; } + public String getAccountId() { return accountId; } + public void setAccountId(String id) { accountId = id; } + public long getAmountCents() { return amountCents; } + public void setAmountCents(long amount) { amountCents = amount; } + public Instant getAuthorizedAt() { return authorizedAt; } + public void setAuthorizedAt(Instant value) { authorizedAt = value; } + public String getTransferId() { return transferId; } + public void setTransferId(String id) { transferId = id; } + } + @Id private String id; + private String creatorId; + private String requestedBy; + private String walletId; + private String currency; + private boolean testMode; + private String providerAccountId; + private String transferGroup; + private long amountCents; + private Status status = Status.RESERVED; + private Instant createdAt = Instant.now(); + private Instant firstAttemptAt; + private Instant lastDispatchAttemptAt; + private Instant completedAt; + private String reviewReason; + private List<Recipient> recipients = new ArrayList<>(); + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + public String getCreatorId() { return creatorId; } + public void setCreatorId(String id) { creatorId = id; } + public String getRequestedBy() { return requestedBy; } + public void setRequestedBy(String id) { requestedBy = id; } + public String getWalletId() { return walletId; } + public void setWalletId(String id) { walletId = id; } + public String getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public String getProviderAccountId() { return providerAccountId; } + public void setProviderAccountId(String value) { providerAccountId = value; } + public String getTransferGroup() { return transferGroup; } + public void setTransferGroup(String value) { transferGroup = value; } + public boolean isTestMode() { return testMode; } + public void setTestMode(boolean mode) { testMode = mode; } + public long getAmountCents() { return amountCents; } + public void setAmountCents(long amount) { amountCents = amount; } + public Status getStatus() { return status; } + public void setStatus(Status status) { this.status = status; } + public Instant getCreatedAt() { return createdAt; } + public void setCreatedAt(Instant value) { createdAt = value; } + public Instant getLastDispatchAttemptAt() { return lastDispatchAttemptAt; } + public void setLastDispatchAttemptAt(Instant value) { lastDispatchAttemptAt = value; } + public Instant getFirstAttemptAt() { return firstAttemptAt; } + public void setFirstAttemptAt(Instant value) { firstAttemptAt = value; } + public Instant getCompletedAt() { return completedAt; } + public void setCompletedAt(Instant value) { completedAt = value; } + public String getReviewReason() { return reviewReason; } + public void setReviewReason(String reason) { reviewReason = reason; } + public List<Recipient> getRecipients() { return recipients; } + public void setRecipients(List<Recipient> recipients) { this.recipients = recipients; } +} diff --git a/backend/src/main/java/net/modtale/model/finance/CreatorSupportSubscription.java b/backend/src/main/java/net/modtale/model/finance/CreatorSupportSubscription.java new file mode 100644 index 000000000..f6395f7d3 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/CreatorSupportSubscription.java @@ -0,0 +1,37 @@ +package net.modtale.model.finance; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +@Document(collection = "creator_support_subscriptions") +public class CreatorSupportSubscription { + @Id private String id; + private String intentId; + private String donorUserId; + private String creatorId; + private String projectId; + private String customerId; + private String providerAccountId; + private long amountCents; + private int platformCutBps; + private String currency; + private boolean testMode; + private String status; + private boolean cancelAtPeriodEnd; + private Instant updatedAt = Instant.now(); + public String getId() { return id; } public void setId(String value) { id = value; } + public String getIntentId() { return intentId; } public void setIntentId(String value) { intentId = value; } + public String getDonorUserId() { return donorUserId; } public void setDonorUserId(String value) { donorUserId = value; } + public String getCreatorId() { return creatorId; } public void setCreatorId(String value) { creatorId = value; } + public String getProjectId() { return projectId; } public void setProjectId(String value) { projectId = value; } + public String getProviderAccountId() { return providerAccountId; } public void setProviderAccountId(String value) { providerAccountId = value; } + public String getCustomerId() { return customerId; } public void setCustomerId(String value) { customerId = value; } + public long getAmountCents() { return amountCents; } public void setAmountCents(long value) { amountCents = value; } + public int getPlatformCutBps() { return platformCutBps; } public void setPlatformCutBps(int value) { platformCutBps = value; } + public String getCurrency() { return currency; } public void setCurrency(String value) { currency = value; } + public boolean isTestMode() { return testMode; } public void setTestMode(boolean value) { testMode = value; } + public String getStatus() { return status; } public void setStatus(String value) { status = value; } + public boolean isCancelAtPeriodEnd() { return cancelAtPeriodEnd; } public void setCancelAtPeriodEnd(boolean value) { cancelAtPeriodEnd = value; } + public Instant getUpdatedAt() { return updatedAt; } public void setUpdatedAt(Instant value) { updatedAt = value; } +} diff --git a/backend/src/main/java/net/modtale/model/finance/CreatorWallet.java b/backend/src/main/java/net/modtale/model/finance/CreatorWallet.java new file mode 100644 index 000000000..4dec147ab --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/CreatorWallet.java @@ -0,0 +1,37 @@ +package net.modtale.model.finance; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Materialized balance, updated in the same transaction as its immutable source-ledger entry. */ +@Document(collection = "creator_wallets") +public class CreatorWallet { + @Id private String id; + private String creatorId; + private String currency; + private boolean testMode; + private String providerAccountId; + private long availableCents; + private long reservedCents; + private boolean payoutHold; + private java.util.List<String> openRiskIds = new java.util.ArrayList<>(); + + 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 getCurrency() { return currency; } + public void setCurrency(String currency) { this.currency = currency; } + public String getProviderAccountId() { return providerAccountId; } + public void setProviderAccountId(String value) { providerAccountId = value; } + public boolean isTestMode() { return testMode; } + public void setTestMode(boolean testMode) { this.testMode = testMode; } + public long getAvailableCents() { return availableCents; } + public void setAvailableCents(long amount) { availableCents = amount; } + public long getReservedCents() { return reservedCents; } + public void setReservedCents(long amount) { reservedCents = amount; } + public boolean isPayoutHold() { return payoutHold || (openRiskIds != null && !openRiskIds.isEmpty()); } + public java.util.List<String> getOpenRiskIds() { return openRiskIds; } + public void setOpenRiskIds(java.util.List<String> ids) { openRiskIds = ids; } + public void setPayoutHold(boolean payoutHold) { this.payoutHold = payoutHold; } +} 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 000000000..648a3f003 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/DonationIntent.java @@ -0,0 +1,191 @@ +package net.modtale.model.finance; + +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.index.CompoundIndex; +import org.springframework.data.mongodb.core.index.CompoundIndexes; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.time.LocalDateTime; + +@Document(collection = "donation_intents") +@CompoundIndexes({ + @CompoundIndex(name = "project_created_idx", def = "{'projectId': 1, 'createdAt': -1}"), + @CompoundIndex(name = "status_expires_idx", def = "{'status': 1, 'expiresAt': 1}") +}) +public class DonationIntent { + + public enum DonationStatus { + PENDING, + COMPLETED, + FAILED, + EXPIRED + } + + @Id + private String id; + + @Indexed + private String projectId; + + @Indexed + private String creatorId; + private String donorUserId; + private boolean guestDonation; + + private long amountCents; + private long creatorCents; + private long platformCents; + private boolean recurring; + private int platformCutBps; + private String currency = "usd"; + + @Indexed + private DonationStatus status = DonationStatus.PENDING; + + private String stripeSessionId; + private String stripePlatformAccountId; + private Boolean stripeTestMode; + private String checkoutUrl; + + private LocalDateTime createdAt = LocalDateTime.now(); + private LocalDateTime completedAt; + private LocalDateTime expiresAt = LocalDateTime.now().plusHours(12); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getProjectId() { + return projectId; + } + + public void setProjectId(String projectId) { + this.projectId = projectId; + } + + public String getCreatorId() { + return creatorId; + } + + public void setCreatorId(String creatorId) { + this.creatorId = creatorId; + } + + public String getDonorUserId() { + return donorUserId; + } + + public void setDonorUserId(String donorUserId) { + this.donorUserId = donorUserId; + } + + public boolean isGuestDonation() { + return guestDonation; + } + + public void setGuestDonation(boolean guestDonation) { + this.guestDonation = guestDonation; + } + + public long getAmountCents() { + return amountCents; + } + + public void setAmountCents(long amountCents) { + this.amountCents = amountCents; + } + + public long getCreatorCents() { + return creatorCents; + } + + public void setCreatorCents(long creatorCents) { + this.creatorCents = creatorCents; + } + + public long getPlatformCents() { + return platformCents; + } + + public void setPlatformCents(long platformCents) { + this.platformCents = platformCents; + } + + public int getPlatformCutBps() { return platformCutBps; } + + public void setPlatformCutBps(int bps) { platformCutBps = bps; } + + 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 getStripePlatformAccountId() { return stripePlatformAccountId; } + public void setStripePlatformAccountId(String id) { stripePlatformAccountId = id; } + public Boolean getStripeTestMode() { return stripeTestMode; } + public void setStripeTestMode(Boolean testMode) { stripeTestMode = testMode; } + + 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/FinanceDisputeBalance.java b/backend/src/main/java/net/modtale/model/finance/FinanceDisputeBalance.java new file mode 100644 index 000000000..38d01b581 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/FinanceDisputeBalance.java @@ -0,0 +1,4 @@ +package net.modtale.model.finance; + +/** Minimal non-secret provider balance evidence used in a dispute's immutable review digest. */ +public record FinanceDisputeBalance(String id, String source, String type, String currency, String status, Long amount, Long fee, Long net) {} diff --git a/backend/src/main/java/net/modtale/model/finance/FinanceDisputeCase.java b/backend/src/main/java/net/modtale/model/finance/FinanceDisputeCase.java new file mode 100644 index 000000000..54e0375ba --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/FinanceDisputeCase.java @@ -0,0 +1,15 @@ +package net.modtale.model.finance; + +import java.time.Instant; +import java.util.List; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.Version; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Contains reconciliation facts only, never evidence documents, customer identity, or card data. */ +@Document(collection = "finance_dispute_cases") +public record FinanceDisputeCase(@Id String id, String disputeId, String chargeId, String creatorId, + String currency, boolean testMode, String providerStatus, long disputedCents, Long actualFeeCents, + String reviewStatus, Instant updatedAt, String providerAccountId, String originalCreditId, + Long principalMovementCents, long returnedPrincipalCents, boolean evidenceReady, String evidenceDigest, + List<FinanceDisputeBalance> balanceTransactions, @Version Long version) {} diff --git a/backend/src/main/java/net/modtale/model/finance/FinanceDisputeResolution.java b/backend/src/main/java/net/modtale/model/finance/FinanceDisputeResolution.java new file mode 100644 index 000000000..2360a499b --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/FinanceDisputeResolution.java @@ -0,0 +1,12 @@ +package net.modtale.model.finance; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Immutable, evidence-bound operator decision. Fee amounts are cumulative targets, not repeated charges. */ +@Document(collection = "finance_dispute_resolutions") +public record FinanceDisputeResolution(@Id String id, String caseId, String disputeId, String evidenceDigest, + String providerAccountId, boolean testMode, String providerStatus, long actualFeeCents, + long creatorFeeCents, String reviewerId, String reason, Instant createdAt, String currency, long disputedCents, + long principalMovementCents, long returnedPrincipalCents, java.util.List<FinanceDisputeBalance> balanceTransactions) {} 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 000000000..8668816bd --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/FinanceLedgerEntry.java @@ -0,0 +1,218 @@ +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, + REFUND_ADJUSTMENT, + DISPUTE_ADJUSTMENT, + DISPUTE_FEE_ADJUSTMENT, + DISPUTE_REVERSAL, + 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 Long processorFeeCents; + private Long creatorGrossCents; + 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<String, String> 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 Long getProcessorFeeCents() { return processorFeeCents; } + + public void setProcessorFeeCents(Long cents) { processorFeeCents = cents; } + + public Long getCreatorGrossCents() { return creatorGrossCents; } + + public void setCreatorGrossCents(Long cents) { creatorGrossCents = cents; } + + 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<String, String> getMetadata() { + return metadata; + } + + public void setMetadata(Map<String, String> metadata) { + this.metadata = metadata; + } +} diff --git a/backend/src/main/java/net/modtale/model/finance/FinanceTransferReceipt.java b/backend/src/main/java/net/modtale/model/finance/FinanceTransferReceipt.java new file mode 100644 index 000000000..ac898143d --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/FinanceTransferReceipt.java @@ -0,0 +1,11 @@ +package net.modtale.model.finance; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Unique provider/account/mode reference claims a transfer for exactly one reserved recipient. */ +@Document(collection = "finance_transfer_receipts") +public record FinanceTransferReceipt(@Id String id, String payoutRequestId, int recipientIndex, + String transferId, String providerAccountId, boolean testMode, String destination, + long amountCents, String currency, String confirmedBy, String confirmationReason, Instant confirmedAt) {} diff --git a/backend/src/main/java/net/modtale/model/finance/PaymentWebhookReceipt.java b/backend/src/main/java/net/modtale/model/finance/PaymentWebhookReceipt.java new file mode 100644 index 000000000..6e924454e --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/PaymentWebhookReceipt.java @@ -0,0 +1,9 @@ +package net.modtale.model.finance; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Minimal event receipt; payment/contact data and raw webhook bodies are never stored here. */ +@Document(collection = "payment_webhook_receipts") +public record PaymentWebhookReceipt(@Id String id, String type, Instant processedAt) {} 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 000000000..f17d9af8a --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/PlatformFinanceSettings.java @@ -0,0 +1,108 @@ +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 monetizationPolicyVersion; + private int adCreatorSplitBps = 7500; + private int donationPlatformCutBps = 1000; + private int fundExpiryDays = 0; + private int defaultAdRevenuePerClickCents = 3; + private int minPayoutCents = 1000; + private boolean adTestModeEnabled = false; + private boolean mockStripeEnabled = false; + private String currency = "usd"; + private LocalDateTime updatedAt = LocalDateTime.now(); + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public int getMonetizationPolicyVersion() { return monetizationPolicyVersion; } + + public void setMonetizationPolicyVersion(int version) { monetizationPolicyVersion = version; } + + 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 = 0; + } + + public int getDefaultAdRevenuePerClickCents() { + return defaultAdRevenuePerClickCents; + } + + public void setDefaultAdRevenuePerClickCents(int defaultAdRevenuePerClickCents) { + this.defaultAdRevenuePerClickCents = Math.max(0, defaultAdRevenuePerClickCents); + } + + public int getMinPayoutCents() { + return minPayoutCents; + } + + public void setMinPayoutCents(int minPayoutCents) { + this.minPayoutCents = Math.max(100, minPayoutCents); + } + + public boolean isAdTestModeEnabled() { + return adTestModeEnabled; + } + + public void setAdTestModeEnabled(boolean adTestModeEnabled) { + this.adTestModeEnabled = adTestModeEnabled; + } + + public boolean isMockStripeEnabled() { + return mockStripeEnabled; + } + + public void setMockStripeEnabled(boolean mockStripeEnabled) { + this.mockStripeEnabled = mockStripeEnabled; + } + + public String getCurrency() { + return currency; + } + + public void setCurrency(String currency) { + this.currency = currency; + } + + public LocalDateTime getUpdatedAt() { + return updatedAt; + } + + public void setUpdatedAt(LocalDateTime updatedAt) { + this.updatedAt = updatedAt; + } +} diff --git a/backend/src/main/java/net/modtale/model/finance/ProviderCostEvidence.java b/backend/src/main/java/net/modtale/model/finance/ProviderCostEvidence.java new file mode 100644 index 000000000..9f95938e9 --- /dev/null +++ b/backend/src/main/java/net/modtale/model/finance/ProviderCostEvidence.java @@ -0,0 +1,13 @@ +package net.modtale.model.finance; + +import java.time.Instant; +import org.springframework.data.annotation.Id; +import org.springframework.data.mongodb.core.mapping.Document; + +/** Immutable provider cost evidence. It has no creator attribution or wallet effect. */ +@Document(collection = "provider_cost_evidence") +public record ProviderCostEvidence(@Id String id, String providerAccountId, boolean testMode, + String balanceTransactionId, String currency, String providerType, String reportingCategory, String source, + long amountMinorUnits, long feeMinorUnits, long netMinorUnits, long costMinorUnits, + Instant providerCreatedAt, Instant availableAt, String apiVersion, String evidenceDigest, + String allocationStatus, String recordedBy, String reason, Instant recordedAt) {} 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 b5511bb0c..b2414f2d7 100644 --- a/backend/src/main/java/net/modtale/model/project/Project.java +++ b/backend/src/main/java/net/modtale/model/project/Project.java @@ -187,6 +187,11 @@ public ProjectMember(String userId, String roleId) { private List<String> childProjectIds; 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; @@ -290,6 +295,16 @@ public Project() {} public void setAllowModpacks(boolean allowModpacks) { this.allowModpacks = allowModpacks; } public boolean isAllowComments() { return allowComments; } public void setAllowComments(boolean allowComments) { this.allowComments = allowComments; } + public boolean isAdsEnabled() { return adsEnabled; } + public void setAdsEnabled(boolean adsEnabled) { this.adsEnabled = adsEnabled; } + public boolean isDonationsEnabled() { return donationsEnabled; } + public void setDonationsEnabled(boolean donationsEnabled) { this.donationsEnabled = donationsEnabled; } + public int getSuggestedDonationCents() { return suggestedDonationCents; } + public void setSuggestedDonationCents(int suggestedDonationCents) { this.suggestedDonationCents = suggestedDonationCents; } + public boolean isDonationRecurringDefault() { return donationRecurringDefault; } + public void setDonationRecurringDefault(boolean donationRecurringDefault) { this.donationRecurringDefault = donationRecurringDefault; } + public int getDonationPlatformCutBps() { return donationPlatformCutBps; } + public void setDonationPlatformCutBps(int donationPlatformCutBps) { this.donationPlatformCutBps = Math.max(0, Math.min(10000, donationPlatformCutBps)); } public boolean isHmWikiEnabled() { return hmWikiEnabled; } public void setHmWikiEnabled(boolean hmWikiEnabled) { this.hmWikiEnabled = hmWikiEnabled; } public String getHmWikiSlug() { return hmWikiSlug; } diff --git a/backend/src/main/java/net/modtale/model/user/AdminPermission.java b/backend/src/main/java/net/modtale/model/user/AdminPermission.java index 4c88d57f4..8d79c5adc 100644 --- a/backend/src/main/java/net/modtale/model/user/AdminPermission.java +++ b/backend/src/main/java/net/modtale/model/user/AdminPermission.java @@ -25,6 +25,7 @@ public enum AdminPermission { USER_RAW_EDIT, AUDIT_LOG_READ, PLATFORM_ANALYTICS_READ, + PLATFORM_FINANCE_MANAGE, STATUS_INCIDENT_READ, STATUS_INCIDENT_MANAGE, NEWS_MANAGE; 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 3b22a8f31..fe584ee9a 100644 --- a/backend/src/main/java/net/modtale/model/user/User.java +++ b/backend/src/main/java/net/modtale/model/user/User.java @@ -85,6 +85,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<OrgPayoutShare> orgPayoutShares = new ArrayList<>(); + public User() { this.id = UUID.randomUUID().toString(); this.tier = ApiKey.Tier.USER; @@ -110,6 +117,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; @@ -221,6 +233,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 static class ProfileBadge implements Serializable { private static final long serialVersionUID = 1L; @@ -352,4 +382,20 @@ public void setAdminPermissions(Set<AdminPermission> adminPermissions) { public LocalDateTime getGitlabTokenExpiresAt() { return gitlabTokenExpiresAt; } public void setGitlabTokenExpiresAt(LocalDateTime gitlabTokenExpiresAt) { this.gitlabTokenExpiresAt = gitlabTokenExpiresAt; } + public String getStripeConnectAccountId() { return stripeConnectAccountId; } + public void setStripeConnectAccountId(String stripeConnectAccountId) { this.stripeConnectAccountId = stripeConnectAccountId; } + + public boolean isStripeOnboardingComplete() { return stripeOnboardingComplete; } + public void setStripeOnboardingComplete(boolean stripeOnboardingComplete) { this.stripeOnboardingComplete = stripeOnboardingComplete; } + + public boolean isStripePayoutsEnabled() { return stripePayoutsEnabled; } + public void setStripePayoutsEnabled(boolean stripePayoutsEnabled) { this.stripePayoutsEnabled = stripePayoutsEnabled; } + + public String getStripeAccountCountry() { return stripeAccountCountry; } + public void setStripeAccountCountry(String stripeAccountCountry) { this.stripeAccountCountry = stripeAccountCountry; } + + public OrgPayoutMode getOrgPayoutMode() { return orgPayoutMode == null ? OrgPayoutMode.DIRECT_TO_ORG_STRIPE : orgPayoutMode; } + public void setOrgPayoutMode(OrgPayoutMode orgPayoutMode) { this.orgPayoutMode = orgPayoutMode; } + public List<OrgPayoutShare> getOrgPayoutShares() { return orgPayoutShares; } + public void setOrgPayoutShares(List<OrgPayoutShare> 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 000000000..97b2c1353 --- /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<AdCampaign, String> { + List<AdCampaign> findByActiveTrue(); + List<AdCampaign> findByActiveTrueAndTestCampaignFalse(); + List<AdCampaign> findByActiveTrueAndTestCampaignTrue(); +} diff --git a/backend/src/main/java/net/modtale/repository/finance/CreatorSupportSubscriptionRepository.java b/backend/src/main/java/net/modtale/repository/finance/CreatorSupportSubscriptionRepository.java new file mode 100644 index 000000000..3e424ded5 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/finance/CreatorSupportSubscriptionRepository.java @@ -0,0 +1,17 @@ +package net.modtale.repository.finance; + +import java.util.List; +import java.time.Instant; +import org.springframework.data.mongodb.repository.Query; +import org.springframework.data.mongodb.repository.Update; +import net.modtale.model.finance.CreatorSupportSubscription; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface CreatorSupportSubscriptionRepository extends MongoRepository<CreatorSupportSubscription, String> { + List<CreatorSupportSubscription> findByDonorUserId(String donorUserId); + + @Query("{'_id': ?0, 'updatedAt': ?1, 'providerAccountId': ?2, 'testMode': ?3, 'customerId': ?4}") + @Update("{'$set': {'status': ?5, 'cancelAtPeriodEnd': ?6, 'updatedAt': ?7}}") + long updateProviderState(String id, Instant expectedUpdatedAt, String accountId, boolean testMode, String customerId, + String status, boolean cancelAtPeriodEnd, Instant updatedAt); +} 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 000000000..852ac9902 --- /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<DonationIntent, String> { + Optional<DonationIntent> findByStripeSessionId(String stripeSessionId); + List<DonationIntent> 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 000000000..230271229 --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/finance/FinanceLedgerEntryRepository.java @@ -0,0 +1,16 @@ +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<FinanceLedgerEntry, String> { + List<FinanceLedgerEntry> findTop100ByTypeAndStatusOrderByCreatedAtAsc(FinanceLedgerEntry.LedgerType type, FinanceLedgerEntry.EntryStatus status); + List<FinanceLedgerEntry> findByCreatorId(String creatorId); + List<FinanceLedgerEntry> findByCreatorIdAndStatus(String creatorId, FinanceLedgerEntry.EntryStatus status); + List<FinanceLedgerEntry> findByCreatorIdAndStatusOrderByCreatedAtAsc(String creatorId, FinanceLedgerEntry.EntryStatus status); + List<FinanceLedgerEntry> findByStatusAndExpiresAtBefore(FinanceLedgerEntry.EntryStatus status, LocalDateTime cutoff); + List<FinanceLedgerEntry> findByCreatedAtBetween(LocalDateTime start, LocalDateTime end); +} diff --git a/backend/src/main/java/net/modtale/repository/finance/PaymentWebhookReceiptRepository.java b/backend/src/main/java/net/modtale/repository/finance/PaymentWebhookReceiptRepository.java new file mode 100644 index 000000000..8ff065d6a --- /dev/null +++ b/backend/src/main/java/net/modtale/repository/finance/PaymentWebhookReceiptRepository.java @@ -0,0 +1,6 @@ +package net.modtale.repository.finance; + +import net.modtale.model.finance.PaymentWebhookReceipt; +import org.springframework.data.mongodb.repository.MongoRepository; + +public interface PaymentWebhookReceiptRepository extends MongoRepository<PaymentWebhookReceipt, String> {} 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 000000000..d55c04825 --- /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<PlatformFinanceSettings, String> { +} diff --git a/backend/src/main/java/net/modtale/service/analytics/TrackingFlushService.java b/backend/src/main/java/net/modtale/service/analytics/TrackingFlushService.java index 7ccd9e5fe..6961cec64 100644 --- a/backend/src/main/java/net/modtale/service/analytics/TrackingFlushService.java +++ b/backend/src/main/java/net/modtale/service/analytics/TrackingFlushService.java @@ -145,6 +145,7 @@ private boolean flushMonthlyStats() { for (TrackingBufferService.DownloadEvent event : batch.downloads()) { ProjectAgg projectAgg = projectAggs.computeIfAbsent(event.projectId(), ignored -> new ProjectAgg(event.authorId())); projectAgg.total++; + if (event.isLauncher()) projectAgg.launcher++; platformAgg.total++; if (event.isApi()) { @@ -175,9 +176,11 @@ private boolean flushMonthlyStats() { .inc("totalDownloads", aggregate.total) .inc("apiDownloads", aggregate.api) .inc("frontendDownloads", aggregate.frontend) + .inc("launcherDownloads", aggregate.launcher) .inc("days." + day + ".d", aggregate.total) .inc("days." + day + ".a", aggregate.api) - .inc("days." + day + ".f", aggregate.frontend); + .inc("days." + day + ".f", aggregate.frontend) + .inc("days." + day + ".l", aggregate.launcher); for (Map.Entry<String, Integer> versionEntry : aggregate.versions.entrySet()) { update.inc("versionDownloads." + versionEntry.getKey().replace(".", "_") + "." + day, versionEntry.getValue()); @@ -278,6 +281,7 @@ private void clearCache(String cacheName) { private static class ProjectAgg { private final String authorId; + private int launcher = 0; private int total = 0; private int api = 0; private int frontend = 0; diff --git a/backend/src/main/java/net/modtale/service/finance/AdCampaignService.java b/backend/src/main/java/net/modtale/service/finance/AdCampaignService.java new file mode 100644 index 000000000..ac918f012 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/AdCampaignService.java @@ -0,0 +1,278 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.AdCampaign; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.model.project.Project; +import net.modtale.repository.finance.AdCampaignRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import net.modtale.service.project.query.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +public class AdCampaignService { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private AdCampaignRepository adCampaignRepository; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private ProjectService projectService; + @Autowired private RevenueOpsSupport core; + + @Value("${app.finance.ads.test-mode-enabled:false}") + private boolean defaultAdTestModeEnabled; + + public Map<String, Object> getAdSlotForProject(String projectId, String placementRaw) { + if (defaultAdTestModeEnabled) { + Map<String, Object> 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<String, Object> getTestAdSlotForProject(String projectId, String placementRaw) { + if (!defaultAdTestModeEnabled) { + return Map.of("enabled", false, "reason", "TEST_MODE_DISABLED"); + } + return resolveAdSlot(projectId, true, placementRaw); + } + + public List<Map<String, Object>> getAdCampaigns() { + return adCampaignRepository.findAll().stream() + .sorted(Comparator.comparing(AdCampaign::getUpdatedAt, Comparator.nullsLast(LocalDateTime::compareTo)).reversed()) + .map(core::toCampaignMap) + .collect(Collectors.toList()); + } + + public Map<String, Object> createAdCampaign(Map<String, Object> payload) { + AdCampaign campaign = new AdCampaign(); + applyCampaignPayload(campaign, payload, true); + campaign.setCreatedAt(LocalDateTime.now()); + campaign.setUpdatedAt(LocalDateTime.now()); + return core.toCampaignMap(adCampaignRepository.save(campaign)); + } + + public Map<String, Object> updateAdCampaign(String campaignId, Map<String, Object> payload) { + AdCampaign campaign = adCampaignRepository.findById(campaignId) + .orElseThrow(() -> new IllegalArgumentException("Ad campaign not found")); + applyCampaignPayload(campaign, payload, false); + campaign.setUpdatedAt(LocalDateTime.now()); + return core.toCampaignMap(adCampaignRepository.save(campaign)); + } + + public Map<String, Object> setCampaignActiveState(String campaignId, boolean active) { + AdCampaign campaign = adCampaignRepository.findById(campaignId) + .orElseThrow(() -> new IllegalArgumentException("Ad campaign not found")); + campaign.setActive(active); + campaign.setUpdatedAt(LocalDateTime.now()); + return core.toCampaignMap(adCampaignRepository.save(campaign)); + } + + public void trackAdImpression(String campaignId, String projectId, String clientIp) { + AdCampaign campaign = adCampaignRepository.findById(campaignId).orElse(null); + Project project = projectService.getProjectById(projectId); + if (!isEligible(campaign, project) || campaign.isTestCampaign()) return; + if (!core.shouldTrackEvent("impression", campaignId, projectId, clientIp)) return; + + FinanceLedgerEntry entry = new FinanceLedgerEntry(); + entry.setCreatorId(core.resolveCreatorId(projectId)); + entry.setProjectId(projectId); + entry.setType(FinanceLedgerEntry.LedgerType.AD_IMPRESSION); + entry.setGrossCents(0); + entry.setCreatorCents(0); + entry.setPlatformCents(0); + entry.setCurrency(financeAccountService.getSettings().getCurrency()); + entry.setStatus(FinanceLedgerEntry.EntryStatus.PAID); + entry.setCreatedAt(LocalDateTime.now()); + entry.setAvailableAt(LocalDateTime.now()); + entry.setCompletedAt(LocalDateTime.now()); + entry.getMetadata().put("campaignId", campaignId); + entry.getMetadata().put("tracked", "engagement_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")); + + Project project = projectService.getProjectById(projectId); + if (!isEligible(campaign, project)) throw new IllegalArgumentException("This sponsored placement is unavailable."); + core.requireSafeExternalUrl(campaign.getTargetUrl()); + String targetUrl = core.appendAffiliateParams(campaign.getTargetUrl(), campaign.getAffiliateParam(), campaign.getAffiliateCode()); + if (campaign.isTestCampaign()) return targetUrl; + + if (!core.shouldTrackEvent("click", campaignId, projectId, clientIp)) { + return targetUrl; + } + + PlatformFinanceSettings settings = financeAccountService.getSettings(); + // Engagement is not proof of paid revenue. Only reconciled provider settlements fund earnings. + FinanceLedgerEntry entry = new FinanceLedgerEntry(); + entry.setCreatorId(project.getAuthorId()); + entry.setProjectId(project.getId()); + entry.setType(FinanceLedgerEntry.LedgerType.AD_CLICK); + entry.setGrossCents(0); + entry.setCreatorCents(0); + entry.setPlatformCents(0); + entry.setCurrency(settings.getCurrency()); + entry.setStatus(FinanceLedgerEntry.EntryStatus.PAID); + entry.setCreatedAt(LocalDateTime.now()); + entry.setAvailableAt(LocalDateTime.now()); + entry.setCompletedAt(LocalDateTime.now()); + entry.getMetadata().put("campaignId", campaign.getId()); + entry.getMetadata().put("providerType", campaign.getProviderType().name()); + entry.getMetadata().put("tracked", "engagement_only"); + ledgerRepository.save(entry); + + return targetUrl; + } + + private Map<String, Object> 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<AdCampaign> activeCampaigns = testOnly + ? adCampaignRepository.findByActiveTrueAndTestCampaignTrue() + : adCampaignRepository.findByActiveTrueAndTestCampaignFalse(); + + AdCampaign.AdPlacement placement = core.parsePlacement(placementRaw); + + List<AdCampaign> candidates = activeCampaigns.stream() + .filter(campaign -> isEligible(campaign, project)) + .filter(campaign -> campaign.getAllowedClassifications() == null + || campaign.getAllowedClassifications().isEmpty() + || (project.getClassification() != null && campaign.getAllowedClassifications().contains(project.getClassification().name()))) + .filter(campaign -> !testOnly || core.hasRenderableCreativeForPlacement(campaign, placement)) + .collect(Collectors.toList()); + + if (candidates.isEmpty()) { + return Map.of("enabled", false, "reason", "NO_ACTIVE_CAMPAIGNS"); + } + + AdCampaign chosen = core.weightedPick(candidates); + AdCampaign.AdCreative creative = core.chooseCreative(chosen, placement); + String imageUrl = creative != null && creative.getImageUrl() != null && !creative.getImageUrl().isBlank() + ? creative.getImageUrl() + : chosen.getImageUrl(); + + Map<String, Object> 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", "Sponsored link. Modtale records aggregate engagement; the sponsor’s privacy policy applies after you leave."); + ad.put("creatorRevenueSharePercent", financeAccountService.getSettings().getAdCreatorSplitBps() / 100.0); + return ad; + } + + private void applyCampaignPayload(AdCampaign campaign, Map<String, Object> payload, boolean isCreate) { + if (payload == null) { + throw new IllegalArgumentException("Campaign payload is required."); + } + + String name = core.asString(payload.get("name")); + if (isCreate && (name == null || name.isBlank())) { + throw new IllegalArgumentException("Campaign name is required."); + } + if (name != null && !name.isBlank()) campaign.setName(name); + + String providerTypeRaw = core.asString(payload.get("providerType")); + if (providerTypeRaw != null && !providerTypeRaw.isBlank()) { + try { + campaign.setProviderType(AdCampaign.ProviderType.valueOf(providerTypeRaw)); + } catch (IllegalArgumentException ignored) { + throw new IllegalArgumentException("Invalid campaign provider type."); + } + } + + if (payload.containsKey("providerName")) campaign.setProviderName(core.asString(payload.get("providerName"))); + if (payload.containsKey("providerPlacementKey")) campaign.setProviderPlacementKey(core.asString(payload.get("providerPlacementKey"))); + if (payload.containsKey("sponsorName")) campaign.setSponsorName(core.asString(payload.get("sponsorName"))); + if (payload.containsKey("headline")) campaign.setHeadline(core.asString(payload.get("headline"))); + if (payload.containsKey("body")) campaign.setBody(core.asString(payload.get("body"))); + if (payload.containsKey("callToAction")) campaign.setCallToAction(core.asString(payload.get("callToAction"))); + if (payload.containsKey("imageUrl")) campaign.setImageUrl(core.asString(payload.get("imageUrl"))); + if (payload.containsKey("targetUrl")) campaign.setTargetUrl(core.asString(payload.get("targetUrl"))); + if (payload.containsKey("affiliateParam")) campaign.setAffiliateParam(core.asString(payload.get("affiliateParam"))); + if (payload.containsKey("affiliateCode")) campaign.setAffiliateCode(core.asString(payload.get("affiliateCode"))); + if (payload.containsKey("active")) campaign.setActive(core.asBoolean(payload.get("active"))); + if (payload.containsKey("privacyRespecting")) campaign.setPrivacyRespecting(core.asBoolean(payload.get("privacyRespecting"))); + if (payload.containsKey("nonIntrusive")) campaign.setNonIntrusive(core.asBoolean(payload.get("nonIntrusive"))); + if (payload.containsKey("testCampaign")) campaign.setTestCampaign(core.asBoolean(payload.get("testCampaign"))); + if (payload.containsKey("baseRevenuePerClickCents")) campaign.setBaseRevenuePerClickCents(Math.max(0, core.asInt(payload.get("baseRevenuePerClickCents"), campaign.getBaseRevenuePerClickCents()))); + if (payload.containsKey("weight")) campaign.setWeight(Math.max(1, core.asInt(payload.get("weight"), campaign.getWeight()))); + + if (payload.containsKey("allowedClassifications")) { + Object raw = payload.get("allowedClassifications"); + List<String> 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<AdCampaign.AdCreative> creatives = new ArrayList<>(); + if (raw instanceof List<?> list) { + for (Object entry : list) { + if (!(entry instanceof Map<?, ?> map)) continue; + String imageUrl = core.asString(map.get("imageUrl")); + if (imageUrl == null || imageUrl.isBlank()) continue; + AdCampaign.AdCreative creative = new AdCampaign.AdCreative(); + creative.setImageUrl(imageUrl); + creative.setAltText(core.asString(map.get("altText"))); + try { + String placementRaw = core.asString(map.get("placement")); + if (placementRaw != null && !placementRaw.isBlank()) { + creative.setPlacement(AdCampaign.AdPlacement.valueOf(placementRaw.trim().toUpperCase())); + } + } catch (Exception ignored) {} + creatives.add(creative); + } + } + campaign.setCreatives(creatives); + } + core.requireSafeExternalUrl(campaign.getTargetUrl()); + if (campaign.getImageUrl() != null && !campaign.getImageUrl().isBlank()) core.requireSafeExternalUrl(campaign.getImageUrl()); + for (AdCampaign.AdCreative creative : campaign.getCreatives()) core.requireSafeExternalUrl(creative.getImageUrl()); + } + + private boolean isEligible(AdCampaign campaign, Project project) { + return campaign != null && campaign.isActive() && campaign.isPrivacyRespecting() && campaign.isNonIntrusive() + && (!campaign.isTestCampaign() || defaultAdTestModeEnabled) + && project != null && project.isAdsEnabled() + && (campaign.getAllowedClassifications() == null || campaign.getAllowedClassifications().isEmpty() + || (project.getClassification() != null && campaign.getAllowedClassifications().contains(project.getClassification().name()))); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/AdSettlementActivityService.java b/backend/src/main/java/net/modtale/service/finance/AdSettlementActivityService.java new file mode 100644 index 000000000..0d53adf2f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/AdSettlementActivityService.java @@ -0,0 +1,90 @@ +package net.modtale.service.finance; + +import java.time.LocalDate; +import java.time.YearMonth; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.stream.Collectors; +import net.modtale.model.finance.AdSettlementStage.Activity; +import net.modtale.model.project.Project; +import net.modtale.model.project.ProjectStatus; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import org.bson.Document; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; + +/** Existing analytics are provisional, debounced inputs, not verified ad eligibility or bot-free traffic. */ +@Service +public class AdSettlementActivityService { + public static final String RULE = "provisional-pageviews-plus-launcher-downloads-v1"; + private final MongoTemplate mongo; + private final ProjectRepository projects; + private final UserRepository users; + public AdSettlementActivityService(MongoTemplate mongo, ProjectRepository projects, UserRepository users) { + this.mongo = mongo; this.projects = projects; this.users = users; + } + + public List<Activity> snapshot(LocalDate from, LocalDate through) { + List<Criteria> months = new ArrayList<>(); + for (YearMonth month = YearMonth.from(from); !month.isAfter(YearMonth.from(through)); month = month.plusMonths(1)) { + months.add(Criteria.where("year").is(month.getYear()).and("month").is(month.getMonthValue())); + } + Query query = Query.query(new Criteria().orOperator(months)).limit(13001); + query.fields().include("projectId").include("authorId").include("year").include("month").include("days"); + List<Document> sources = mongo.find(query, Document.class, "project_monthly_stats"); + if (sources.size() > 13000) throw new IllegalArgumentException("The activity window exceeds the bounded review size."); + Map<String, long[]> totals = new TreeMap<>(); + Map<String, Set<String>> sourceOwners = new HashMap<>(); + for (Document source : sources) { + String projectId = source.getString("projectId"); + if (projectId == null || !(source.get("days") instanceof Map<?, ?> days)) continue; + YearMonth month = YearMonth.of(((Number) source.get("year")).intValue(), ((Number) source.get("month")).intValue()); + for (int day = 1; day <= month.lengthOfMonth(); day++) { + LocalDate date = month.atDay(day); + if (date.isBefore(from) || date.isAfter(through) || !(days.get(String.valueOf(day)) instanceof Map<?, ?> counts)) continue; + long[] row = totals.computeIfAbsent(projectId, key -> new long[4]); + String[] fields = {"v", "l", "f", "a"}; + for (int i = 0; i < fields.length; i++) row[i] = Math.addExact(row[i], count(counts.get(fields[i]))); + } + sourceOwners.computeIfAbsent(projectId, key -> new java.util.HashSet<>()).add(source.getString("authorId")); + } + if (totals.size() > 1000) throw new IllegalArgumentException("More than 1000 projects requires a separately reviewed reporting batch."); + Map<String, Project> byId = new HashMap<>(); + projects.findAllById(totals.keySet()).forEach(project -> byId.put(project.getId(), project)); + Set<String> ownerIds = byId.values().stream().map(Project::getAuthorId).filter(java.util.Objects::nonNull).collect(Collectors.toSet()); + Set<String> activeOwners = new java.util.HashSet<>(); + users.findAllById(ownerIds).forEach(user -> { if (!user.isDeleted()) activeOwners.add(user.getId()); }); + List<Activity> result = new ArrayList<>(); + totals.forEach((projectId, counts) -> { + Project project = byId.get(projectId); + String note = "Provisional: fraud filtering and historical opt-in still require validation."; + boolean eligible = true; + if (project == null || project.getDeletedAt() != null || project.getStatus() == null || !Set.of(ProjectStatus.PUBLISHED, ProjectStatus.UNLISTED).contains(project.getStatus())) { + eligible = false; note = "Excluded: project is unavailable or not published."; + } else if (!project.isAdsEnabled()) { eligible = false; note = "Excluded: creator has not opted into ads."; } + else if (!activeOwners.contains(project.getAuthorId())) { eligible = false; note = "Excluded: creator account is unavailable."; } + else if (sourceOwners.get(projectId).size() != 1 || !sourceOwners.get(projectId).contains(project.getAuthorId())) { + eligible = false; note = "Excluded: historical owner identity needs review."; + } + result.add(new Activity(projectId, project == null ? null : project.getAuthorId(), project == null ? "Unavailable project" : project.getTitle(), + counts[0], counts[1], counts[2], counts[3], eligible ? Math.addExact(counts[0], counts[1]) : 0, 0, note)); + }); + return List.copyOf(result); + } + private static long count(Object value) { + if (value == null) return 0; + if (!(value instanceof Number)) throw new IllegalArgumentException("Analytics contains an invalid count."); + long count; + try { count = new java.math.BigDecimal(value.toString()).longValueExact(); } + catch (ArithmeticException invalid) { throw new IllegalArgumentException("Analytics contains an invalid count."); } + if (count < 0) throw new IllegalArgumentException("Analytics contains a negative count."); + return count; + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/AdSettlementStagingService.java b/backend/src/main/java/net/modtale/service/finance/AdSettlementStagingService.java new file mode 100644 index 000000000..980866f0d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/AdSettlementStagingService.java @@ -0,0 +1,158 @@ +package net.modtale.service.finance; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import net.modtale.model.dto.request.finance.StageAdSettlementRequest; +import net.modtale.model.finance.AdSettlementDepositClaim; +import net.modtale.model.finance.AdSettlementStage; +import net.modtale.model.finance.AdSettlementStage.*; +import net.modtale.model.user.AdminPermission; +import net.modtale.model.user.User; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.MongoTransactionManager; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +/** Review-only: no provider calls, wallet dependency, funding flag or release endpoint. */ +@Service +public class AdSettlementStagingService { + private static final ObjectMapper JSON = new ObjectMapper().findAndRegisterModules(); + private final MongoTemplate mongo; + private final AdSettlementActivityService activity; + private final TransactionTemplate transactions; + public AdSettlementStagingService(MongoTemplate mongo, MongoDatabaseFactory factory, AdSettlementActivityService activity) { + this.mongo = mongo; this.activity = activity; + transactions = new TransactionTemplate(new MongoTransactionManager(factory)); + } + public record Amendment(int expectedRevision, String operationId, StageAdSettlementRequest report, String reason) {} + public record Review(int expectedRevision, String operationId, String decision, String reason) {} + public List<Map<String, Object>> list(User actor) { + requireReviewer(actor); + return mongo.find(new Query().with(Sort.by(Sort.Direction.DESC, "updatedAt")).limit(30), AdSettlementStage.class).stream().map(stage -> { + Snapshot snapshot = stage.snapshots().getLast(); + Map<String, Object> row = new LinkedHashMap<>(); + row.put("id", stage.id()); row.put("provider", stage.provider()); row.put("reportId", stage.reportId()); + row.put("currency", stage.currency()); row.put("revision", stage.revision()); row.put("status", stage.status()); + row.put("reportedCollectedCents", snapshot.reportedCollectedCents()); row.put("from", snapshot.from()); row.put("through", snapshot.through()); + row.put("fundingVerified", false); row.put("creditReleaseEnabled", false); return row; + }).toList(); + } + public AdSettlementStage get(User actor, String id) { + requireReviewer(actor); + AdSettlementStage stage = mongo.findById(id, AdSettlementStage.class); + if (stage == null) throw new IllegalArgumentException("Staged report not found."); + return stage; + } + public AdSettlementStage create(User actor, StageAdSettlementRequest input) { + requireReviewer(actor); validate(input); + String id = sourceId("report", input.provider(), input.providerAccount(), input.reportId()); + String digest = digest(input); + AdSettlementStage existing = mongo.findById(id, AdSettlementStage.class); + if (existing != null) return replay(existing, digest); + Snapshot snapshot = snapshot(input, 1); + AdSettlementStage stage = new AdSettlementStage(id, input.provider(), input.providerAccount(), input.reportId(), input.depositId(), input.currency(), + 1, "AWAITING_REVIEW", List.of(snapshot), List.of(new AuditEvent("create", "STAGED", 1, actor.getId(), + "Reported deposit and activity remain unverified.", digest, Instant.now())), Instant.now()); + try { + return transactions.execute(status -> { + mongo.insert(new AdSettlementDepositClaim(sourceId("deposit", input.provider(), input.providerAccount(), input.depositId()), id)); + return mongo.insert(stage); + }); + } catch (DuplicateKeyException duplicate) { + existing = mongo.findById(id, AdSettlementStage.class); + if (existing != null) return replay(existing, digest); + throw new IllegalArgumentException("This deposit identity is already claimed by another staged report."); + } + } + public AdSettlementStage amend(User actor, String id, Amendment amendment) { + AdSettlementStage stage = get(actor, id); + if (amendment == null) throw new IllegalArgumentException("Amendment required."); + validate(amendment.report()); requireOperation(amendment.operationId()); requireReason(amendment.reason()); + StageAdSettlementRequest input = amendment.report(); + if (!stage.provider().equals(input.provider()) || !stage.providerAccount().equals(input.providerAccount()) || !stage.reportId().equals(input.reportId()) + || !stage.depositId().equals(input.depositId()) || !stage.currency().equals(input.currency())) throw new IllegalArgumentException("Report, deposit, account and currency identities cannot be changed by an amendment."); + String digest = digest(amendment); + if (hasOperation(stage, amendment.operationId(), digest)) return stage; + if (stage.revision() != amendment.expectedRevision() || stage.revision() >= 10) throw new IllegalArgumentException("Reload the report before amending; at most 10 immutable revisions are supported."); + Snapshot snapshot = snapshot(input, stage.revision() + 1); + AuditEvent event = new AuditEvent(amendment.operationId(), "AMENDED", snapshot.revision(), actor.getId(), amendment.reason(), digest, Instant.now()); + var result = mongo.updateFirst(Query.query(Criteria.where("_id").is(id).and("revision").is(stage.revision()).and("status").is(stage.status()).and("audit.operationId").ne(amendment.operationId())), + new Update().push("snapshots", snapshot).push("audit", event).set("revision", snapshot.revision()).set("status", "AWAITING_REVIEW").set("updatedAt", Instant.now()), AdSettlementStage.class); + if (result.getModifiedCount() != 1) throw new IllegalArgumentException("The report changed; reload it before amending."); + return get(actor, id); + } + public AdSettlementStage review(User actor, String id, Review review) { + AdSettlementStage stage = get(actor, id); + if (review == null) throw new IllegalArgumentException("Review required."); + requireOperation(review.operationId()); requireReason(review.reason()); + if (review.decision() == null || !List.of("REVIEWED_PROVISIONAL", "REJECTED").contains(review.decision())) throw new IllegalArgumentException("Review records provisional acceptance or rejection only; it cannot verify funding or release credit."); + String digest = digest(review); + if (hasOperation(stage, review.operationId(), digest)) return stage; + if (stage.revision() != review.expectedRevision() || !"AWAITING_REVIEW".equals(stage.status())) throw new IllegalArgumentException("This revision has already changed or been reviewed."); + var result = mongo.updateFirst(Query.query(Criteria.where("_id").is(id).and("revision").is(stage.revision()).and("status").is("AWAITING_REVIEW")), + new Update().push("audit", new AuditEvent(review.operationId(), review.decision(), stage.revision(), actor.getId(), review.reason(), digest, Instant.now())) + .set("status", review.decision()).set("updatedAt", Instant.now()), AdSettlementStage.class); + if (result.getModifiedCount() != 1) throw new IllegalArgumentException("The report changed; reload it before reviewing."); + return get(actor, id); + } + private Snapshot snapshot(StageAdSettlementRequest input, int revision) { + List<Activity> rows = activity.snapshot(input.from(), input.through()); + Map<String, Long> weights = new LinkedHashMap<>(); + rows.stream().filter(row -> row.provisionalPoints() > 0).forEach(row -> weights.put(row.projectId(), row.provisionalPoints())); + long pool = FinanceAmounts.share(input.exactCents(), 7500); + Map<String, Long> cents = weights.isEmpty() ? Map.of() : RevenuePoolAllocator.allocate(input.exactCents(), 7500, weights).projectCents(); + List<Activity> allocated = rows.stream().map(row -> new Activity(row.projectId(), row.creatorId(), row.title(), row.pageviews(), row.launcherDownloads(), + row.frontendDownloads(), row.apiDownloads(), row.provisionalPoints(), cents.getOrDefault(row.projectId(), 0L), row.eligibilityNote())).toList(); + return new Snapshot(revision, input.from(), input.through(), input.exactCents(), input.reportSha256(), digest(input), digest(rows), 7500, + pool, input.exactCents() - pool, weights.isEmpty() ? pool : 0, AdSettlementActivityService.RULE, allocated, Instant.now()); + } + private static AdSettlementStage replay(AdSettlementStage existing, String digest) { + if (!existing.snapshots().getFirst().inputDigest().equals(digest)) throw new IllegalArgumentException("Conflicting report replay. Submit an explicit amendment with a reason."); + return existing; + } + private static boolean hasOperation(AdSettlementStage stage, String operationId, String digest) { + for (AuditEvent event : stage.audit()) if (event.operationId().equals(operationId)) { + if (!event.inputDigest().equals(digest)) throw new IllegalArgumentException("This operation ID belongs to different review content."); + return true; + } + return false; + } + public static void requireReviewer(User actor) { + if (actor == null || actor.getId() == null || actor.getId().isBlank() || actor.isDeleted() || !AdminPermission.hasPermission(actor, AdminPermission.PLATFORM_FINANCE_MANAGE)) throw new SecurityException("Finance review permission is required."); + } + static void validate(StageAdSettlementRequest input) { + if (input == null) throw new IllegalArgumentException("Report required."); + if (input.provider() == null || !input.provider().matches("[a-z0-9][a-z0-9._-]{0,79}")) throw new IllegalArgumentException("Use a lowercase provider key of at most 80 characters."); + for (String id : List.of(input.provider() == null ? "" : input.provider(), input.providerAccount() == null ? "" : input.providerAccount(), + input.reportId() == null ? "" : input.reportId(), input.depositId() == null ? "" : input.depositId())) { + if (!id.matches("[A-Za-z0-9][A-Za-z0-9._:-]{0,119}")) throw new IllegalArgumentException("Use non-secret provider/report/deposit identifiers of at most 120 characters."); + } + if (!"usd".equals(input.currency())) throw new IllegalArgumentException("Staging currently supports USD settlement reports only."); + if (input.exactCents() <= 0 || input.exactCents() > 1_000_000_000_000L) throw new IllegalArgumentException("Reported collected cents must be a positive bounded integer."); + if (input.from() == null || input.through() == null || input.through().isBefore(input.from()) || !input.through().isBefore(LocalDate.now(ZoneOffset.UTC)) + || input.from().plusDays(365).isBefore(input.through())) throw new IllegalArgumentException("Choose a closed UTC reporting period of no more than 366 days."); + if (input.reportSha256() == null || !input.reportSha256().matches("[a-f0-9]{64}")) throw new IllegalArgumentException("A SHA-256 digest of the source report is required; this digest is not funding verification."); + } + private static void requireOperation(String id) { try { UUID.fromString(id); } catch (RuntimeException invalid) { throw new IllegalArgumentException("A unique review operation ID is required."); } } + private static void requireReason(String reason) { if (reason == null || reason.isBlank() || reason.length() > 1000) throw new IllegalArgumentException("A review reason of 1–1000 characters is required."); } + static String sourceId(String type, String provider, String account, String source) { return "ad-" + type + ":" + digest(List.of(provider, account, source)); } + static String digest(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(JSON.writeValueAsString(value).getBytes(StandardCharsets.UTF_8))); } + catch (Exception invalid) { throw new IllegalArgumentException("Could not fingerprint report evidence.", invalid); } + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/CreatorPayoutService.java b/backend/src/main/java/net/modtale/service/finance/CreatorPayoutService.java new file mode 100644 index 000000000..85c878ee7 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/CreatorPayoutService.java @@ -0,0 +1,174 @@ +package net.modtale.service.finance; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import net.modtale.model.finance.CreatorPayoutRequest; +import net.modtale.model.user.User; +import net.modtale.repository.user.UserRepository; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** Reserves first, then dispatches each snapshotted recipient with a stable provider idempotency key. */ +@Service +public class CreatorPayoutService { + private final FinanceWalletService wallets; + private final StripeGatewayService gateway; + private final UserRepository users; + private final RevenueOpsSupport core; + + public CreatorPayoutService(FinanceWalletService wallets, StripeGatewayService gateway, + UserRepository users, RevenueOpsSupport core) { + this.wallets = wallets; this.gateway = gateway; this.users = users; this.core = core; + } + + public CreatorPayoutRequest request(User requester, User owner, String currency, Long requestedAmount, + long minimum, String requestKey) { + if (!gateway.isOperational()) throw new IllegalStateException(gateway.getAvailabilityMessage()); + try { java.util.UUID.fromString(requestKey); } catch (RuntimeException invalid) { throw new IllegalArgumentException("A valid payout request key is required."); } + String accountId = gateway.getPlatformAccountId(); + if (!gateway.verifyPlatformAccountId(accountId)) throw new IllegalStateException("The payout provider account could not be verified."); + String id = FinanceWalletService.walletId(owner.getId(), currency, gateway.isTestMode()) + ":" + requestKey; + CreatorPayoutRequest existing = wallets.getRequest(id); + if (existing != null) { + if (!accountId.equals(existing.getProviderAccountId()) || !requester.getId().equals(existing.getRequestedBy()) || (requestedAmount != null && requestedAmount != existing.getAmountCents())) { + throw new IllegalArgumentException("This request key belongs to a different payout."); + } + return existing; + } + long amount = requestedAmount == null ? wallets.getWallet(owner.getId(), currency, gateway.isTestMode()).getAvailableCents() : requestedAmount; + if (amount < minimum) throw new IllegalArgumentException("The payout amount must meet the minimum."); + List<CreatorPayoutRequest.Recipient> recipients = resolveRecipients(owner, amount); + return wallets.reserve(owner.getId(), requester.getId(), currency, gateway.isTestMode(), requestKey, amount, minimum, recipients, accountId); + } + + private List<CreatorPayoutRequest.Recipient> resolveRecipients(User owner, long amount) { + Map<String, Long> weights = new LinkedHashMap<>(); + if (owner.getAccountType() == User.AccountType.ORGANIZATION && owner.getOrgPayoutMode() == User.OrgPayoutMode.DISTRIBUTE_TO_MEMBERS) { + core.validateOrgPayoutShares(owner, owner.getOrgPayoutShares()); + if (owner.getOrgPayoutShares().size() > 50) throw new IllegalArgumentException("A payout may have at most 50 recipients."); + for (var share : owner.getOrgPayoutShares()) weights.put(share.getUserId(), (long) share.getPercent()); + } else weights.put(owner.getId(), 100L); + var allocation = RevenuePoolAllocator.allocate(amount, 10000, weights); + List<CreatorPayoutRequest.Recipient> recipients = new ArrayList<>(); + for (var entry : allocation.projectCents().entrySet().stream().sorted(Map.Entry.comparingByKey()).toList()) { + if (entry.getValue() == 0) continue; + User user = users.findById(entry.getKey()).orElseThrow(() -> new IllegalArgumentException("Payout recipient no longer exists.")); + String accountId = user.getStripeConnectAccountId(); + if (accountId == null || !accountId.startsWith("acct_")) throw new IllegalStateException("All recipients must complete payout onboarding first."); + Map<String, Object> status = gateway.getAccountStatus(accountId, false); + if (!accountId.equals(status.get("id")) || !Boolean.TRUE.equals(status.get("payouts_enabled")) + || !(status.get("capabilities") instanceof Map<?, ?> capabilities) || !"active".equals(capabilities.get("transfers"))) { + throw new IllegalStateException("A recipient is not currently eligible for transfers and payouts. Refresh their onboarding status."); + } + var recipient = new CreatorPayoutRequest.Recipient(); recipient.setUserId(user.getId()); recipient.setAccountId(accountId); recipient.setAmountCents(entry.getValue()); + recipients.add(recipient); + } + return recipients; + } + + @Scheduled(fixedDelayString = "${app.finance.payout-dispatch-interval-ms:60000}") + public void dispatchReservedPayouts() { + if (!gateway.isOperational()) return; + for (CreatorPayoutRequest request : wallets.getUnfinishedRequests(gateway.isTestMode())) { + if (request.isTestMode() != gateway.isTestMode()) continue; + try { dispatch(request.getId()); } + catch (RuntimeException requiresReview) { + try { wallets.requireReview(request.getId(), "Dispatch interrupted; verify the saved provider request before any further transfer."); } + catch (RuntimeException storageUnavailable) { /* No funds are released; retry after storage recovers. */ } + } + } + } + + public void dispatch(String requestId) { + CreatorPayoutRequest request = wallets.getRequest(requestId); + if (request == null || request.isTestMode() != gateway.isTestMode() || !gateway.isOperational()) return; + wallets.noteDispatchAttempt(requestId); + if (!gateway.verifyPlatformAccountId(request.getProviderAccountId())) { + wallets.requireReview(requestId, "Payout provider account scope needs reconciliation."); return; + } + if (request.getStatus() == CreatorPayoutRequest.Status.RESERVED) { + wallets.markAttempted(requestId); + request = wallets.getRequest(requestId); + } + if (request.getStatus() != CreatorPayoutRequest.Status.PROCESSING) return; + var wallet = wallets.getWallet(request.getCreatorId(), request.getCurrency(), request.isTestMode()); + if (wallet.isPayoutHold() || wallet.getAvailableCents() < 0) { + wallets.requireReview(requestId, "Creator balance is on hold or needs reconciliation."); return; + } + // Stripe may prune keys after 24h. Never risk an automatic duplicate after that window. + if (request.getFirstAttemptAt() != null && request.getFirstAttemptAt().isBefore(Instant.now().minus(Duration.ofHours(23)))) { + wallets.requireReview(requestId, "Transfer confirmation needs reconciliation before any further attempt."); return; + } + for (int i = 0; i < request.getRecipients().size(); i++) { + var recipient = request.getRecipients().get(i); + if (recipient.getTransferId() != null) continue; + boolean authorized = wallets.authorizeRecipientTransfer(requestId, i); + if (!authorized) wallets.requireRecipientReview(requestId, i, "Transfer authorization is paused for balance or risk reconciliation."); + request = wallets.getRequest(requestId); + if (request == null || request.getStatus() != CreatorPayoutRequest.Status.PROCESSING) return; + recipient = request.getRecipients().get(i); + // Another dispatcher may have confirmed this recipient since our snapshot or authorization. + if (recipient.getTransferId() != null) continue; + if (!authorized) return; + var result = gateway.createTransfer(recipient.getAccountId(), recipient.getAmountCents(), request.getCurrency(), + "Modtale creator earnings", transferMetadata(request, i), false, + "modtale-payout:" + request.getId() + ":" + i); + if (!result.success()) return; // Leave funds reserved; the next attempt uses the exact same key. + if (!matchesTransfer(request, i, result.id(), result.raw(), Instant.now())) { + wallets.requireReview(requestId, "Provider transfer response did not match the saved authorization."); return; + } + try { wallets.recordTransfer(requestId, i, result.id(), "provider-response", "Verified response to the original idempotent transfer request."); } + catch (IllegalStateException conflictingEvidence) { wallets.requireReview(requestId, "Transfer evidence conflicts with an existing payout claim."); return; } + } + wallets.completeTransfers(requestId); + } + + static Map<String, String> transferMetadata(CreatorPayoutRequest request, int index) { + return Map.of("payoutRequestId", request.getId(), "recipientIndex", String.valueOf(index), + "correlationId", request.getRecipients().get(index).getCorrelationId(), "transferGroup", request.getTransferGroup()); + } + + /** Read-only provider retrieval followed by audited recognition of an already-sent transfer. */ + public CreatorPayoutRequest reconcileKnownTransfer(String requestId, int recipientIndex, String transferId, User reviewer, String reason) { + CreatorPayoutRequest request = wallets.getRequest(requestId); + if (request == null || recipientIndex < 0 || recipientIndex >= request.getRecipients().size()) throw new IllegalArgumentException("Payout recipient not found."); + if (reviewer == null || reviewer.getId() == null || reason == null || reason.isBlank() || reason.length() > 1000) throw new IllegalArgumentException("A reviewer and reconciliation reason are required."); + if (!gateway.isReconciliationEnabled() || request.isTestMode() != gateway.isTestMode() + || !gateway.verifyPlatformAccountId(request.getProviderAccountId())) throw new IllegalArgumentException("Payout provider account or mode does not match."); + Map<String, Object> transfer = gateway.getTransfer(transferId); + if (!matchesTransfer(request, recipientIndex, transferId, transfer, Instant.now())) throw new IllegalArgumentException("Transfer evidence does not match this saved payout authorization. Funds remain reserved."); + wallets.recordTransfer(requestId, recipientIndex, transferId, reviewer.getId(), reason.trim()); + return wallets.getRequest(requestId); + } + + public List<CreatorPayoutRequest> getReviewRequests() { + // Review remains visible when credentials are unavailable or a legacy account scope differs. + // Any provider lookup or accounting confirmation still requires exact saved scope verification. + return wallets.getReviewRequests(); + } + + static boolean matchesTransfer(CreatorPayoutRequest request, int index, String transferId, Map<String, Object> transfer, Instant now) { + if (request == null || index < 0 || index >= request.getRecipients().size() || transfer == null) return false; + var recipient = request.getRecipients().get(index); + if (transferId == null || !transferId.matches("tr_[A-Za-z0-9]+") || !transferId.equals(transfer.get("id")) || !"transfer".equals(transfer.get("object")) + || !(transfer.get("livemode") instanceof Boolean) || request.isTestMode() != Boolean.FALSE.equals(transfer.get("livemode")) + || !recipient.getAccountId().equals(transfer.get("destination")) || recipient.getAmountCents() != PaymentAdjustmentService.number(transfer.get("amount")) + || !request.getCurrency().equals(transfer.get("currency")) || request.getTransferGroup() == null || !request.getTransferGroup().equals(transfer.get("transfer_group")) + || !Boolean.FALSE.equals(transfer.get("reversed")) || PaymentAdjustmentService.number(transfer.get("amount_reversed")) != 0 + || recipient.getAuthorizedAt() == null || request.getFirstAttemptAt() == null || recipient.getCorrelationId() == null + || !(transfer.get("metadata") instanceof Map<?, ?> metadata)) return false; + for (var expected : transferMetadata(request, index).entrySet()) if (!expected.getValue().equals(metadata.get(expected.getKey()))) return false; + long created = PaymentAdjustmentService.number(transfer.get("created")); + return created >= recipient.getAuthorizedAt().minusSeconds(300).getEpochSecond() && created <= now.plusSeconds(300).getEpochSecond(); + } + + public static Map<String, Object> toResponse(CreatorPayoutRequest request) { + return Map.of("ok", true, "requestId", request.getId(), "amountCents", request.getAmountCents(), + "status", request.getStatus(), "recipientCount", request.getRecipients().size(), + "testMode", request.isTestMode(), "paymentStage", "transfer_to_connected_account"); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/DisputeEvidence.java b/backend/src/main/java/net/modtale/service/finance/DisputeEvidence.java new file mode 100644 index 000000000..39545658f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/DisputeEvidence.java @@ -0,0 +1,43 @@ +package net.modtale.service.finance; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.*; +import net.modtale.model.finance.FinanceDisputeBalance; + +/** Only unique, source-linked, same-currency available balance movements can support a financial release. */ +public record DisputeEvidence(List<FinanceDisputeBalance> balances, Long principalMovementCents, Long actualFeeCents, + long returnedPrincipalCents, boolean ready, String digest) { + static DisputeEvidence read(String accountId, boolean testMode, String disputeId, String chargeId, String currency, + long amount, String status, Object values) { + Map<String, FinanceDisputeBalance> unique = new TreeMap<>(); boolean valid = values instanceof List<?>; + if (values instanceof List<?> list) for (Object value : list) { + if (!(value instanceof Map<?, ?> data) || !(data.get("id") instanceof String id)) { valid = false; continue; } + if (!id.matches("txn_[A-Za-z0-9]+")) valid = false; + String source = data.get("source") instanceof String direct ? direct : data.get("source") instanceof Map<?, ?> object && object.get("id") instanceof String idValue ? idValue : null; + var balance = new FinanceDisputeBalance(id, source, string(data.get("type")), string(data.get("currency")), string(data.get("status")), number(data.get("amount")), number(data.get("fee")), number(data.get("net"))); + FinanceDisputeBalance previous = unique.putIfAbsent(id, balance); + if (previous != null && !previous.equals(balance)) valid = false; + if (!disputeId.equals(source) || !"adjustment".equals(balance.type()) || !currency.equals(balance.currency()) || !"available".equals(balance.status()) + || balance.amount() == null || balance.fee() == null || balance.net() == null) valid = false; + else try { if (Math.subtractExact(balance.amount(), balance.fee()) != balance.net()) valid = false; } + catch (ArithmeticException overflow) { valid = false; } + } + long principal = 0, fees = 0, returned = 0; + if (valid) try { + for (FinanceDisputeBalance balance : unique.values()) { + principal = Math.addExact(principal, balance.amount()); fees = Math.addExact(fees, balance.fee()); + if (balance.amount() > 0) returned = Math.addExact(returned, balance.amount()); + } + } catch (ArithmeticException overflow) { valid = false; } + String fingerprint = accountId + ":" + testMode + ":" + disputeId + ":" + chargeId + ":" + currency + ":" + amount + ":" + status + ":" + valid + ":" + unique.values(); + String digest; + try { digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(fingerprint.getBytes(StandardCharsets.UTF_8))); } + catch (java.security.NoSuchAlgorithmException impossible) { throw new IllegalStateException(impossible); } + return new DisputeEvidence(List.copyOf(unique.values()), valid ? principal : null, valid ? fees : null, valid ? returned : 0, valid, digest); + } + private static String string(Object value) { return value instanceof String text ? text : null; } + private static Long number(Object value) { + long parsed = PaymentAdjustmentService.number(value); return parsed == Long.MIN_VALUE ? null : parsed; + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/DonationCheckoutService.java b/backend/src/main/java/net/modtale/service/finance/DonationCheckoutService.java new file mode 100644 index 000000000..bf6b5caae --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/DonationCheckoutService.java @@ -0,0 +1,236 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.DonationIntent; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.model.project.Project; +import net.modtale.model.user.User; +import net.modtale.repository.finance.DonationIntentRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import net.modtale.service.project.query.ProjectService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.dao.DuplicateKeyException; +import java.util.UUID; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; + +@Service +public class DonationCheckoutService { + + public static class SupportTermsChangedException extends IllegalStateException { + public SupportTermsChangedException() { super("Support terms changed. Review the updated share before starting checkout."); } + } + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private DonationIntentRepository donationIntentRepository; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private ProjectService projectService; + @Autowired private StripeGatewayService stripeGatewayService; + @Autowired private RevenueOpsSupport core; + @Autowired private RecurringSupportService recurringSupport; + + public Map<String, Object> getDonationConfig(String projectId) { + Project project = projectService.getProjectById(projectId); + if (project == null) { + throw new IllegalArgumentException("Project not found"); + } + + Map<String, Object> response = new HashMap<>(); + response.put("projectId", project.getId()); + response.put("donationsEnabled", project.isDonationsEnabled()); + response.put("checkoutEnabled", project.isDonationsEnabled() && stripeGatewayService.isCheckoutAvailable()); + response.put("recurringEnabled", stripeGatewayService.isOperational()); + response.put("testMode", stripeGatewayService.isTestMode() || stripeGatewayService.isMockEnabled()); + response.put("availabilityMessage", stripeGatewayService.getAvailabilityMessage()); + response.put("suggestedDonationCents", Math.max(100, project.getSuggestedDonationCents())); + response.put("donationRecurringDefault", false); + response.put("donationPlatformCutPercent", project.getDonationPlatformCutBps() / 100.0); + response.put("donationPlatformCutBps", project.getDonationPlatformCutBps()); + response.put("currency", financeAccountService.getSettings().getCurrency()); + response.put("minimumDonationCents", FinanceAmounts.MIN_SUPPORT_CENTS); + response.put("maximumDonationCents", FinanceAmounts.MAX_SUPPORT_CENTS); + return response; + } + + public Map<String, Object> createDonationCheckout(String projectId, long amountCents, boolean recurring, User donor, boolean guestCheckout, int expectedPlatformCutBps) { + 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."); + } + int platformCutBps = project.getDonationPlatformCutBps(); + if (expectedPlatformCutBps != platformCutBps) throw new SupportTermsChangedException(); + + if (!stripeGatewayService.isCheckoutAvailable()) { + throw new IllegalStateException(stripeGatewayService.getAvailabilityMessage()); + } + if (recurring && (donor == null || guestCheckout || !stripeGatewayService.isOperational())) throw new IllegalArgumentException("Sign in to start monthly support."); + PlatformFinanceSettings settings = financeAccountService.getSettings(); + if (!"usd".equalsIgnoreCase(settings.getCurrency())) { + throw new IllegalStateException("This checkout currently supports USD only."); + } + long normalizedAmount = FinanceAmounts.validateSupportAmount(amountCents); + + long platformCut = FinanceAmounts.share(normalizedAmount, platformCutBps); + long creatorCut = normalizedAmount - platformCut; + + DonationIntent intent = new DonationIntent(); + intent.setId(UUID.randomUUID().toString()); + intent.setStripeTestMode(stripeGatewayService.isTestMode() || stripeGatewayService.isMockEnabled()); + if (!stripeGatewayService.isMockEnabled()) intent.setStripePlatformAccountId(stripeGatewayService.getPlatformAccountId()); + intent.setProjectId(project.getId()); + intent.setCreatorId(project.getAuthorId()); + intent.setDonorUserId(donor != null ? donor.getId() : null); + intent.setGuestDonation(guestCheckout || donor == null); + intent.setAmountCents(normalizedAmount); + intent.setCreatorCents(creatorCut); + intent.setPlatformCents(platformCut); + intent.setRecurring(recurring); + intent.setPlatformCutBps(platformCutBps); + intent.setCurrency(settings.getCurrency()); + intent.setStatus(DonationIntent.DonationStatus.PENDING); + intent = donationIntentRepository.save(intent); + + String projectPath = projectService.getProjectLink(project); + String successUrl = core.normalizeFrontendUrl() + projectPath + "?donation_intent=" + intent.getId() + "&donation_status=success"; + String cancelUrl = core.normalizeFrontendUrl() + projectPath + "?donation_intent=" + intent.getId() + "&donation_status=cancel"; + + StripeGatewayService.StripeResult session = stripeGatewayService.createOrSimulateDonationCheckout( + intent.getId(), + project.getTitle(), + normalizedAmount, + recurring, + successUrl, + cancelUrl, + settings.getCurrency(), + stripeGatewayService.isMockEnabled() + ); + + 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")); + + Map<String, Object> 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<String, Object> 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()); + } + + requireMatchingProviderScope(intent); + Map<String, Object> session = stripeGatewayService.getCheckoutSession( + intent.getStripeSessionId(), + stripeGatewayService.isMockEnabled() + ); + if (isMatchingPaidSession(intent, session)) { + if (intent.isRecurring()) recurringSupport.registerCheckout(intent, session); + else if (isVerifiedPayment(intent, session)) completeDonationIntent(intent, session); + return Map.of("ok", true, "status", "COMPLETED"); + } + + return Map.of("ok", false, "status", "PENDING"); + } + + private void completeDonationIntent(DonationIntent intent, Map<String, Object> sessionData) { + if (intent.getStatus() == DonationIntent.DonationStatus.COMPLETED) return; + + FinanceLedgerEntry entry = new FinanceLedgerEntry(); + // MongoDB's unique _id makes repeated/concurrent confirmation and webhook delivery safe. + // Insert before marking the intent complete: retries repair a crash between these writes. + entry.setId(FinanceSourceKey.stripe(Boolean.FALSE.equals(sessionData.get("livemode")), intent.getStripePlatformAccountId(), "checkout:" + intent.getStripeSessionId())); + 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.PENDING); + entry.setCreatedAt(LocalDateTime.now()); + + 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"))); + } + entry.getMetadata().put("settlement", "awaiting_reconciliation"); + entry.getMetadata().put("providerAccountId", intent.getStripePlatformAccountId()); + Object paymentIntent = sessionData.get("payment_intent"); + if (paymentIntent instanceof String paymentId && paymentId.startsWith("pi_")) entry.getMetadata().put("paymentIntentId", paymentId); + entry.setCreatorGrossCents(intent.getCreatorCents()); + entry.getMetadata().put("testMode", String.valueOf(!Boolean.TRUE.equals(sessionData.get("livemode")))); + try { + ledgerRepository.insert(entry); + } catch (DuplicateKeyException duplicate) { + // A concurrent delivery already inserted this exact payment. Never overwrite it. + } + intent.setStatus(DonationIntent.DonationStatus.COMPLETED); + intent.setCompletedAt(LocalDateTime.now()); + donationIntentRepository.save(intent); + } + + public void handlePaidCheckout(Map<String, Object> session) { + String sessionId = String.valueOf(session.get("id")); + DonationIntent intent = donationIntentRepository.findByStripeSessionId(sessionId).orElse(null); + if (intent == null) throw new IllegalArgumentException("Checkout session is not recorded yet."); + if (!"paid".equals(session.get("payment_status"))) return; + requireMatchingProviderScope(intent); + if (!isMatchingPaidSession(intent, session)) throw new IllegalArgumentException("Paid checkout does not match its recorded intent."); + if (intent.isRecurring()) recurringSupport.registerCheckout(intent, session); + else if (isVerifiedPayment(intent, session)) completeDonationIntent(intent, session); + } + + private void requireMatchingProviderScope(DonationIntent intent) { + if (intent.getStripeTestMode() == null || intent.getStripeTestMode() != stripeGatewayService.isTestMode() + || !java.util.Objects.equals(intent.getStripePlatformAccountId(), stripeGatewayService.getPlatformAccountId())) { + throw new IllegalArgumentException("Checkout provider account or mode changed; reconciliation is required."); + } + } + + static boolean isVerifiedPayment(DonationIntent intent, Map<String, Object> session) { + return session != null && !intent.isRecurring() && "payment".equals(session.get("mode")) && isMatchingPaidSession(intent, session); + } + + static boolean isMatchingPaidSession(DonationIntent intent, Map<String, Object> session) { + if (session == null || Boolean.TRUE.equals(session.get("simulated"))) return false; + if (!(session.get("livemode") instanceof Boolean)) return false; + if (intent.getStripeTestMode() == null || intent.getStripeTestMode() != Boolean.FALSE.equals(session.get("livemode"))) return false; + if (!"paid".equals(session.get("payment_status")) || !"complete".equals(session.get("status"))) return false; + if (!(intent.isRecurring() ? "subscription" : "payment").equals(session.get("mode"))) return false; + if (intent.getStripeSessionId() == null || !intent.getStripeSessionId().equals(session.get("id"))) return false; + if (!intent.getCurrency().equalsIgnoreCase(String.valueOf(session.get("currency")))) return false; + if (!(session.get("amount_total") instanceof Number amount)) return false; + try { if (new java.math.BigDecimal(amount.toString()).longValueExact() != intent.getAmountCents()) return false; } + catch (ArithmeticException invalid) { return false; } + if (!(session.get("metadata") instanceof Map<?, ?> metadata) || !intent.getId().equals(metadata.get("intentId"))) return false; + return true; + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/EarningsAccountService.java b/backend/src/main/java/net/modtale/service/finance/EarningsAccountService.java new file mode 100644 index 000000000..59dcc78e0 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/EarningsAccountService.java @@ -0,0 +1,556 @@ +package net.modtale.service.finance; + +import net.modtale.model.dto.request.finance.UpdatePlatformFinanceSettingsRequest; +import net.modtale.model.dto.request.finance.UpdateProjectMonetizationRequest; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.model.project.Project; +import net.modtale.model.user.ApiKey; +import net.modtale.model.user.User; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import net.modtale.repository.finance.PlatformFinanceSettingsRepository; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +@Service +public class EarningsAccountService { + + @Autowired private PlatformFinanceSettingsRepository settingsRepository; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private ProjectRepository projectRepository; + @Autowired private UserRepository userRepository; + @Autowired private ProjectService projectService; + @Autowired private AccessControlService accessControlService; + @Autowired private StripeGatewayService stripeGatewayService; + @Autowired private RevenueOpsSupport core; + @Autowired private FinanceWalletService wallets; + @Autowired private CreatorPayoutService payouts; + + @Value("${app.finance.ads.test-mode-enabled:false}") + private boolean defaultAdTestModeEnabled; + + @Value("${app.finance.stripe.creator-countries:US,CA,GB,CH,AT,BE,BG,HR,CY,CZ,DK,EE,FI,FR,DE,GR,HU,IE,IS,IT,LI,LT,LU,LV,MT,NL,NO,PL,PT,RO,SE,SI,SK,ES}") + private String creatorCountries; + + public List<String> getCreatorCountries() { + return java.util.Arrays.stream(creatorCountries.split(",")).map(String::trim).map(String::toUpperCase) + .filter(value -> value.matches("[A-Z]{2}")).distinct().sorted().toList(); + } + + public PlatformFinanceSettings getSettings() { + PlatformFinanceSettings settings = settingsRepository.findById("platform").orElseGet(() -> { + PlatformFinanceSettings defaults = new PlatformFinanceSettings(); + defaults.setId("platform"); + defaults.setAdCreatorSplitBps(7500); + defaults.setFundExpiryDays(0); + defaults.setDonationPlatformCutBps(1000); + defaults.setDefaultAdRevenuePerClickCents(3); + defaults.setMinPayoutCents(1000); + defaults.setAdTestModeEnabled(defaultAdTestModeEnabled); + defaults.setCurrency("usd"); + defaults.setUpdatedAt(LocalDateTime.now()); + return defaults; + }); + if (settings.getMonetizationPolicyVersion() < 2) { + // Prospective policy migration only. Historic source-ledger amounts remain untouched. + settings.setAdCreatorSplitBps(7500); + settings.setFundExpiryDays(0); + settings.setMonetizationPolicyVersion(2); + settings.setUpdatedAt(LocalDateTime.now()); + return settingsRepository.save(settings); + } + return settings; + } + + public Map<String, Object> getCreatorOverview(User requester, String ownerId, String range) { + User creator = core.resolveFinanceOwner(requester, ownerId, false); + PlatformFinanceSettings settings = getSettings(); + boolean testMode = stripeGatewayService.isTestMode() || stripeGatewayService.isMockEnabled(); + var liveWallet = wallets.getWallet(creator.getId(), settings.getCurrency(), false); + var testWallet = wallets.getWallet(creator.getId(), settings.getCurrency(), true); + var activeWallet = testMode ? testWallet : liveWallet; + List<FinanceLedgerEntry> entries = ledgerRepository.findByCreatorId(creator.getId()).stream() + .filter(entry -> settings.getCurrency().equalsIgnoreCase(entry.getCurrency())) + .filter(entry -> FinanceLedgerRules.isInMode(entry, testMode)).collect(Collectors.toList()); + + 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(); + + int days = core.parseRangeDays(range); + LocalDate start = LocalDate.now().minusDays(days - 1); + LocalDate end = LocalDate.now(); + + Predicate<FinanceLedgerEntry> inRange = e -> e.getCreatedAt() != null && !e.getCreatedAt().toLocalDate().isBefore(start) && !e.getCreatedAt().toLocalDate().isAfter(end); + + long periodAdRevenue = entries.stream() + .filter(FinanceLedgerRules::isSettledRevenue) + .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(FinanceLedgerRules::isSettledRevenue) + .filter(inRange) + .filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.DONATION || e.getType() == FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT || e.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT || e.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT || e.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL) + .mapToLong(FinanceLedgerEntry::getCreatorCents) + .sum(); + + List<Map<String, Object>> earningsChart = core.buildDailySeries(entries, start, end, Set.of( + FinanceLedgerEntry.LedgerType.DONATION, + FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT, + FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL, + FinanceLedgerEntry.LedgerType.AD_CLICK, + FinanceLedgerEntry.LedgerType.AD_IMPRESSION + ), + FinanceLedgerEntry::getCreatorCents, + FinanceLedgerRules::isSettledRevenue + ); + + List<Map<String, Object>> donationsChart = core.buildDailySeries(entries, start, end, Set.of(FinanceLedgerEntry.LedgerType.DONATION, FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL), FinanceLedgerEntry::getCreatorCents, FinanceLedgerRules::isSettledRevenue); + List<Map<String, Object>> adsChart = core.buildDailySeries(entries, start, end, Set.of(FinanceLedgerEntry.LedgerType.AD_CLICK, FinanceLedgerEntry.LedgerType.AD_IMPRESSION), FinanceLedgerEntry::getCreatorCents, FinanceLedgerRules::isSettledRevenue); + List<Map<String, Object>> expiredChart = core.buildDailySeries(entries, start, end, Set.of(FinanceLedgerEntry.LedgerType.EXPIRED_TRANSFER), FinanceLedgerEntry::getPlatformCents, e -> true); + + Map<String, Long> revenueByProject = entries.stream() + .filter(FinanceLedgerRules::isSettledRevenue) + .filter(e -> e.getProjectId() != null) + .collect(Collectors.groupingBy(FinanceLedgerEntry::getProjectId, Collectors.summingLong(FinanceLedgerEntry::getCreatorCents))); + + List<Map<String, Object>> monetizationProjects = projectRepository.findByAuthorIdList(creator.getId()).stream() + .sorted(Comparator.comparing(Project::getUpdatedAt, Comparator.nullsLast(String::compareTo)).reversed()) + .map(project -> { + Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> response = new HashMap<>(); + response.put("ownerId", creator.getId()); + response.put("ownerAccountType", creator.getAccountType().name()); + response.put("currency", settings.getCurrency()); + response.put("fundExpiryDays", 0); + response.put("fundsExpire", false); + response.put("adCreatorSplitPercent", settings.getAdCreatorSplitBps() / 100.0); + response.put("defaultDonationPlatformCutPercent", settings.getDonationPlatformCutBps() / 100.0); + response.put("availableCents", Math.max(0, liveWallet.getAvailableCents())); + response.put("reservedCents", activeWallet.getReservedCents()); + response.put("adjustmentOwedCents", Math.max(0, -activeWallet.getAvailableCents())); + boolean fundingScopeVerified = false; + if (activeWallet.getProviderAccountId() != null && stripeGatewayService.isReconciliationEnabled()) { + try { fundingScopeVerified = activeWallet.getProviderAccountId().equals(stripeGatewayService.getPlatformAccountId()); } + catch (IllegalStateException unavailable) { /* Preserve balance visibility while provider verification is unavailable. */ } + } + boolean fundingReview = !fundingScopeVerified && (activeWallet.getAvailableCents() != 0 || activeWallet.getReservedCents() != 0); + response.put("payoutHold", activeWallet.isPayoutHold() || fundingReview); + response.put("fundingScopeVerified", fundingScopeVerified); + response.put("testAvailableCents", Math.max(0, testWallet.getAvailableCents())); + response.put("pendingCents", Math.max(0, pending)); + response.put("paidOutCents", Math.max(0, paidOut)); + response.put("expiredCents", Math.max(0, expired)); + response.put("expiringSoonCents", 0); + response.put("withdrawalsEnabled", stripeGatewayService.isOperational()); + response.put("onboardingEnabled", stripeGatewayService.isOperational()); + response.put("onboardingCountries", getCreatorCountries()); + response.put("stripeAccountCountry", creator.getStripeAccountCountry()); + response.put("testMode", testMode); + response.put("availabilityMessage", stripeGatewayService.getAvailabilityMessage()); + 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("payoutRequests", wallets.getRecentRequests(creator.getId(), testMode).stream().map(request -> { + Map<String, Object> row = new HashMap<>(); row.put("id", request.getId()); row.put("amountCents", request.getAmountCents()); + row.put("status", request.getStatus()); row.put("createdAt", request.getCreatedAt()); + if (request.getStatus() != net.modtale.model.finance.CreatorPayoutRequest.Status.TRANSFERRED) row.put("reviewReason", request.getReviewReason()); + long transferred = request.getRecipients().stream().filter(recipient -> recipient.getTransferId() != null).mapToLong(net.modtale.model.finance.CreatorPayoutRequest.Recipient::getAmountCents).sum(); + row.put("transferredCents", transferred); row.put("remainingReservedCents", request.getAmountCents() - transferred); + return row; + }).toList()); + 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<String, Object> getAdminOverview(String range) { + PlatformFinanceSettings settings = getSettings(); + int days = core.parseRangeDays(range); + LocalDateTime start = LocalDate.now().minusDays(days - 1).atStartOfDay(); + LocalDateTime end = LocalDateTime.now(); + + List<FinanceLedgerEntry> entries = ledgerRepository.findByCreatedAtBetween(start, end).stream() + .filter(FinanceLedgerRules::isReal).collect(Collectors.toList()); + + long periodPlatformRevenue = entries.stream().filter(FinanceLedgerRules::isRecognizedRevenue).mapToLong(FinanceLedgerEntry::getPlatformCents).sum(); + long periodCreatorRevenue = entries.stream().filter(FinanceLedgerRules::isRecognizedRevenue).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<Map<String, Object>> platformRevenueChart = core.buildDailySeries(entries, chartStart, chartEnd, Set.of( + FinanceLedgerEntry.LedgerType.DONATION, + FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT, + FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL, + FinanceLedgerEntry.LedgerType.AD_CLICK, + FinanceLedgerEntry.LedgerType.EXPIRED_TRANSFER, + FinanceLedgerEntry.LedgerType.PLATFORM_CUT + ), + FinanceLedgerEntry::getPlatformCents, + FinanceLedgerRules::isRecognizedRevenue + ); + + List<Map<String, Object>> creatorRevenueChart = core.buildDailySeries(entries, chartStart, chartEnd, Set.of( + FinanceLedgerEntry.LedgerType.DONATION, + FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT, + FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL, + FinanceLedgerEntry.LedgerType.AD_CLICK, + FinanceLedgerEntry.LedgerType.AD_IMPRESSION + ), + FinanceLedgerEntry::getCreatorCents, + FinanceLedgerRules::isRecognizedRevenue + ); + + Map<String, Long> creatorRevenueMap = entries.stream() + .filter(FinanceLedgerRules::isRecognizedRevenue) + .filter(e -> e.getCreatorId() != null) + .collect(Collectors.groupingBy(FinanceLedgerEntry::getCreatorId, Collectors.summingLong(FinanceLedgerEntry::getCreatorCents))); + + List<Map<String, Object>> 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<String, Object> 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()); + + long totalCreatorAvailable = wallets.getTotalAvailable(settings.getCurrency(), false); + + Map<String, Object> response = new HashMap<>(); + response.put("currency", settings.getCurrency()); + response.put("fundExpiryDays", 0); + response.put("fundsExpire", false); + 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<String, Object> 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<String, Object> createStripeOnboardingLink(User requester, String ownerId, String returnPath, String country) { + User creator = core.resolveFinanceOwner(requester, ownerId, true); + if (creator.getAccountType() == User.AccountType.ORGANIZATION) { + core.requireOrganizationOwner(requester, creator); + } + if (!stripeGatewayService.isOperational()) throw new IllegalStateException(stripeGatewayService.getAvailabilityMessage()); + String accountId = creator.getStripeConnectAccountId(); + if (accountId == null || accountId.isBlank()) { + String selectedCountry = country == null ? "" : country.trim().toUpperCase(java.util.Locale.ROOT); + if (!getCreatorCountries().contains(selectedCountry)) throw new IllegalArgumentException("Select an eligible country for the payout account owner."); + StripeGatewayService.StripeResult accountResult = stripeGatewayService.createOrSimulateConnectAccount( + creator.getEmail(), selectedCountry, false, + "connect:" + stripeGatewayService.getPlatformAccountId() + (stripeGatewayService.isTestMode() ? ":test:" : ":live:") + creator.getId() + ":" + selectedCountry); + + if (!accountResult.success()) { + throw new IllegalStateException("Unable to initialize Stripe account: " + accountResult.error()); + } + accountId = accountResult.id(); + creator.setStripeConnectAccountId(accountId); + creator.setStripeAccountCountry(selectedCountry); + userRepository.save(creator); + } + + String safePath = "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/dashboard/finance"; + StripeGatewayService.StripeResult linkResult = stripeGatewayService.createOrSimulateOnboardingLink( + accountId, + safePath, + stripeGatewayService.isMockEnabled() + ); + if (!linkResult.success()) { + throw new IllegalStateException("Unable to create onboarding link: " + linkResult.error()); + } + + Map<String, Object> 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<String, Object> refreshStripeStatus(User requester, String ownerId) { + User creator = core.resolveFinanceOwner(requester, ownerId, true); + if (creator.getStripeConnectAccountId() == null || creator.getStripeConnectAccountId().isBlank()) { + return Map.of( + "connected", false, + "onboardingComplete", false, + "payoutsEnabled", false + ); + } + + Map<String, Object> status = stripeGatewayService.getAccountStatus( + creator.getStripeConnectAccountId(), + stripeGatewayService.isMockEnabled() + ); + + if (status.containsKey("error")) throw new IllegalStateException("Payment provider status is temporarily unavailable. Your saved account status has not changed."); + boolean detailsSubmitted = core.asBoolean(status.get("details_submitted")); + boolean payoutsEnabled = core.asBoolean(status.get("payouts_enabled")) + && status.get("capabilities") instanceof Map<?, ?> capabilities && "active".equals(capabilities.get("transfers")); + + creator.setStripeOnboardingComplete(detailsSubmitted); + creator.setStripePayoutsEnabled(payoutsEnabled); + if (status.get("country") != null) { + creator.setStripeAccountCountry(String.valueOf(status.get("country"))); + } + userRepository.save(creator); + + Map<String, Object> response = new HashMap<>(); + response.put("connected", true); + response.put("onboardingComplete", detailsSubmitted); + response.put("payoutsEnabled", payoutsEnabled); + response.put("ownerId", creator.getId()); + + return response; + } + + public Map<String, Object> requestPayout(User requester, String ownerId, Long amountCentsInput, String requestKey) { + User creator = core.resolveFinanceOwner(requester, ownerId, true); + if (creator.getAccountType() == User.AccountType.ORGANIZATION) { + core.requireOrganizationOwner(requester, creator); + } + PlatformFinanceSettings settings = getSettings(); + return CreatorPayoutService.toResponse(payouts.request(requester, creator, settings.getCurrency(), amountCentsInput, settings.getMinPayoutCents(), requestKey)); + } + + public Map<String, Object> updateProjectMonetization(User requester, Project project, UpdateProjectMonetizationRequest request) { + core.requireProjectMonetizationOwner(requester, project); + if (request.getAdsEnabled() != null) { + project.setAdsEnabled(request.getAdsEnabled()); + } + if (request.getDonationsEnabled() != null) { + project.setDonationsEnabled(request.getDonationsEnabled()); + } + if (request.getSuggestedDonationCents() != null) { + int clamped = Math.max(100, Math.min(100000, request.getSuggestedDonationCents())); + project.setSuggestedDonationCents(clamped); + } + if (request.getDonationRecurringDefault() != null) { + project.setDonationRecurringDefault(request.getDonationRecurringDefault()); + } + if (request.getDonationPlatformCutBps() != null) { + project.setDonationPlatformCutBps(request.getDonationPlatformCutBps()); + } + + projectRepository.save(project); + projectService.evictProjectCache(project); + + return Map.of( + "ok", true, + "projectId", project.getId(), + "adsEnabled", project.isAdsEnabled(), + "donationsEnabled", project.isDonationsEnabled(), + "suggestedDonationCents", project.getSuggestedDonationCents(), + "donationRecurringDefault", project.isDonationRecurringDefault(), + "donationPlatformCutBps", project.getDonationPlatformCutBps() + ); + } + + public List<Map<String, Object>> getFinanceContexts(User requester) { + List<Map<String, Object>> contexts = new ArrayList<>(); + Map<String, Object> 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<String, Object> 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<String, Object> getOrgPayoutPolicy(User requester, String orgId) { + User org = core.resolveFinanceOwner(requester, orgId, true); + if (org.getAccountType() != User.AccountType.ORGANIZATION) { + throw new IllegalArgumentException("Finance policy is only available for organizations."); + } + + List<Map<String, Object>> 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<String, Object> 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<Map<String, Object>> members = new ArrayList<>(); + for (User.OrganizationMember member : org.getOrganizationMembers()) { + User user = userRepository.findById(member.getUserId()).orElse(null); + if (user == null) continue; + Map<String, Object> 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<String, Object> updateOrgPayoutPolicy(User requester, String orgId, String payoutModeRaw, List<Map<String, Object>> sharesRaw) { + User org = core.resolveFinanceOwner(requester, orgId, true); + core.requireOrganizationOwner(requester, org); + if (org.getAccountType() != User.AccountType.ORGANIZATION) { + throw new IllegalArgumentException("Finance policy is only available for organizations."); + } + + User.OrgPayoutMode mode; + try { + mode = User.OrgPayoutMode.valueOf(String.valueOf(payoutModeRaw)); + } catch (Exception e) { + throw new IllegalArgumentException("Invalid payout mode."); + } + + List<User.OrgPayoutShare> parsedShares = new ArrayList<>(); + if (sharesRaw != null) { + for (Map<String, Object> item : sharesRaw) { + if (item == null) continue; + String userId = item.get("userId") == null ? null : String.valueOf(item.get("userId")); + int percent = core.asInt(item.get("percent"), 0); + if (userId == null || userId.isBlank() || percent <= 0) continue; + parsedShares.add(new User.OrgPayoutShare(userId, percent)); + } + } + + if (mode == User.OrgPayoutMode.DISTRIBUTE_TO_MEMBERS) { + core.validateOrgPayoutShares(org, parsedShares); + } else { + parsedShares = new ArrayList<>(); + } + + org.setOrgPayoutMode(mode); + org.setOrgPayoutShares(parsedShares); + userRepository.save(org); + + return getOrgPayoutPolicy(requester, orgId); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/FinanceAmounts.java b/backend/src/main/java/net/modtale/service/finance/FinanceAmounts.java new file mode 100644 index 000000000..6d2c6337d --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/FinanceAmounts.java @@ -0,0 +1,23 @@ +package net.modtale.service.finance; + +/** Integer-only money validation for the currently supported two-decimal USD amounts. */ +public final class FinanceAmounts { + public static final long MIN_SUPPORT_CENTS = 100; + public static final long MAX_SUPPORT_CENTS = 100000; + + private FinanceAmounts() {} + + public static long validateSupportAmount(long cents) { + if (cents < MIN_SUPPORT_CENTS || cents > MAX_SUPPORT_CENTS) { + throw new IllegalArgumentException("Enter an amount between 1.00 and 1,000.00 USD."); + } + return cents; + } + + public static long share(long cents, int basisPoints) { + if (cents < 0 || basisPoints < 0 || basisPoints > 10000) { + throw new IllegalArgumentException("Invalid revenue allocation."); + } + return Math.addExact(Math.multiplyExact(cents, basisPoints), 5000) / 10000; + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/FinanceLedgerRules.java b/backend/src/main/java/net/modtale/service/finance/FinanceLedgerRules.java new file mode 100644 index 000000000..9eb13ee1c --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/FinanceLedgerRules.java @@ -0,0 +1,40 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.FinanceLedgerEntry; + +/** Provider events are not themselves settled revenue; moving money is not new revenue. */ +public final class FinanceLedgerRules { + private FinanceLedgerRules() {} + + public static boolean isReal(FinanceLedgerEntry entry) { + if (entry.getMetadata() != null && ("true".equals(entry.getMetadata().get("simulated")) + || "true".equals(entry.getMetadata().get("testMode")))) return false; + return entry.getStripeReference() == null || !entry.getStripeReference().startsWith("sim_"); + } + + public static boolean isRevenue(FinanceLedgerEntry entry) { + return entry.getType() == FinanceLedgerEntry.LedgerType.DONATION + || entry.getType() == FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT + || entry.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT + || entry.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT + || entry.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL + || entry.getType() == FinanceLedgerEntry.LedgerType.AD_CLICK + || entry.getType() == FinanceLedgerEntry.LedgerType.AD_IMPRESSION; + } + + public static boolean isRecognizedRevenue(FinanceLedgerEntry entry) { + return isReal(entry) && isSettledRevenue(entry); + } + + public static boolean isInMode(FinanceLedgerEntry entry, boolean testMode) { + if (entry.getMetadata() != null && "true".equals(entry.getMetadata().get("simulated"))) return false; + return testMode ? entry.getMetadata() != null && "true".equals(entry.getMetadata().get("testMode")) : isReal(entry); + } + + public static boolean isSettledRevenue(FinanceLedgerEntry entry) { + return isRevenue(entry) + && (entry.getStatus() == FinanceLedgerEntry.EntryStatus.AVAILABLE + || entry.getStatus() == FinanceLedgerEntry.EntryStatus.PAID) + && entry.getMetadata() != null && "settled".equals(entry.getMetadata().get("settlement")); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/FinanceSourceKey.java b/backend/src/main/java/net/modtale/service/finance/FinanceSourceKey.java new file mode 100644 index 000000000..e98db76e2 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/FinanceSourceKey.java @@ -0,0 +1,12 @@ +package net.modtale.service.finance; + +/** Provider object IDs are scoped to the platform account and test/live mode in our ledger. */ +public final class FinanceSourceKey { + private FinanceSourceKey() {} + public static String stripe(boolean testMode, String accountId, String objectId) { + if (accountId == null || !accountId.startsWith("acct_") || objectId == null || objectId.isBlank()) { + throw new IllegalArgumentException("A verified provider account and source object are required."); + } + return "stripe:" + (testMode ? "test:" : "live:") + accountId + ":" + objectId; + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/FinanceWalletService.java b/backend/src/main/java/net/modtale/service/finance/FinanceWalletService.java new file mode 100644 index 000000000..a50fedf00 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/FinanceWalletService.java @@ -0,0 +1,457 @@ +package net.modtale.service.finance; + +import com.mongodb.MongoException; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.function.Supplier; +import net.modtale.model.finance.CreatorPayoutRequest; +import net.modtale.model.finance.CreatorWallet; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.FinanceTransferReceipt; +import net.modtale.model.finance.FinanceDisputeCase; +import net.modtale.model.finance.FinanceDisputeResolution; +import org.springframework.data.mongodb.MongoDatabaseFactory; +import org.springframework.data.mongodb.MongoTransactionManager; +import org.springframework.data.mongodb.core.FindAndModifyOptions; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; + +/** Requires MongoDB replica-set transactions. Standalone databases fail closed before moving money. */ +@Service +public class FinanceWalletService { + private final MongoTemplate mongo; + private final TransactionTemplate transactions; + + public FinanceWalletService(MongoTemplate mongo, MongoDatabaseFactory databaseFactory) { + this.mongo = mongo; + this.transactions = new TransactionTemplate(new MongoTransactionManager(databaseFactory)); + } + + public static String walletId(String creatorId, String currency, boolean testMode) { + if (creatorId == null || creatorId.isBlank() || creatorId.contains(":")) throw new IllegalArgumentException("Invalid creator."); + if (currency == null || !currency.matches("[A-Za-z]{3}")) throw new IllegalArgumentException("Invalid currency."); + return (testMode ? "test:" : "live:") + creatorId + ":" + currency.toLowerCase(Locale.ROOT); + } + + public CreatorWallet getWallet(String creatorId, String currency, boolean testMode) { + String id = walletId(creatorId, currency, testMode); + CreatorWallet wallet = mongo.findById(id, CreatorWallet.class); + if (wallet != null) return wallet; + wallet = new CreatorWallet(); wallet.setId(id); wallet.setCreatorId(creatorId); + wallet.setCurrency(currency); wallet.setTestMode(testMode); + return wallet; + } + + public long getTotalAvailable(String currency, boolean testMode) { + var aggregation = org.springframework.data.mongodb.core.aggregation.Aggregation.newAggregation( + org.springframework.data.mongodb.core.aggregation.Aggregation.match(Criteria.where("currency").is(currency) + .and("testMode").is(testMode).and("availableCents").gt(0)), + org.springframework.data.mongodb.core.aggregation.Aggregation.group().sum("availableCents").as("amount")); + var result = mongo.aggregate(aggregation, CreatorWallet.class, org.bson.Document.class).getUniqueMappedResult(); + return result == null ? 0 : ((Number) result.get("amount")).longValue(); + } + + public void postSettledCredit(FinanceLedgerEntry credit, boolean testMode) { + if (credit.getId() == null || credit.getId().isBlank() || credit.getCreatorCents() < 0 + || credit.getStatus() != FinanceLedgerEntry.EntryStatus.AVAILABLE + || !"settled".equals(credit.getMetadata().get("settlement"))) { + throw new IllegalArgumentException("Only final, source-identified settled credits can fund a wallet."); + } + String id = walletId(credit.getCreatorId(), credit.getCurrency(), testMode); + credit.getMetadata().put("testMode", String.valueOf(testMode)); + transact(() -> { + FinanceLedgerEntry existing = mongo.findById(credit.getId(), FinanceLedgerEntry.class); + if (existing != null) { + if (!Objects.equals(existing.getCreatorId(), credit.getCreatorId()) + || existing.getCreatorCents() != credit.getCreatorCents() + || existing.getGrossCents() != credit.getGrossCents() + || !Objects.equals(existing.getCurrency(), credit.getCurrency()) + || existing.getPlatformCents() != credit.getPlatformCents() + || !Objects.equals(existing.getProcessorFeeCents(), credit.getProcessorFeeCents()) + || !Objects.equals(existing.getCreatorGrossCents(), credit.getCreatorGrossCents()) + || !Objects.equals(existing.getProjectId(), credit.getProjectId()) + || !Objects.equals(existing.getExternalReference(), credit.getExternalReference()) + || existing.getType() != credit.getType() || existing.getStatus() != credit.getStatus() + || !Objects.equals(existing.getMetadata().get("providerAccountId"), credit.getMetadata().get("providerAccountId")) + || !Objects.equals(existing.getMetadata().get("testMode"), String.valueOf(testMode))) { + throw new IllegalStateException("A conflicting settlement already uses this source reference."); + } + return null; + } + String accountId = credit.getMetadata().get("providerAccountId"); + if (accountId == null || !accountId.matches("acct_[A-Za-z0-9]+")) throw new IllegalArgumentException("Verified funding account is required."); + CreatorWallet wallet = mongo.findById(id, CreatorWallet.class); + if (wallet != null && (!Objects.equals(accountId, wallet.getProviderAccountId()) + && (wallet.getProviderAccountId() != null || wallet.getAvailableCents() != 0 || wallet.getReservedCents() != 0))) { + throw new IllegalStateException("Wallet funding scope needs reconciliation before accepting another account's funds."); + } + mongo.insert(credit); + mongo.upsert(Query.query(Criteria.where("_id").is(id)), new Update() + .setOnInsert("creatorId", credit.getCreatorId()).setOnInsert("currency", credit.getCurrency()) + .setOnInsert("testMode", testMode).setOnInsert("reservedCents", 0L).setOnInsert("payoutHold", false) + .set("providerAccountId", accountId).inc("availableCents", credit.getCreatorCents()), CreatorWallet.class); + return null; + }); + } + + public CreatorPayoutRequest reserve(String creatorId, String requesterId, String currency, boolean testMode, + String requestKey, long amountCents, long minimumCents, List<CreatorPayoutRequest.Recipient> recipients, String providerAccountId) { + if (providerAccountId == null || !providerAccountId.matches("acct_[A-Za-z0-9]+")) throw new IllegalArgumentException("Verified payout account is required."); + try { UUID.fromString(requestKey); } catch (RuntimeException invalid) { throw new IllegalArgumentException("A valid payout request key is required."); } + if (amountCents < minimumCents || minimumCents < 1) throw new IllegalArgumentException("Payout amount is below the minimum."); + if (recipients == null || recipients.isEmpty()) throw new IllegalArgumentException("No verified payout recipients."); + long total = 0; + var accountIds = new java.util.HashSet<String>(); + for (var recipient : recipients) { + if (recipient.getAccountId() == null || !recipient.getAccountId().startsWith("acct_") + || !accountIds.add(recipient.getAccountId()) || recipient.getAmountCents() <= 0) throw new IllegalArgumentException("Invalid payout recipient."); + total = Math.addExact(total, recipient.getAmountCents()); + } + if (total != amountCents) throw new IllegalArgumentException("Recipient allocations must equal the reserved payout."); + String walletId = walletId(creatorId, currency, testMode); + String payoutId = walletId + ":" + requestKey; + return transact(() -> { + CreatorPayoutRequest existing = mongo.findById(payoutId, CreatorPayoutRequest.class); + if (existing != null) { + if (existing.getAmountCents() != amountCents || !Objects.equals(existing.getRequestedBy(), requesterId) + || !Objects.equals(existing.getProviderAccountId(), providerAccountId)) { + throw new IllegalArgumentException("This request key was already used for a different payout."); + } + return existing; + } + var wallet = mongo.findAndModify(Query.query(Criteria.where("_id").is(walletId) + .and("providerAccountId").is(providerAccountId).and("availableCents").gte(amountCents).and("payoutHold").ne(true).and("openRiskIds.0").exists(false)), + new Update().inc("availableCents", -amountCents).inc("reservedCents", amountCents), + FindAndModifyOptions.options().returnNew(true), CreatorWallet.class); + if (wallet == null) throw new IllegalStateException("Insufficient settled funds or the account is on a payout hold."); + CreatorPayoutRequest request = new CreatorPayoutRequest(); + request.setId(payoutId); request.setWalletId(walletId); request.setCreatorId(creatorId); + request.setRequestedBy(requesterId); request.setCurrency(currency); request.setTestMode(testMode); + request.setAmountCents(amountCents); request.setProviderAccountId(providerAccountId); + request.setTransferGroup("modtale_" + UUID.randomUUID().toString().replace("-", "")); + for (var recipient : recipients) recipient.setCorrelationId(UUID.randomUUID().toString()); + request.setRecipients(recipients); + return mongo.insert(request); + }); + } + + public void holdForRisk(String creatorId, String currency, boolean testMode, String riskId) { + String id = walletId(creatorId, currency, testMode); + mongo.upsert(Query.query(Criteria.where("_id").is(id)), new Update() + .setOnInsert("creatorId", creatorId).setOnInsert("currency", currency).setOnInsert("testMode", testMode) + .setOnInsert("availableCents", 0L).setOnInsert("reservedCents", 0L).setOnInsert("payoutHold", false) + .addToSet("openRiskIds", riskId), CreatorWallet.class); + } + + public void resolveRisk(String creatorId, String currency, boolean testMode, String riskId) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(walletId(creatorId, currency, testMode))), + new Update().pull("openRiskIds", riskId), CreatorWallet.class); + } + + /** Reverses refunded principal once; provider refund fees/rebates are additional actual amounts. */ + public void postRefund(String originalCreditId, String adjustmentId, long refundedGrossCents, + long providerRefundFeeCents, String providerReference, boolean testMode) { + postPrincipalAdjustment(originalCreditId, adjustmentId, refundedGrossCents, providerRefundFeeCents, providerReference, testMode, FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT); + } + + public void postDisputePrincipal(String originalCreditId, String adjustmentId, long disputedCents, String providerReference, boolean testMode) { + postPrincipalAdjustment(originalCreditId, adjustmentId, disputedCents, 0, providerReference, testMode, FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT); + } + + private void postPrincipalAdjustment(String originalCreditId, String adjustmentId, long refundedGrossCents, + long providerRefundFeeCents, String providerReference, boolean testMode, FinanceLedgerEntry.LedgerType type) { + if (adjustmentId == null || refundedGrossCents <= 0) throw new IllegalArgumentException("Invalid principal adjustment."); + transact(() -> { postPrincipalAdjustmentInTransaction(originalCreditId, adjustmentId, refundedGrossCents, providerRefundFeeCents, providerReference, testMode, type); return null; }); + } + + private void postPrincipalAdjustmentInTransaction(String originalCreditId, String adjustmentId, long refundedGrossCents, + long providerRefundFeeCents, String providerReference, boolean testMode, FinanceLedgerEntry.LedgerType type) { + FinanceLedgerEntry existing = mongo.findById(adjustmentId, FinanceLedgerEntry.class); + if (existing != null) { + if (!Objects.equals(existing.getExternalReference(), originalCreditId) || existing.getGrossCents() != -refundedGrossCents + || !Objects.equals(existing.getProcessorFeeCents(), providerRefundFeeCents) || existing.getType() != type) throw new IllegalStateException("Conflicting refund source."); + return; + } + FinanceLedgerEntry original = mongo.findById(originalCreditId, FinanceLedgerEntry.class); + if (original == null || original.getGrossCents() <= 0 || !Boolean.valueOf(original.getMetadata().get("testMode")).equals(testMode)) { + throw new IllegalStateException("The original payment must be reconciled before its refund."); + } + List<FinanceLedgerEntry> prior = mongo.find(Query.query(Criteria.where("externalReference").is(originalCreditId) + .and("type").in(FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT, FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL)), FinanceLedgerEntry.class); + long previousGross = prior.stream().mapToLong(entry -> -entry.getGrossCents()).sum(); + long previousPlatform = prior.stream().mapToLong(entry -> -entry.getPlatformCents()).sum(); + long cumulativeGross = Math.addExact(previousGross, refundedGrossCents); + if (cumulativeGross > original.getGrossCents()) throw new IllegalArgumentException("Refunds exceed the recorded charge."); + long platformTotal = java.math.BigInteger.valueOf(original.getPlatformCents()).multiply(java.math.BigInteger.valueOf(cumulativeGross)) + .add(java.math.BigInteger.valueOf(original.getGrossCents() / 2)).divide(java.math.BigInteger.valueOf(original.getGrossCents())).longValueExact(); + long platformReversal = platformTotal - previousPlatform; + long creatorAdjustment = Math.subtractExact(-refundedGrossCents + platformReversal, providerRefundFeeCents); + FinanceLedgerEntry adjustment = new FinanceLedgerEntry(); adjustment.setId(adjustmentId); + adjustment.setCreatorId(original.getCreatorId()); adjustment.setProjectId(original.getProjectId()); + adjustment.setType(type); adjustment.setGrossCents(-refundedGrossCents); + adjustment.setCreatorCents(creatorAdjustment); adjustment.setPlatformCents(-platformReversal); adjustment.setProcessorFeeCents(providerRefundFeeCents); + adjustment.setCurrency(original.getCurrency()); adjustment.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); + adjustment.setExternalReference(originalCreditId); adjustment.setStripeReference(providerReference); + adjustment.getMetadata().put("settlement", "settled"); adjustment.getMetadata().put("testMode", String.valueOf(testMode)); + adjustment.getMetadata().put("adjustmentReason", type == FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT ? "provider_confirmed_refund" : "provider_confirmed_dispute_loss"); + mongo.insert(adjustment); + var result = mongo.updateFirst(Query.query(Criteria.where("_id").is(walletId(original.getCreatorId(), original.getCurrency(), testMode))), + new Update().inc("availableCents", creatorAdjustment), CreatorWallet.class); + if (result.getMatchedCount() != 1) throw new IllegalStateException("Original creator wallet not found."); + return; + } + + /** Marks a new observation in the same transaction as its hold, fencing older reviews. */ + public FinanceDisputeCase beginDisputeRefresh(String caseId, String disputeId, String chargeId, FinanceLedgerEntry original, boolean testMode) { + return transact(() -> { + String account = original.getMetadata().get("providerAccountId"); + FinanceDisputeCase existing = mongo.findById(caseId, FinanceDisputeCase.class); + if (existing != null && (!Objects.equals(existing.originalCreditId(), original.getId()) || !Objects.equals(existing.providerAccountId(), account))) throw new IllegalArgumentException("Dispute binding changed."); + holdForRisk(original.getCreatorId(), original.getCurrency(), testMode, disputeId); + return mongo.findAndModify(Query.query(Criteria.where("_id").is(caseId)), new Update() + .setOnInsert("disputeId", disputeId).setOnInsert("chargeId", chargeId).setOnInsert("creatorId", original.getCreatorId()) + .setOnInsert("currency", original.getCurrency()).setOnInsert("testMode", testMode).setOnInsert("providerAccountId", account) + .setOnInsert("originalCreditId", original.getId()).setOnInsert("balanceTransactions", List.of()) + .setOnInsert("disputedCents", 0L).setOnInsert("returnedPrincipalCents", 0L).setOnInsert("evidenceReady", false).setOnInsert("providerStatus", "pending") + .set("reviewStatus", "REFRESHING").set("updatedAt", Instant.now()).inc("version", 1L), + FindAndModifyOptions.options().upsert(true).returnNew(true), FinanceDisputeCase.class); + }); + } + + public void recordDisputeLoss(String caseId, String digest) { + transact(() -> { + FinanceDisputeCase dispute = requireCurrentDispute(caseId, digest); + if (!"lost".equals(dispute.providerStatus())) throw new IllegalArgumentException("Dispute is no longer a verified loss."); + if (mongo.exists(Query.query(Criteria.where("_id").is(FinanceSourceKey.stripe(dispute.testMode(), dispute.providerAccountId(), "dispute-return:" + dispute.disputeId()))), FinanceLedgerEntry.class)) throw new IllegalArgumentException("A reopened principal movement requires separate review."); + postPrincipalAdjustmentInTransaction(dispute.originalCreditId(), FinanceSourceKey.stripe(dispute.testMode(), dispute.providerAccountId(), "dispute-principal:" + dispute.disputeId()), + dispute.disputedCents(), 0, dispute.disputeId(), dispute.testMode(), FinanceLedgerEntry.LedgerType.DISPUTE_ADJUSTMENT); + mongo.updateFirst(Query.query(Criteria.where("_id").is(caseId)), new Update().inc("version", 1L), FinanceDisputeCase.class); + return null; + }); + } + + private FinanceDisputeCase requireCurrentDispute(String caseId, String digest) { + FinanceDisputeCase dispute = mongo.findById(caseId, FinanceDisputeCase.class); + if (dispute == null || !Objects.equals(digest, dispute.evidenceDigest()) || !dispute.evidenceReady() + || !List.of("POLICY_REVIEW_REQUIRED", "RESOLVED").contains(dispute.reviewStatus()) || dispute.principalMovementCents() == null || dispute.actualFeeCents() == null || dispute.actualFeeCents() < 0) { + throw new IllegalArgumentException("Dispute evidence is incomplete or changed; refresh the review."); + } + return dispute; + } + + public void clearReviewedDisputeRisk(String caseId, String digest) { + transact(() -> { + FinanceDisputeCase dispute = requireCurrentDispute(caseId, digest); + if (!mongo.exists(Query.query(Criteria.where("_id").is(caseId + ":" + digest)), FinanceDisputeResolution.class)) throw new IllegalArgumentException("Dispute policy review is missing."); + resolveRisk(dispute.creatorId(), dispute.currency(), dispute.testMode(), dispute.disputeId()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(caseId)), new Update().inc("version", 1L), FinanceDisputeCase.class); + return null; + }); + } + + /** Explicit cumulative fee allocation and any proven late-win restoration are one immutable transaction. */ + public FinanceDisputeResolution resolveDispute(String caseId, String digest, long creatorFeeCents, String reviewer, String reason) { + return transact(() -> { + FinanceDisputeCase dispute = requireCurrentDispute(caseId, digest); + if (creatorFeeCents < 0 || creatorFeeCents > dispute.actualFeeCents()) throw new IllegalArgumentException("Creator fees must be explicitly allocated within verified actual costs."); + String resolutionId = caseId + ":" + digest; + FinanceDisputeResolution priorResolution = mongo.findById(resolutionId, FinanceDisputeResolution.class); + if (priorResolution != null) { + if (priorResolution.creatorFeeCents() != creatorFeeCents) throw new IllegalArgumentException("This evidence already has a different immutable fee decision."); + resolveRisk(dispute.creatorId(), dispute.currency(), dispute.testMode(), dispute.disputeId()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(caseId)), new Update().set("reviewStatus", "RESOLVED").inc("version", 1L), FinanceDisputeCase.class); + return priorResolution; + } + FinanceLedgerEntry original = mongo.findById(dispute.originalCreditId(), FinanceLedgerEntry.class); + if (original == null || !Objects.equals(original.getMetadata().get("providerAccountId"), dispute.providerAccountId())) throw new IllegalArgumentException("Original funding evidence changed."); + CreatorWallet wallet = getWallet(dispute.creatorId(), dispute.currency(), dispute.testMode()); + if (!Objects.equals(wallet.getProviderAccountId(), dispute.providerAccountId())) throw new IllegalArgumentException("Wallet funding scope requires separate reconciliation."); + String lossId = FinanceSourceKey.stripe(dispute.testMode(), dispute.providerAccountId(), "dispute-principal:" + dispute.disputeId()); + String returnId = FinanceSourceKey.stripe(dispute.testMode(), dispute.providerAccountId(), "dispute-return:" + dispute.disputeId()); + FinanceLedgerEntry loss = mongo.findById(lossId, FinanceLedgerEntry.class); + FinanceLedgerEntry returned = mongo.findById(returnId, FinanceLedgerEntry.class); + long creatorChange = 0; + if ("lost".equals(dispute.providerStatus())) { + if (loss == null || returned != null || loss.getGrossCents() != -dispute.disputedCents()) throw new IllegalArgumentException("Loss principal needs reconciliation before closing its hold."); + } else if (loss != null && returned == null) { + if (!"won".equals(dispute.providerStatus()) || dispute.principalMovementCents() != 0 || dispute.returnedPrincipalCents() < dispute.disputedCents()) throw new IllegalArgumentException("No settled principal-return evidence is available."); + FinanceLedgerEntry reversal = disputeAdjustment(original, dispute, returnId, FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL); + reversal.setGrossCents(-loss.getGrossCents()); reversal.setCreatorCents(-loss.getCreatorCents()); reversal.setPlatformCents(-loss.getPlatformCents()); reversal.setProcessorFeeCents(0L); + mongo.insert(reversal); creatorChange = Math.addExact(creatorChange, reversal.getCreatorCents()); + } + List<FinanceLedgerEntry> feeHistory = mongo.find(Query.query(Criteria.where("type").is(FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT) + .and("metadata.disputeCaseId").is(caseId)), FinanceLedgerEntry.class); + long priorCreatorFees = 0, priorActualFees = 0; + for (var fee : feeHistory) { priorCreatorFees = Math.addExact(priorCreatorFees, -fee.getCreatorCents()); priorActualFees = Math.addExact(priorActualFees, fee.getProcessorFeeCents()); } + long creatorDelta = Math.subtractExact(creatorFeeCents, priorCreatorFees); + long actualDelta = Math.subtractExact(dispute.actualFeeCents(), priorActualFees); + if (creatorDelta != 0 || actualDelta != 0) { + FinanceLedgerEntry fee = disputeAdjustment(original, dispute, resolutionId + ":fee", FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT); + fee.setGrossCents(0); fee.setCreatorCents(-creatorDelta); fee.setPlatformCents(Math.negateExact(Math.subtractExact(actualDelta, creatorDelta))); fee.setProcessorFeeCents(actualDelta); + mongo.insert(fee); creatorChange = Math.subtractExact(creatorChange, creatorDelta); + } + var decision = new FinanceDisputeResolution(resolutionId, caseId, dispute.disputeId(), digest, dispute.providerAccountId(), dispute.testMode(), + dispute.providerStatus(), dispute.actualFeeCents(), creatorFeeCents, reviewer, reason, Instant.now(), dispute.currency(), dispute.disputedCents(), + dispute.principalMovementCents(), dispute.returnedPrincipalCents(), List.copyOf(dispute.balanceTransactions())); + mongo.insert(decision); + var result = mongo.updateFirst(Query.query(Criteria.where("_id").is(wallet.getId()).and("providerAccountId").is(dispute.providerAccountId())), + new Update().inc("availableCents", creatorChange).pull("openRiskIds", dispute.disputeId()), CreatorWallet.class); + if (result.getMatchedCount() != 1) throw new IllegalStateException("Wallet scope changed during dispute resolution."); + mongo.updateFirst(Query.query(Criteria.where("_id").is(caseId)), new Update().set("reviewStatus", "RESOLVED").inc("version", 1L), FinanceDisputeCase.class); + return decision; + }); + } + + private FinanceLedgerEntry disputeAdjustment(FinanceLedgerEntry original, FinanceDisputeCase dispute, String id, FinanceLedgerEntry.LedgerType type) { + FinanceLedgerEntry entry = new FinanceLedgerEntry(); entry.setId(id); entry.setCreatorId(original.getCreatorId()); entry.setProjectId(original.getProjectId()); + entry.setType(type); entry.setCurrency(original.getCurrency()); entry.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); + entry.setExternalReference(original.getId()); entry.setStripeReference(dispute.disputeId()); + entry.getMetadata().put("settlement", "settled"); entry.getMetadata().put("testMode", String.valueOf(dispute.testMode())); + entry.getMetadata().put("providerAccountId", dispute.providerAccountId()); entry.getMetadata().put("disputeCaseId", dispute.id()); + entry.getMetadata().put("evidenceDigest", dispute.evidenceDigest()); return entry; + } + + public List<CreatorPayoutRequest> getRecentRequests(String creatorId, boolean testMode) { + return mongo.find(Query.query(Criteria.where("creatorId").is(creatorId).and("testMode").is(testMode)) + .with(org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "createdAt")).limit(20), CreatorPayoutRequest.class); + } + + public CreatorPayoutRequest getRequest(String id) { return mongo.findById(id, CreatorPayoutRequest.class); } + + public List<CreatorPayoutRequest> getUnfinishedRequests(boolean testMode) { + return mongo.find(Query.query(Criteria.where("status").in(CreatorPayoutRequest.Status.RESERVED, CreatorPayoutRequest.Status.PROCESSING).and("testMode").is(testMode)).with(org.springframework.data.domain.Sort.by("lastDispatchAttemptAt", "createdAt")).limit(100), CreatorPayoutRequest.class); + } + + public void noteDispatchAttempt(String id) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id).and("status").in(CreatorPayoutRequest.Status.RESERVED, CreatorPayoutRequest.Status.PROCESSING)), + new Update().set("lastDispatchAttemptAt", Instant.now()), CreatorPayoutRequest.class); + } + + public CreatorPayoutRequest markAttempted(String id) { + return mongo.findAndModify(Query.query(Criteria.where("_id").is(id).and("firstAttemptAt").is(null) + .and("status").is(CreatorPayoutRequest.Status.RESERVED)), + new Update().set("status", CreatorPayoutRequest.Status.PROCESSING), + FindAndModifyOptions.options().returnNew(true), CreatorPayoutRequest.class); + } + + /** Linearizes each outbound authorization against risk holds on the same wallet document. */ + public boolean authorizeRecipientTransfer(String requestId, int recipientIndex) { + return Boolean.TRUE.equals(transact(() -> { + CreatorPayoutRequest request = mongo.findById(requestId, CreatorPayoutRequest.class); + if (request == null || request.getStatus() != CreatorPayoutRequest.Status.PROCESSING + || recipientIndex < 0 || recipientIndex >= request.getRecipients().size()) return false; + var recipient = request.getRecipients().get(recipientIndex); + if (recipient.getTransferId() != null || request.getProviderAccountId() == null || request.getTransferGroup() == null + || recipient.getCorrelationId() == null) return false; + Instant now = Instant.now(); + if (recipient.getAuthorizedAt() != null && recipient.getAuthorizedAt().isBefore(now.minus(java.time.Duration.ofHours(23)))) return false; + var result = mongo.updateFirst(Query.query(Criteria.where("_id").is(request.getWalletId()) + .and("providerAccountId").is(request.getProviderAccountId()).and("payoutHold").ne(true).and("openRiskIds.0").exists(false).and("availableCents").gte(0)), + new Update().inc("dispatchAuthorizationSequence", 1L), CreatorWallet.class); + if (result.getModifiedCount() != 1) return false; + Update authorization = new Update(); + if (recipient.getAuthorizedAt() == null) authorization.set("recipients." + recipientIndex + ".authorizedAt", now); + if (request.getFirstAttemptAt() == null) authorization.set("firstAttemptAt", now); + if (!authorization.getUpdateObject().isEmpty()) mongo.updateFirst(Query.query(Criteria.where("_id").is(requestId)), authorization, CreatorPayoutRequest.class); + return true; + })); + } + + /** Books confirmed outgoing money even if a newer risk hold interrupted the request. No hold is cleared. */ + public void recordTransfer(String id, int recipientIndex, String transferId, String actor, String reason) { + if (transferId == null || !transferId.matches("tr_[A-Za-z0-9]+") || actor == null || reason == null) throw new IllegalArgumentException("Verified transfer evidence is required."); + transact(() -> { + CreatorPayoutRequest request = mongo.findById(id, CreatorPayoutRequest.class); + if (request == null || recipientIndex < 0 || recipientIndex >= request.getRecipients().size() + || request.getProviderAccountId() == null || request.getTransferGroup() == null) throw new IllegalArgumentException("Payout scope needs reconciliation."); + var recipient = request.getRecipients().get(recipientIndex); + if (recipient.getAuthorizedAt() == null) throw new IllegalArgumentException("No recorded outbound authorization exists for this recipient."); + String receiptId = FinanceSourceKey.stripe(request.isTestMode(), request.getProviderAccountId(), "transfer:" + transferId); + var prior = mongo.findById(receiptId, FinanceTransferReceipt.class); + if (prior != null) { + if (!id.equals(prior.payoutRequestId()) || recipientIndex != prior.recipientIndex()) throw new IllegalStateException("This transfer is already claimed by another payout."); + if (!transferId.equals(recipient.getTransferId())) throw new IllegalStateException("Transfer receipt and payout require reconciliation."); + return null; + } + if (recipient.getTransferId() != null) throw new IllegalStateException("This recipient already has a different transfer."); + if (request.getStatus() != CreatorPayoutRequest.Status.PROCESSING && request.getStatus() != CreatorPayoutRequest.Status.REQUIRES_REVIEW) throw new IllegalStateException("Payout is not awaiting transfer confirmation."); + Instant now = Instant.now(); + mongo.insert(new FinanceTransferReceipt(receiptId, id, recipientIndex, transferId, request.getProviderAccountId(), request.isTestMode(), + recipient.getAccountId(), recipient.getAmountCents(), request.getCurrency(), actor, reason, now)); + var result = mongo.updateFirst(Query.query(Criteria.where("_id").is(request.getWalletId()) + .and("providerAccountId").is(request.getProviderAccountId()).and("reservedCents").gte(recipient.getAmountCents())), + new Update().inc("reservedCents", -recipient.getAmountCents()), CreatorWallet.class); + if (result.getModifiedCount() != 1) throw new IllegalStateException("Payout reservation needs reconciliation."); + FinanceLedgerEntry entry = new FinanceLedgerEntry(); entry.setId("payout:" + id + ":" + recipientIndex); + entry.setCreatorId(request.getCreatorId()); entry.setType(FinanceLedgerEntry.LedgerType.PAYOUT); + entry.setCurrency(request.getCurrency()); entry.setCreatorCents(-recipient.getAmountCents()); + entry.setStatus(FinanceLedgerEntry.EntryStatus.PAID); entry.setCompletedAt(LocalDateTime.now()); entry.setExternalReference(id); + entry.getMetadata().put("testMode", String.valueOf(request.isTestMode())); entry.getMetadata().put("providerAccountId", request.getProviderAccountId()); + entry.getMetadata().put("paymentStage", "connected_account_transfer"); entry.setStripeReference(transferId); mongo.insert(entry); + recipient.setTransferId(transferId); recipient.setConfirmedBy(actor); recipient.setConfirmationReason(reason); recipient.setConfirmedAt(now); + if (request.getRecipients().stream().allMatch(r -> r.getTransferId() != null)) { request.setStatus(CreatorPayoutRequest.Status.TRANSFERRED); request.setCompletedAt(now); } + mongo.save(request); return null; + }); + } + + public void requireReview(String id, String reason) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(id).and("status").in(CreatorPayoutRequest.Status.PROCESSING, CreatorPayoutRequest.Status.RESERVED)), + new Update().set("status", CreatorPayoutRequest.Status.REQUIRES_REVIEW).set("reviewReason", reason), CreatorPayoutRequest.class); + } + + /** A stale dispatch failure must not pause recipients already confirmed by another worker. */ + public void requireRecipientReview(String id, int recipientIndex, String reason) { + if (recipientIndex < 0) throw new IllegalArgumentException("Payout recipient not found."); + transact(() -> { + CreatorPayoutRequest request = mongo.findById(id, CreatorPayoutRequest.class); + if (request == null || request.getStatus() != CreatorPayoutRequest.Status.PROCESSING + || recipientIndex >= request.getRecipients().size() || request.getRecipients().get(recipientIndex).getTransferId() != null) return null; + // Indexed null predicates do not reliably select one array element. Read the exact + // recipient, then write this same document so concurrent confirmations force a retry. + mongo.updateFirst(Query.query(Criteria.where("_id").is(id).and("status").is(CreatorPayoutRequest.Status.PROCESSING)), + new Update().set("status", CreatorPayoutRequest.Status.REQUIRES_REVIEW).set("reviewReason", reason), CreatorPayoutRequest.class); + return null; + }); + } + + public CreatorPayoutRequest completeTransfers(String id) { + CreatorPayoutRequest request = getRequest(id); + if (request == null) throw new IllegalArgumentException("Payout not found."); + if (request.getStatus() != CreatorPayoutRequest.Status.TRANSFERRED || request.getRecipients().stream().anyMatch(r -> r.getTransferId() == null)) { + throw new IllegalStateException("All recipient transfers must be independently confirmed."); + } + return request; + } + + public List<CreatorPayoutRequest> getReviewRequests() { + return mongo.find(Query.query(Criteria.where("status").in(CreatorPayoutRequest.Status.PROCESSING, CreatorPayoutRequest.Status.REQUIRES_REVIEW)) + .with(org.springframework.data.domain.Sort.by("createdAt")).limit(100), CreatorPayoutRequest.class); + } + + private <T> T transact(Supplier<T> work) { + for (int attempt = 0; ; attempt++) { + try { return transactions.execute(status -> work.get()); } + catch (RuntimeException failure) { + Throwable root = failure; + boolean retryable = false; + while (root != null) { + if (root instanceof MongoException mongoFailure && (mongoFailure.hasErrorLabel("TransientTransactionError") + || mongoFailure.hasErrorLabel("UnknownTransactionCommitResult"))) retryable = true; + root = root.getCause(); + } + if (!retryable || attempt >= 4) throw failure; + try { Thread.sleep(5L << attempt); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); throw new IllegalStateException("Financial transaction retry interrupted.", interrupted); } + } + } + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/PaymentAdjustmentService.java b/backend/src/main/java/net/modtale/service/finance/PaymentAdjustmentService.java new file mode 100644 index 000000000..9ed62fa9a --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/PaymentAdjustmentService.java @@ -0,0 +1,179 @@ +package net.modtale.service.finance; + +import java.util.List; +import java.util.Map; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.FinanceDisputeCase; +import net.modtale.model.finance.FinanceDisputeResolution; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** Refund collection never issues bank debits; negative balances offset future earnings only. */ +@Service +public class PaymentAdjustmentService { + private final MongoTemplate mongo; + private final StripeGatewayService gateway; + private final FinanceWalletService wallets; + public PaymentAdjustmentService(MongoTemplate mongo, StripeGatewayService gateway, FinanceWalletService wallets) { + this.mongo = mongo; this.gateway = gateway; this.wallets = wallets; + } + + public void synchronizeCharge(String chargeId) { synchronizeCharge(chargeId, chargeId); } + + public void synchronizeCharge(String chargeId, String riskKey) { + FinanceLedgerEntry original = mongo.findOne(Query.query(Criteria.where("type").is(FinanceLedgerEntry.LedgerType.DONATION) + .and("metadata.settlement").is("settled").and("metadata.chargeId").is(chargeId) + .and("metadata.testMode").is(String.valueOf(gateway.isTestMode())).and("metadata.providerAccountId").is(gateway.getPlatformAccountId())), FinanceLedgerEntry.class); + if (original == null) throw new IllegalArgumentException("Payment settlement is not recorded yet."); + boolean testMode = Boolean.parseBoolean(original.getMetadata().get("testMode")); + if (!gateway.getPlatformAccountId().equals(original.getMetadata().get("providerAccountId"))) throw new IllegalArgumentException("Payment account scope changed."); + wallets.holdForRisk(original.getCreatorId(), original.getCurrency(), testMode, riskKey); + Map<String, Object> charge = gateway.getCharge(chargeId); + if (!(charge.get("livemode") instanceof Boolean) || !chargeId.equals(charge.get("id")) || !original.getCurrency().equals(charge.get("currency")) + || testMode != Boolean.FALSE.equals(charge.get("livemode"))) throw new IllegalArgumentException("Could not verify refund source."); + String after = null; + long refunded = 0; + boolean allFinal = true; + int pages = 0; + do { + Map<String, Object> page = gateway.getChargeRefunds(chargeId, after); + if (!(page.get("data") instanceof List<?> refunds) || !(page.get("has_more") instanceof Boolean)) throw new IllegalArgumentException("Could not reconcile refunds."); + for (Object value : refunds) { + if (!(value instanceof Map<?, ?> refund) || !(refund.get("id") instanceof String refundId)) throw new IllegalArgumentException("Invalid provider refund."); + after = refundId; + if (!"succeeded".equals(refund.get("status"))) { + if (!"failed".equals(refund.get("status")) && !"canceled".equals(refund.get("status"))) allFinal = false; + continue; + } + long amount = number(refund.get("amount")); + if (amount <= 0 || !chargeId.equals(refund.get("charge")) || !original.getCurrency().equals(refund.get("currency")) + || !(refund.get("balance_transaction") instanceof Map<?, ?> balance) || !"available".equals(balance.get("status"))) { allFinal = false; continue; } + long fee = number(balance.get("fee")); + if (!(balance.get("id") instanceof String balanceId) || !original.getCurrency().equals(balance.get("currency")) + || number(balance.get("amount")) != -amount || fee == Long.MIN_VALUE || number(balance.get("net")) != -amount - fee) { allFinal = false; continue; } + wallets.postRefund(original.getId(), FinanceSourceKey.stripe(testMode, original.getMetadata().get("providerAccountId"), "refund:" + refundId), amount, fee, balanceId, testMode); + refunded = Math.addExact(refunded, amount); + } + if (!Boolean.TRUE.equals(page.get("has_more"))) break; + if (refunds.isEmpty() || ++pages > 100) throw new IllegalArgumentException("Refund pagination requires review."); + } while (true); + if (allFinal && refunded == number(charge.get("amount_refunded")) + && (!Boolean.TRUE.equals(charge.get("disputed")) || reconcileAllChargeDisputes(original, chargeId))) { + wallets.resolveRisk(original.getCreatorId(), original.getCurrency(), testMode, riskKey); + } + } + + public void synchronizeDispute(String disputeId, String chargeId) { + FinanceLedgerEntry original = mongo.findOne(Query.query(Criteria.where("type").is(FinanceLedgerEntry.LedgerType.DONATION) + .and("metadata.settlement").is("settled").and("metadata.chargeId").is(chargeId) + .and("metadata.testMode").is(String.valueOf(gateway.isTestMode())).and("metadata.providerAccountId").is(gateway.getPlatformAccountId())), FinanceLedgerEntry.class); + if (original == null) throw new IllegalArgumentException("Payment settlement is not recorded yet."); + boolean testMode = Boolean.parseBoolean(original.getMetadata().get("testMode")); + String accountId = original.getMetadata().get("providerAccountId"); + if (!gateway.getPlatformAccountId().equals(accountId)) throw new IllegalArgumentException("Payment account scope changed."); + String caseId = FinanceSourceKey.stripe(testMode, accountId, "dispute-case:" + disputeId); + FinanceDisputeCase previous = wallets.beginDisputeRefresh(caseId, disputeId, chargeId, original, testMode); + Map<String, Object> dispute = gateway.getDispute(disputeId); + if (!(dispute.get("livemode") instanceof Boolean) || !disputeId.equals(dispute.get("id")) || !chargeId.equals(dispute.get("charge")) + || !original.getCurrency().equals(dispute.get("currency")) || testMode != Boolean.FALSE.equals(dispute.get("livemode"))) { + throw new IllegalArgumentException("Could not verify dispute source."); + } + long amount = number(dispute.get("amount")); + if (amount <= 0 || amount > original.getGrossCents()) throw new IllegalArgumentException("Dispute amount needs review."); + String status = String.valueOf(dispute.get("status")); + DisputeEvidence evidence = DisputeEvidence.read(accountId, testMode, disputeId, chargeId, original.getCurrency(), amount, status, dispute.get("balance_transactions")); + boolean eligible = evidence.ready() && evidence.actualFeeCents() != null && evidence.actualFeeCents() >= 0 + && (("lost".equals(status) && evidence.principalMovementCents() == -amount) + || ("won".equals(status) && evidence.principalMovementCents() == 0 && evidence.returnedPrincipalCents() >= amount) + || (List.of("warning_closed", "prevented").contains(status) && evidence.principalMovementCents() == 0)); + if ("lost".equals(status) && mongo.exists(Query.query(Criteria.where("_id").is(FinanceSourceKey.stripe(testMode, accountId, "dispute-return:" + disputeId))), FinanceLedgerEntry.class)) eligible = false; + String resolutionId = caseId + ":" + evidence.digest(); + String reviewStatus = eligible ? (mongo.exists(Query.query(Criteria.where("_id").is(resolutionId)), FinanceDisputeResolution.class) ? "RESOLVED" : "POLICY_REVIEW_REQUIRED") : "EVIDENCE_PENDING"; + try { + mongo.save(new FinanceDisputeCase(caseId, disputeId, chargeId, original.getCreatorId(), original.getCurrency(), testMode, status, amount, + evidence.actualFeeCents(), reviewStatus, java.time.Instant.now(), accountId, original.getId(), evidence.principalMovementCents(), + evidence.returnedPrincipalCents(), eligible, evidence.digest(), evidence.balances(), previous == null ? null : previous.version())); + } catch (org.springframework.dao.OptimisticLockingFailureException | org.springframework.dao.DuplicateKeyException changed) { + throw new IllegalArgumentException("Dispute evidence changed during reconciliation; retry the canonical lookup."); + } + if (eligible && "lost".equals(status)) wallets.recordDisputeLoss(caseId, evidence.digest()); + if ("RESOLVED".equals(reviewStatus)) wallets.clearReviewedDisputeRisk(caseId, evidence.digest()); + } + + private boolean reconcileAllChargeDisputes(FinanceLedgerEntry original, String chargeId) { + String after = null; int pages = 0; boolean reviewed = true; var seen = new java.util.HashSet<String>(); + do { + Map<String, Object> page = gateway.getChargeDisputes(chargeId, after); + if (!(page.get("data") instanceof List<?> values) || !(page.get("has_more") instanceof Boolean)) throw new IllegalArgumentException("Could not enumerate charge disputes."); + for (Object value : values) { + if (!(value instanceof Map<?, ?> dispute) || !(dispute.get("id") instanceof String id) || !chargeId.equals(dispute.get("charge")) + || !(dispute.get("livemode") instanceof Boolean) || gateway.isTestMode() != Boolean.FALSE.equals(dispute.get("livemode"))) throw new IllegalArgumentException("Dispute list source needs review."); + after = id; + if (!seen.add(id)) continue; + synchronizeDispute(id, chargeId); + FinanceDisputeCase current = mongo.findById(FinanceSourceKey.stripe(gateway.isTestMode(), original.getMetadata().get("providerAccountId"), "dispute-case:" + id), FinanceDisputeCase.class); + if (current == null || !"RESOLVED".equals(current.reviewStatus())) reviewed = false; + } + if (!Boolean.TRUE.equals(page.get("has_more"))) break; + if (values.isEmpty() || ++pages > 100) throw new IllegalArgumentException("Dispute pagination requires review."); + } while (true); + return !seen.isEmpty() && reviewed; + } + + public FinanceDisputeResolution resolveCase(String caseId, String expectedEvidenceDigest, long creatorFeeCents, + net.modtale.model.user.User reviewer, String reason) { + FinanceDisputeCase before = mongo.findById(caseId, FinanceDisputeCase.class); + if (before == null) throw new IllegalArgumentException("Dispute case not found."); + if (reviewer == null || reviewer.getId() == null || reason == null || reason.isBlank() || reason.length() > 1000) throw new IllegalArgumentException("A reviewer and policy reason are required."); + if (!gateway.isReconciliationEnabled() || gateway.isTestMode() != before.testMode() || !gateway.verifyPlatformAccountId(before.providerAccountId())) throw new IllegalArgumentException("Dispute provider account or mode does not match."); + synchronizeDispute(before.disputeId(), before.chargeId()); + FinanceDisputeResolution resolution = wallets.resolveDispute(caseId, expectedEvidenceDigest, creatorFeeCents, reviewer.getId(), reason.trim()); + // Generic charge/refund holds are independent. Repair only already-existing holds after enumerating all related disputes. + for (String risk : wallets.getWallet(before.creatorId(), before.currency(), before.testMode()).getOpenRiskIds()) { + if (risk.equals(before.chargeId()) || risk.endsWith(":" + before.chargeId())) { + try { synchronizeCharge(before.chargeId(), risk); } catch (IllegalArgumentException | IllegalStateException pending) { /* Retain this precise hold. */ } + } + } + return resolution; + } + + public List<net.modtale.model.finance.FinanceDisputeCase> getDisputeCases() { + return mongo.find(new Query().with(org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "updatedAt")).limit(100), net.modtale.model.finance.FinanceDisputeCase.class); + } + + public FinanceDisputeCase refreshCase(String caseId) { + FinanceDisputeCase current = mongo.findById(caseId, FinanceDisputeCase.class); + if (current == null) throw new IllegalArgumentException("Dispute case not found."); + if (!gateway.isReconciliationEnabled() || gateway.isTestMode() != current.testMode() || !gateway.verifyPlatformAccountId(current.providerAccountId())) throw new IllegalArgumentException("Dispute provider account or mode does not match."); + synchronizeDispute(current.disputeId(), current.chargeId()); + return mongo.findById(caseId, FinanceDisputeCase.class); + } + + public List<FinanceDisputeResolution> getDisputeDecisions(String caseId) { + return mongo.find(Query.query(Criteria.where("caseId").is(caseId)) + .with(org.springframework.data.domain.Sort.by(org.springframework.data.domain.Sort.Direction.DESC, "createdAt")).limit(100), FinanceDisputeResolution.class); + } + + @Scheduled(fixedDelayString = "${app.finance.reconciliation-interval-ms:3600000}") + public void reconcileHeldCharges() { + if (!gateway.isReconciliationEnabled()) return; + for (FinanceDisputeCase dispute : mongo.find(Query.query(Criteria.where("testMode").is(gateway.isTestMode()) + .and("providerAccountId").is(gateway.getPlatformAccountId())).with(org.springframework.data.domain.Sort.by("updatedAt")).limit(100), FinanceDisputeCase.class)) { + try { synchronizeDispute(dispute.disputeId(), dispute.chargeId()); } catch (IllegalArgumentException | IllegalStateException pending) { /* Includes late wins; do not treat a recorded loss as irreversible. */ } + } + for (var wallet : mongo.find(Query.query(Criteria.where("testMode").is(gateway.isTestMode()).and("openRiskIds.0").exists(true)).limit(100), net.modtale.model.finance.CreatorWallet.class)) { + for (String id : wallet.getOpenRiskIds()) if (id.startsWith("ch_") || id.startsWith("evt_")) { + String chargeId = id.contains(":") ? id.substring(id.indexOf(':') + 1) : id; + try { synchronizeCharge(chargeId, id); } catch (IllegalArgumentException pending) { /* Keep the precise hold until provider data is final. */ } + } + } + } + + static long number(Object value) { + if (!(value instanceof Number)) return Long.MIN_VALUE; + try { return new java.math.BigDecimal(value.toString()).longValueExact(); } catch (ArithmeticException invalid) { return Long.MIN_VALUE; } + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/PaymentSettlementService.java b/backend/src/main/java/net/modtale/service/finance/PaymentSettlementService.java new file mode 100644 index 000000000..e81ebe6f3 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/PaymentSettlementService.java @@ -0,0 +1,100 @@ +package net.modtale.service.finance; + +import java.time.LocalDateTime; +import java.util.Map; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Criteria; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.data.mongodb.core.query.Update; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +/** Provider availability and actual fees, rather than elapsed guesses, determine payable credit. */ +@Service +public class PaymentSettlementService { + private final FinanceLedgerEntryRepository ledger; + private final StripeGatewayService gateway; + private final FinanceWalletService wallets; + private final MongoTemplate mongo; + private final PaymentAdjustmentService adjustments; + + public PaymentSettlementService(FinanceLedgerEntryRepository ledger, StripeGatewayService gateway, + FinanceWalletService wallets, MongoTemplate mongo, PaymentAdjustmentService adjustments) { + this.ledger = ledger; this.gateway = gateway; this.wallets = wallets; this.mongo = mongo; this.adjustments = adjustments; + } + + @Scheduled(fixedDelayString = "${app.finance.reconciliation-interval-ms:3600000}") + public void reconcilePendingPayments() { + if (!gateway.isReconciliationEnabled()) return; + String accountId = gateway.getPlatformAccountId(); + for (FinanceLedgerEntry pending : mongo.find(Query.query(Criteria.where("type").is(FinanceLedgerEntry.LedgerType.DONATION) + .and("status").is(FinanceLedgerEntry.EntryStatus.PENDING) + .and("metadata.testMode").is(String.valueOf(gateway.isTestMode())) + .and("metadata.providerAccountId").is(accountId)).with(org.springframework.data.domain.Sort.by("metadata.lastReconciliationAttemptAt")).limit(100), FinanceLedgerEntry.class)) { + mongo.updateFirst(Query.query(Criteria.where("_id").is(pending.getId())), + new Update().set("metadata.lastReconciliationAttemptAt", java.time.Instant.now().toString()), FinanceLedgerEntry.class); + try { reconcile(pending); } + catch (IllegalArgumentException | IllegalStateException needsReview) { + // A conflicting/legacy wallet must not starve unrelated settled payments in this batch. + mongo.updateFirst(Query.query(Criteria.where("_id").is(pending.getId())), + new Update().set("metadata.reconciliationReview", "source_or_wallet_scope_conflict"), FinanceLedgerEntry.class); + } + } + } + + public void reconcile(FinanceLedgerEntry pending) { + String paymentId = pending.getMetadata().get("paymentIntentId"); + if (paymentId == null || Boolean.parseBoolean(pending.getMetadata().get("simulated"))) return; + if (!gateway.getPlatformAccountId().equals(pending.getMetadata().get("providerAccountId"))) return; + Map<String, Object> payment = gateway.getPaymentWithBalanceTransaction(paymentId); + FinanceLedgerEntry credit = settledCredit(pending, payment); + if (credit == null) return; + boolean testMode = Boolean.FALSE.equals(payment.get("livemode")); + Map<?, ?> charge = (Map<?, ?>) payment.get("latest_charge"); + boolean hasRisk = Boolean.TRUE.equals(charge.get("disputed")) || number(charge.get("amount_refunded")) > 0; + if (hasRisk) wallets.holdForRisk(credit.getCreatorId(), credit.getCurrency(), testMode, String.valueOf(charge.get("id"))); + wallets.postSettledCredit(credit, testMode); + if (hasRisk) adjustments.synchronizeCharge(String.valueOf(charge.get("id"))); + // The observation is retained as evidence but is no longer a pending earning estimate. + // A crash before this update is safe: the credit's source ID and wallet transaction are idempotent. + mongo.updateFirst(Query.query(Criteria.where("_id").is(pending.getId()).and("status").is(FinanceLedgerEntry.EntryStatus.PENDING)), + new Update().set("status", FinanceLedgerEntry.EntryStatus.PAID).set("metadata.settlement", "reconciled_observation") + .set("metadata.creditId", credit.getId()), FinanceLedgerEntry.class); + } + + static FinanceLedgerEntry settledCredit(FinanceLedgerEntry pending, Map<String, Object> payment) { + if (payment == null || !"succeeded".equals(payment.get("status")) || !(payment.get("livemode") instanceof Boolean)) return null; + if (!pending.getMetadata().get("paymentIntentId").equals(payment.get("id"))) return null; + if (!Boolean.valueOf(pending.getMetadata().get("testMode")).equals(!Boolean.TRUE.equals(payment.get("livemode")))) return null; + if (!pending.getCurrency().equals(payment.get("currency")) || number(payment.get("amount_received")) != pending.getGrossCents()) return null; + if (!(payment.get("latest_charge") instanceof Map<?, ?> charge) || !Boolean.TRUE.equals(charge.get("paid")) + || !Boolean.TRUE.equals(charge.get("captured")) || !(charge.get("balance_transaction") instanceof Map<?, ?> balance)) return null; + if (!"available".equals(balance.get("status")) || !pending.getCurrency().equals(balance.get("currency")) + || number(balance.get("amount")) != pending.getGrossCents() || !(balance.get("id") instanceof String balanceId)) return null; + long fee = number(balance.get("fee")); + if (fee < 0 || number(balance.get("net")) != pending.getGrossCents() - fee) return null; + long creatorNet = pending.getGrossCents() - pending.getPlatformCents() - fee; + // Never silently create a negative creator balance or invent a platform fee subsidy. + if (creatorNet < 0) return null; + FinanceLedgerEntry credit = new FinanceLedgerEntry(); + credit.setId(FinanceSourceKey.stripe(Boolean.FALSE.equals(payment.get("livemode")), pending.getMetadata().get("providerAccountId"), "settlement:" + balanceId)); credit.setCreatorId(pending.getCreatorId()); credit.setProjectId(pending.getProjectId()); + credit.setType(FinanceLedgerEntry.LedgerType.DONATION); credit.setGrossCents(pending.getGrossCents()); + credit.setPlatformCents(pending.getPlatformCents()); credit.setCreatorGrossCents(pending.getGrossCents() - pending.getPlatformCents()); + credit.setProcessorFeeCents(fee); credit.setCreatorCents(creatorNet); credit.setCurrency(pending.getCurrency()); + credit.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); credit.setAvailableAt(LocalDateTime.now()); + credit.setExternalReference(pending.getId()); credit.setStripeReference(balanceId); credit.setRecurring(pending.isRecurring()); + credit.getMetadata().put("providerAccountId", pending.getMetadata().get("providerAccountId")); + credit.getMetadata().put("settlement", "settled"); credit.getMetadata().put("paymentIntentId", String.valueOf(payment.get("id"))); + credit.getMetadata().put("testMode", String.valueOf(Boolean.FALSE.equals(payment.get("livemode")))); + credit.getMetadata().put("chargeId", String.valueOf(charge.get("id"))); + return credit; + } + + private static long number(Object value) { + if (!(value instanceof Number)) return Long.MIN_VALUE; + try { return new java.math.BigDecimal(value.toString()).longValueExact(); } + catch (ArithmeticException invalid) { return Long.MIN_VALUE; } + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/ProviderCostEvidenceService.java b/backend/src/main/java/net/modtale/service/finance/ProviderCostEvidenceService.java new file mode 100644 index 000000000..d254244a8 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/ProviderCostEvidenceService.java @@ -0,0 +1,59 @@ +package net.modtale.service.finance; + +import java.time.Instant; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Map; +import net.modtale.model.finance.ProviderCostEvidence; +import net.modtale.model.user.User; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.data.domain.Sort; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.stereotype.Service; + +/** Read-only provider retrieval plus immutable evidence storage; never changes earnings or wallet balances. */ +@Service +public class ProviderCostEvidenceService { + private final StripeGatewayService gateway; + private final MongoTemplate mongo; + public ProviderCostEvidenceService(StripeGatewayService gateway, MongoTemplate mongo) { this.gateway = gateway; this.mongo = mongo; } + public record ImportRequest(@NotBlank @Pattern(regexp = "txn_[A-Za-z0-9]+") String balanceTransactionId, + @NotBlank @Pattern(regexp = "acct_[A-Za-z0-9]+") String expectedAccountId, @NotNull Boolean expectedTestMode, + @NotBlank @Size(max = 1000) String reason) {} + public List<ProviderCostEvidence> list(User actor) { + AdSettlementStagingService.requireReviewer(actor); + return mongo.find(new Query().with(Sort.by(Sort.Direction.DESC, "recordedAt")).limit(100), ProviderCostEvidence.class); + } + public ProviderCostEvidence retrieveAndImport(User actor, ImportRequest input) { + AdSettlementStagingService.requireReviewer(actor); validate(input); + boolean testMode = gateway.isTestMode(); + if (!gateway.isReconciliationEnabled() || testMode != input.expectedTestMode() + || !input.expectedAccountId().equals(gateway.getExpectedPlatformAccountId()) + || !gateway.verifyPlatformAccountId(input.expectedAccountId())) throw new IllegalStateException("The configured provider account and mode must be verified before importing costs."); + // BalanceTransaction has no livemode field. Verify the authenticated account's balance mode explicitly. + Map<String, Object> balance = gateway.getBalance(); + if (!"balance".equals(balance.get("object")) || !(balance.get("livemode") instanceof Boolean live) || live == testMode) + throw new IllegalStateException("The authenticated provider balance mode could not be verified."); + StripeCostEvidence.Snapshot snapshot = StripeCostEvidence.parse(input.balanceTransactionId(), gateway.getBalanceTransaction(input.balanceTransactionId()), Instant.now()); + if (testMode != gateway.isTestMode() || !input.expectedAccountId().equals(gateway.getExpectedPlatformAccountId())) + throw new IllegalStateException("Provider scope changed while retrieving costs; start a new verified review."); + String id = FinanceSourceKey.stripe(testMode, input.expectedAccountId(), "provider-cost:" + snapshot.transactionId()); + String digest = snapshot.digest(input.expectedAccountId(), testMode); + ProviderCostEvidence evidence = new ProviderCostEvidence(id, input.expectedAccountId(), testMode, snapshot.transactionId(), snapshot.currency(), + snapshot.type(), snapshot.reportingCategory(), snapshot.source(), snapshot.amount(), snapshot.fee(), snapshot.net(), snapshot.cost(), + snapshot.created(), snapshot.available(), StripeGatewayService.API_VERSION, digest, "UNALLOCATED", actor.getId(), input.reason().trim(), Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS)); + try { return mongo.insert(evidence); } + catch (DuplicateKeyException duplicate) { + ProviderCostEvidence original = mongo.findById(id, ProviderCostEvidence.class); + if (original == null || !digest.equals(original.evidenceDigest())) throw new IllegalArgumentException("This provider transaction already has conflicting immutable evidence. Reconcile the conflict before any allocation."); + return original; + } + } + static void validate(ImportRequest input) { + if (input == null || input.balanceTransactionId() == null || !input.balanceTransactionId().matches("txn_[A-Za-z0-9]+") + || input.expectedAccountId() == null || !input.expectedAccountId().matches("acct_[A-Za-z0-9]+") || input.expectedTestMode() == null + || input.reason() == null || input.reason().isBlank() || input.reason().length() > 1000) + throw new IllegalArgumentException("Supply the exact balance transaction, expected account and test/live mode, and a review reason of 1–1000 characters."); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/RecurringSupportService.java b/backend/src/main/java/net/modtale/service/finance/RecurringSupportService.java new file mode 100644 index 000000000..3ba835ed6 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/RecurringSupportService.java @@ -0,0 +1,143 @@ +package net.modtale.service.finance; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import net.modtale.model.finance.CreatorSupportSubscription; +import net.modtale.model.finance.DonationIntent; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.user.User; +import net.modtale.repository.finance.CreatorSupportSubscriptionRepository; +import net.modtale.repository.finance.DonationIntentRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +/** Monthly support is accounted per cash-paid invoice, never just per checkout or subscription. */ +@Service +public class RecurringSupportService { + private final CreatorSupportSubscriptionRepository subscriptions; + private final DonationIntentRepository intents; + private final FinanceLedgerEntryRepository ledger; + private final StripeGatewayService gateway; + private final RevenueOpsSupport core; + private final net.modtale.service.project.query.ProjectService projects; + + public RecurringSupportService(CreatorSupportSubscriptionRepository subscriptions, DonationIntentRepository intents, + FinanceLedgerEntryRepository ledger, StripeGatewayService gateway, RevenueOpsSupport core, net.modtale.service.project.query.ProjectService projects) { + this.subscriptions = subscriptions; this.intents = intents; this.ledger = ledger; this.gateway = gateway; this.core = core; this.projects = projects; + } + + public void registerCheckout(DonationIntent intent, Map<String, Object> session) { + if (!DonationCheckoutService.isMatchingPaidSession(intent, session) || !intent.isRecurring() || intent.getDonorUserId() == null || !"subscription".equals(session.get("mode")) + || !"complete".equals(session.get("status")) || !"paid".equals(session.get("payment_status")) + || !intent.getStripeSessionId().equals(session.get("id")) + || !(session.get("subscription") instanceof String subscriptionId) || !subscriptionId.startsWith("sub_") + || !(session.get("customer") instanceof String customerId) || !customerId.startsWith("cus_")) throw new IllegalArgumentException("Subscription checkout is not ready."); + if (subscriptions.existsById(subscriptionId)) return; + CreatorSupportSubscription subscription = new CreatorSupportSubscription(); + subscription.setId(subscriptionId); subscription.setIntentId(intent.getId()); subscription.setDonorUserId(intent.getDonorUserId()); + subscription.setCreatorId(intent.getCreatorId()); subscription.setProjectId(intent.getProjectId()); subscription.setCustomerId(customerId); + subscription.setProviderAccountId(intent.getStripePlatformAccountId()); + subscription.setAmountCents(intent.getAmountCents()); subscription.setPlatformCutBps(intent.getPlatformCutBps()); subscription.setCurrency(intent.getCurrency()); + subscription.setTestMode(Boolean.FALSE.equals(session.get("livemode"))); subscription.setStatus("active"); + try { subscriptions.insert(subscription); } catch (DuplicateKeyException duplicate) { /* another event recorded this checkout */ } + intent.setStatus(DonationIntent.DonationStatus.COMPLETED); intents.save(intent); + } + + public void handlePaidInvoice(Map<String, Object> invoice) { + if (!(invoice.get("id") instanceof String invoiceId) || !invoiceId.startsWith("in_") || !"paid".equals(invoice.get("status"))) return; + if (!(invoice.get("parent") instanceof Map<?, ?> parent) || !"subscription_details".equals(parent.get("type")) || !(parent.get("subscription_details") instanceof Map<?, ?> details) + || !(details.get("subscription") instanceof String subscriptionId)) return; + CreatorSupportSubscription subscription = subscriptions.findById(subscriptionId).orElse(null); + if (subscription == null) { + if (!(details.get("metadata") instanceof Map<?, ?> metadata) || !(metadata.get("intentId") instanceof String intentId)) return; + DonationIntent intent = intents.findById(intentId).orElseThrow(() -> new IllegalArgumentException("Subscription checkout is not recorded yet.")); + if (intent.getStripeSessionId() == null) throw new IllegalArgumentException("Subscription checkout is not recorded yet."); + Map<String, Object> checkout = gateway.getCheckoutSession(intent.getStripeSessionId(), false); + if (!subscriptionId.equals(checkout.get("subscription"))) throw new IllegalArgumentException("Subscription invoice does not match checkout."); + registerCheckout(intent, checkout); + subscription = subscriptions.findById(subscriptionId).orElseThrow(); + } + if (!(invoice.get("livemode") instanceof Boolean) || subscription.isTestMode() != gateway.isTestMode() + || !subscription.getProviderAccountId().equals(gateway.getPlatformAccountId()) + || !subscription.getCurrency().equals(invoice.get("currency")) || !subscription.getCustomerId().equals(invoice.get("customer")) + || subscription.isTestMode() != Boolean.FALSE.equals(invoice.get("livemode")) + || number(invoice.get("amount_paid")) != subscription.getAmountCents()) throw new IllegalArgumentException("Subscription invoice needs reconciliation."); + Map<String, Object> payments = gateway.getInvoicePayments(invoiceId); + // This product creates one fixed-price cash payment per month. Partial/multiple/credit-funded invoices are held. + if (!Boolean.FALSE.equals(payments.get("has_more")) || !(payments.get("data") instanceof List<?> rows) || rows.size() != 1 + || !(rows.getFirst() instanceof Map<?, ?> payment) || !"paid".equals(payment.get("status")) + || number(payment.get("amount_paid")) != subscription.getAmountCents() + || !(payment.get("payment") instanceof Map<?, ?> source) || !"payment_intent".equals(source.get("type")) + || !(source.get("payment_intent") instanceof String paymentId)) throw new IllegalArgumentException("Subscription payment needs reconciliation."); + FinanceLedgerEntry observation = new FinanceLedgerEntry(); observation.setId(FinanceSourceKey.stripe(subscription.isTestMode(), subscription.getProviderAccountId(), "invoice:" + invoiceId)); + observation.setCreatorId(subscription.getCreatorId()); observation.setProjectId(subscription.getProjectId()); + observation.setType(FinanceLedgerEntry.LedgerType.DONATION); observation.setRecurring(true); observation.setCurrency(subscription.getCurrency()); + observation.setGrossCents(subscription.getAmountCents()); + long platform = FinanceAmounts.share(subscription.getAmountCents(), subscription.getPlatformCutBps()); + observation.setPlatformCents(platform); observation.setCreatorCents(subscription.getAmountCents() - platform); + observation.setCreatorGrossCents(subscription.getAmountCents() - platform); observation.setStripeReference(invoiceId); + observation.setStatus(FinanceLedgerEntry.EntryStatus.PENDING); observation.setExternalReference(subscriptionId); + observation.getMetadata().put("providerAccountId", subscription.getProviderAccountId()); + observation.getMetadata().put("settlement", "awaiting_reconciliation"); observation.getMetadata().put("paymentIntentId", paymentId); + observation.getMetadata().put("testMode", String.valueOf(subscription.isTestMode())); + try { ledger.insert(observation); } catch (DuplicateKeyException duplicate) { /* invoice replay */ } + refreshSubscription(subscriptionId); + } + + public void refreshSubscription(String id) { + CreatorSupportSubscription subscription = subscriptions.findById(id).orElse(null); + if (subscription == null) return; + if (subscription.isTestMode() != gateway.isTestMode() || !subscription.getProviderAccountId().equals(gateway.getPlatformAccountId())) { + throw new IllegalArgumentException("Subscription account mode or scope changed."); + } + Map<String, Object> current = gateway.getSubscription(id); + if (!(current.get("livemode") instanceof Boolean) || subscription.isTestMode() != Boolean.FALSE.equals(current.get("livemode")) + || !id.equals(current.get("id")) || !subscription.getCustomerId().equals(current.get("customer")) + || !(current.get("status") instanceof String)) { + throw new IllegalArgumentException("Could not refresh subscription state."); + } + // Stripe requires a new subscription to restart after completed cancellation. + if ("canceled".equals(subscription.getStatus()) && !"canceled".equals(current.get("status"))) { + throw new IllegalArgumentException("Canceled subscription state cannot be reactivated; provider reconciliation is required."); + } + Instant previous = subscription.getUpdatedAt(); + if (previous == null) throw new IllegalArgumentException("Subscription state needs reconciliation."); + Instant updated = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + if (!updated.isAfter(previous)) updated = previous.plusMillis(1).truncatedTo(java.time.temporal.ChronoUnit.MILLIS); + // A slower older provider response cannot overwrite a newer cancellation/update. + // The millisecond revision must advance even when calls finish within one MongoDB timestamp tick. + if (subscriptions.updateProviderState(id, previous, subscription.getProviderAccountId(), subscription.isTestMode(), subscription.getCustomerId(), + String.valueOf(current.get("status")), Boolean.TRUE.equals(current.get("cancel_at_period_end")), updated) != 1) { + throw new IllegalArgumentException("Subscription state changed during refresh; retry its canonical provider lookup."); + } + } + + public List<Map<String, Object>> listForDonor(User donor) { + return subscriptions.findByDonorUserId(donor.getId()).stream().map(subscription -> { + var project = projects.getProjectById(subscription.getProjectId()); + var result = new java.util.HashMap<String, Object>(); + result.put("id", subscription.getId()); result.put("projectId", subscription.getProjectId()); + result.put("projectTitle", project == null ? "Unavailable project" : project.getTitle()); + result.put("projectUrl", project == null ? "" : projects.getProjectLink(project)); + result.put("amountCents", subscription.getAmountCents()); result.put("currency", subscription.getCurrency()); + result.put("status", subscription.getStatus()); result.put("cancelAtPeriodEnd", subscription.isCancelAtPeriodEnd()); + result.put("testMode", subscription.isTestMode()); return (Map<String, Object>) result; + }).toList(); + } + + public String openBillingPortal(User donor, String subscriptionId) { + CreatorSupportSubscription subscription = subscriptions.findById(subscriptionId).orElseThrow(() -> new IllegalArgumentException("Subscription not found.")); + if (!donor.getId().equals(subscription.getDonorUserId())) throw new SecurityException("This subscription belongs to another account."); + if (subscription.isTestMode() != gateway.isTestMode() || !gateway.getPlatformAccountId().equals(subscription.getProviderAccountId())) throw new IllegalStateException("Billing account mode or scope does not match this subscription."); + var result = gateway.createBillingPortalSession(subscription.getCustomerId(), core.normalizeFrontendUrl() + "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/dashboard/finance"); + if (!result.success() || result.url() == null) throw new IllegalStateException("Billing management is temporarily unavailable. Please try again."); + return result.url(); + } + + private static long number(Object value) { + if (!(value instanceof Number)) return Long.MIN_VALUE; + try { return new java.math.BigDecimal(value.toString()).longValueExact(); } catch (ArithmeticException invalid) { return Long.MIN_VALUE; } + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/RevenueOpsSupport.java b/backend/src/main/java/net/modtale/service/finance/RevenueOpsSupport.java new file mode 100644 index 000000000..36395370b --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/RevenueOpsSupport.java @@ -0,0 +1,421 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.AdCampaign; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.project.Project; +import net.modtale.model.user.ApiKey; +import net.modtale.model.user.AdminPermission; +import net.modtale.model.user.User; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.AccessControlService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import java.time.Duration; +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.function.Predicate; +import java.util.function.ToLongFunction; +import java.util.stream.Collectors; + +@Component +public class RevenueOpsSupport { + + public static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + @Autowired UserRepository userRepository; + @Autowired ProjectRepository projectRepository; + @Autowired ProjectService projectService; + @Autowired AccessControlService accessControlService; + @Autowired StripeGatewayService stripeGatewayService; + + @Value("${app.frontend.url:http://localhost:5173}") + private String frontendUrl; + + private final Cache<String, Boolean> adDebounce = Caffeine.newBuilder() + .maximumSize(100000).expireAfterWrite(Duration.ofMinutes(20)).build(); + private final String eventSalt = UUID.randomUUID().toString(); + + public List<Map<String, Object>> buildDailySeries( + List<FinanceLedgerEntry> source, + LocalDate start, + LocalDate end, + Set<FinanceLedgerEntry.LedgerType> allowedTypes, + ToLongFunction<FinanceLedgerEntry> mapper, + Predicate<FinanceLedgerEntry> extraFilter + ) { + Map<LocalDate, Long> 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<Map<String, Object>> response = new ArrayList<>(); + for (LocalDate day = start; !day.isAfter(end); day = day.plusDays(1)) { + Map<String, Object> row = new HashMap<>(); + row.put("date", day.format(DATE_FMT)); + row.put("count", buckets.getOrDefault(day, 0L)); + response.add(row); + } + return response; + } + + public int parseRangeDays(String range) { + if ("7d".equalsIgnoreCase(range)) return 7; + if ("90d".equalsIgnoreCase(range)) return 90; + if ("1y".equalsIgnoreCase(range)) return 365; + return 30; + } + + public AdCampaign weightedPick(List<AdCampaign> campaigns) { + int total = campaigns.stream().mapToInt(c -> Math.max(1, c.getWeight())).sum(); + int roll = ThreadLocalRandom.current().nextInt(total); + + int cursor = 0; + for (AdCampaign campaign : campaigns) { + cursor += Math.max(1, campaign.getWeight()); + if (roll < cursor) { + return campaign; + } + } + return campaigns.get(0); + } + + public AdCampaign.AdPlacement parsePlacement(String placementRaw) { + if (placementRaw == null || placementRaw.isBlank()) return AdCampaign.AdPlacement.SIDEBAR_CARD; + try { + return AdCampaign.AdPlacement.valueOf(placementRaw.trim().toUpperCase()); + } catch (Exception ignored) { + return AdCampaign.AdPlacement.SIDEBAR_CARD; + } + } + + public AdCampaign.AdCreative chooseCreative(AdCampaign campaign, AdCampaign.AdPlacement placement) { + if (campaign.getCreatives() == null || campaign.getCreatives().isEmpty()) return null; + for (AdCampaign.AdCreative creative : campaign.getCreatives()) { + if (creative == null || creative.getImageUrl() == null || creative.getImageUrl().isBlank()) continue; + AdCampaign.AdPlacement creativePlacement = creative.getPlacement() == null + ? AdCampaign.AdPlacement.SIDEBAR_CARD + : creative.getPlacement(); + if (creativePlacement == placement) return creative; + } + for (AdCampaign.AdCreative creative : campaign.getCreatives()) { + if (creative != null && creative.getImageUrl() != null && !creative.getImageUrl().isBlank()) { + return creative; + } + } + return null; + } + + public boolean hasRenderableCreativeForPlacement(AdCampaign campaign, AdCampaign.AdPlacement placement) { + if (campaign == null || campaign.getCreatives() == null) return false; + for (AdCampaign.AdCreative creative : campaign.getCreatives()) { + if (creative == null) continue; + AdCampaign.AdPlacement creativePlacement = creative.getPlacement() == null + ? AdCampaign.AdPlacement.SIDEBAR_CARD + : creative.getPlacement(); + if (creativePlacement == placement && creative.getImageUrl() != null && !creative.getImageUrl().isBlank()) { + return true; + } + } + return false; + } + + public static void requireSafeExternalUrl(String value) { + try { + URI uri = URI.create(value); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getHost() == null || uri.getUserInfo() != null) { + throw new IllegalArgumentException("Sponsored links and images must use an absolute HTTPS URL without credentials."); + } + } catch (RuntimeException invalid) { + throw new IllegalArgumentException("Sponsored links and images must use an absolute HTTPS URL without credentials."); + } + } + + public String appendAffiliateParams(String targetUrl, String param, String code) { + requireSafeExternalUrl(targetUrl); + if (code == null || code.isBlank()) return targetUrl; + String queryParam = (param == null || param.isBlank()) ? "ref" : param; + return UriComponentsBuilder.fromUriString(targetUrl).queryParam(queryParam, code).build().encode().toUriString(); + } + + public String resolveCreatorId(String projectId) { + if (projectId == null) return null; + Project project = projectService.getProjectById(projectId); + return project == null ? null : project.getAuthorId(); + } + + public boolean shouldTrackEvent(String type, String campaignId, String projectId, String clientIp) { + if (clientIp == null || clientIp.isBlank()) return false; + try { + String source = eventSalt + ":" + type + ":" + campaignId + ":" + projectId + ":" + clientIp; + String key = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(source.getBytes(StandardCharsets.UTF_8))); + return adDebounce.asMap().putIfAbsent(key, Boolean.TRUE) == null; + } catch (NoSuchAlgorithmException impossible) { + throw new IllegalStateException("SHA-256 unavailable", impossible); + } + } + + public User resolveFinanceOwner(User requester, String ownerId, boolean requireOrgFinanceManage) { + if (requester == null) { + throw new SecurityException("Authentication required."); + } + + String targetId = (ownerId == null || ownerId.isBlank()) ? requester.getId() : ownerId; + if (requester.getId().equals(targetId)) { + return requester; + } + + User target = userRepository.findById(targetId) + .orElseThrow(() -> new IllegalArgumentException("Owner account not found.")); + + if (target.getAccountType() != User.AccountType.ORGANIZATION) { + throw new SecurityException("You can only manage your own personal finance account."); + } + + if (requireOrgFinanceManage && !accessControlService.hasOrgPermission(target, requester.getId(), ApiKey.ApiPermission.ORG_EDIT_METADATA)) { + throw new SecurityException("Missing organization permission for finance management."); + } + + if (!requireOrgFinanceManage && !accessControlService.hasOrgPermission(target, requester.getId(), ApiKey.ApiPermission.ORG_MEMBER_READ)) { + throw new SecurityException("Missing organization permission for finance access."); + } + + return target; + } + + public List<String> executePayoutTransfers(User creator, long totalAmountCents, String currency, boolean forceMock) { + if (creator.getAccountType() == User.AccountType.ORGANIZATION + && creator.getOrgPayoutMode() == User.OrgPayoutMode.DISTRIBUTE_TO_MEMBERS) { + List<User.OrgPayoutShare> shares = creator.getOrgPayoutShares() == null ? new ArrayList<>() : creator.getOrgPayoutShares(); + validateOrgPayoutShares(creator, shares); + List<String> refs = new ArrayList<>(); + + long allocated = 0; + for (int i = 0; i < shares.size(); i++) { + User.OrgPayoutShare share = shares.get(i); + User member = userRepository.findById(share.getUserId()) + .orElseThrow(() -> new IllegalStateException("Organization member not found: " + share.getUserId())); + ensureStripePayoutReady(member); + + long amount = (i == shares.size() - 1) + ? (totalAmountCents - allocated) + : Math.round((totalAmountCents * share.getPercent()) / 100.0); + allocated += amount; + if (amount <= 0) continue; + + StripeGatewayService.StripeResult result = stripeGatewayService.createOrSimulateTransfer( + member.getStripeConnectAccountId(), + amount, + currency, + "Modtale organization payout (" + creator.getUsername() + ")", + Map.of( + "organizationId", creator.getId(), + "memberUserId", member.getId(), + "source", "modtale_finance" + ), + forceMock + ); + if (!result.success()) { + throw new IllegalStateException("Stripe payout failed for " + member.getUsername() + ": " + result.error()); + } + refs.add(result.id()); + } + + if (refs.isEmpty()) { + throw new IllegalStateException("No payout recipients were resolved from organization payout shares."); + } + + return refs; + } + + ensureStripePayoutReady(creator); + StripeGatewayService.StripeResult payoutResult = stripeGatewayService.createOrSimulateTransfer( + creator.getStripeConnectAccountId(), + totalAmountCents, + currency, + "Modtale creator payout", + Map.of("creatorId", creator.getId(), "source", "modtale_finance"), + forceMock + ); + + if (!payoutResult.success()) { + throw new IllegalStateException("Stripe payout failed: " + payoutResult.error()); + } + + return List.of(payoutResult.id()); + } + + public void ensureStripePayoutReady(User accountOwner) { + if (accountOwner.getStripeConnectAccountId() == null || accountOwner.getStripeConnectAccountId().isBlank()) { + throw new IllegalStateException("Connect Stripe first before requesting payouts."); + } + if (stripeGatewayService.isEnabled() && !accountOwner.isStripePayoutsEnabled()) { + throw new IllegalStateException("Stripe onboarding is incomplete for " + accountOwner.getUsername() + ". Refresh Stripe status after onboarding."); + } + } + + public void validateOrgPayoutShares(User org, List<User.OrgPayoutShare> shares) { + if (shares == null || shares.isEmpty()) { + throw new IllegalArgumentException("At least one payout share is required for distributed payouts."); + } + + Set<String> memberIds = org.getOrganizationMembers().stream() + .map(User.OrganizationMember::getUserId) + .collect(Collectors.toSet()); + + int totalPercent = 0; + Set<String> recipients = new java.util.HashSet<>(); + 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 (!recipients.add(share.getUserId())) throw new IllegalArgumentException("Each recipient may appear only once."); + if (share.getPercent() <= 0 || share.getPercent() > 100) { + throw new IllegalArgumentException("Payout share percentages must be greater than zero."); + } + totalPercent += share.getPercent(); + } + + if (totalPercent != 100) { + throw new IllegalArgumentException("Organization payout shares must total exactly 100%."); + } + } + + public void requireProjectMonetizationOwner(User requester, Project project) { + if (requester == null) { + throw new SecurityException("Authentication required."); + } + if (project == null || project.getAuthorId() == null || project.getAuthorId().isBlank()) { + throw new SecurityException("Project ownership could not be verified."); + } + + User owner = userRepository.findById(project.getAuthorId()).orElse(null); + if (owner == null) { + throw new SecurityException("Project owner was not found."); + } + + if (owner.getAccountType() == User.AccountType.ORGANIZATION) { + requireOrganizationOwner(requester, owner); + return; + } + + if (!owner.getId().equals(requester.getId())) { + throw new SecurityException("Only the project owner can update monetization policies."); + } + } + + public void requireOrganizationOwner(User requester, User organization) { + if (requester == null || organization == null) { + throw new SecurityException("Organization ownership could not be verified."); + } + if (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; + } + throw new SecurityException("Only current organization owners can update monetization policies."); + } + + String legacyRole = requesterMembership.getRole(); + if (legacyRole != null && "OWNER".equalsIgnoreCase(legacyRole)) { + return; + } + + throw new SecurityException("Only organization owners can update monetization policies."); + } + + public Map<String, Object> toCampaignMap(AdCampaign campaign) { + Map<String, Object> item = new LinkedHashMap<>(); + item.put("id", campaign.getId()); + item.put("name", campaign.getName()); + item.put("active", campaign.isActive()); + item.put("testCampaign", campaign.isTestCampaign()); + item.put("providerType", campaign.getProviderType()); + item.put("providerName", campaign.getProviderName()); + item.put("providerPlacementKey", campaign.getProviderPlacementKey()); + item.put("sponsorName", campaign.getSponsorName()); + item.put("headline", campaign.getHeadline()); + item.put("body", campaign.getBody()); + item.put("callToAction", campaign.getCallToAction()); + item.put("imageUrl", campaign.getImageUrl()); + item.put("creatives", campaign.getCreatives()); + item.put("targetUrl", campaign.getTargetUrl()); + item.put("affiliateParam", campaign.getAffiliateParam()); + item.put("affiliateCode", campaign.getAffiliateCode()); + item.put("baseRevenuePerClickCents", campaign.getBaseRevenuePerClickCents()); + item.put("weight", campaign.getWeight()); + item.put("privacyRespecting", campaign.isPrivacyRespecting()); + item.put("nonIntrusive", campaign.isNonIntrusive()); + item.put("allowedClassifications", campaign.getAllowedClassifications()); + item.put("createdAt", campaign.getCreatedAt()); + item.put("updatedAt", campaign.getUpdatedAt()); + return item; + } + + public String asString(Object value) { + return value == null ? null : String.valueOf(value); + } + + public int asInt(Object value, int fallback) { + if (value == null) return fallback; + try { + return Integer.parseInt(String.valueOf(value)); + } catch (Exception e) { + return fallback; + } + } + + public boolean asBoolean(Object value) { + if (value instanceof Boolean b) return b; + if (value == null) return false; + return Boolean.parseBoolean(String.valueOf(value)); + } + + public String normalizeFrontendUrl() { + if (frontendUrl == null || frontendUrl.isBlank()) return "http://localhost:5173"; + return frontendUrl.endsWith("/") ? frontendUrl.substring(0, frontendUrl.length() - 1) : frontendUrl; + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/RevenuePoolAllocator.java b/backend/src/main/java/net/modtale/service/finance/RevenuePoolAllocator.java new file mode 100644 index 000000000..5703e887f --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/RevenuePoolAllocator.java @@ -0,0 +1,43 @@ +package net.modtale.service.finance; + +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.Map; + +/** Allocates a collected revenue pool using a frozen set of valid activity weights, never ad clicks. */ +public final class RevenuePoolAllocator { + public record Allocation(long collectedCents, int creatorShareBps, long creatorPoolCents, + long platformCents, Map<String, Long> projectCents) {} + private record Remainder(String projectId, BigInteger value) {} + private RevenuePoolAllocator() {} + + public static Allocation allocate(long collectedCents, int creatorShareBps, Map<String, Long> eligibleActivity) { + if (collectedCents < 0 || creatorShareBps < 0 || creatorShareBps > 10000) throw new IllegalArgumentException("Invalid funded pool."); + if (eligibleActivity == null || eligibleActivity.isEmpty()) throw new IllegalArgumentException("A funded pool needs verified eligible activity before allocation."); + BigInteger total = BigInteger.ZERO; + for (var entry : eligibleActivity.entrySet()) { + if (entry.getKey() == null || entry.getKey().isBlank() || entry.getValue() == null || entry.getValue() <= 0) { + throw new IllegalArgumentException("Every project must have a positive verified activity weight."); + } + total = total.add(BigInteger.valueOf(entry.getValue())); + } + long creatorPool = BigInteger.valueOf(collectedCents).multiply(BigInteger.valueOf(creatorShareBps)) + .add(BigInteger.valueOf(5000)).divide(BigInteger.valueOf(10000)).longValueExact(); + Map<String, Long> amounts = new LinkedHashMap<>(); + var remainders = new ArrayList<Remainder>(); + long allocated = 0; + for (String projectId : eligibleActivity.keySet().stream().sorted().toList()) { + BigInteger[] quotient = BigInteger.valueOf(creatorPool).multiply(BigInteger.valueOf(eligibleActivity.get(projectId))).divideAndRemainder(total); + long cents = quotient[0].longValueExact(); + amounts.put(projectId, cents); + allocated = Math.addExact(allocated, cents); + remainders.add(new Remainder(projectId, quotient[1])); + } + // Largest remainder preserves every cent. Stable project ID order resolves equal remainders. + remainders.sort(Comparator.comparing(Remainder::value).reversed().thenComparing(Remainder::projectId)); + for (int i = 0; i < creatorPool - allocated; i++) amounts.compute(remainders.get(i).projectId(), (key, amount) -> amount + 1); + return new Allocation(collectedCents, creatorShareBps, creatorPool, collectedCents - creatorPool, Map.copyOf(amounts)); + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/RevenueReportingService.java b/backend/src/main/java/net/modtale/service/finance/RevenueReportingService.java new file mode 100644 index 000000000..948e8eb47 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/RevenueReportingService.java @@ -0,0 +1,66 @@ +package net.modtale.service.finance; + +import net.modtale.model.finance.DonationIntent; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.repository.finance.DonationIntentRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class RevenueReportingService { + + @Autowired private EarningsAccountService financeAccountService; + @Autowired private FinanceLedgerEntryRepository ledgerRepository; + @Autowired private DonationIntentRepository donationIntentRepository; + @Autowired private RevenueOpsSupport core; + + public List<Map<String, Object>> 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<FinanceLedgerEntry> entries = ledgerRepository.findByCreatedAtBetween(startAt, endAt); + Map<LocalDate, long[]> buckets = new HashMap<>(); + + for (FinanceLedgerEntry entry : entries) { + if (entry.getCreatedAt() == null || !FinanceLedgerRules.isRecognizedRevenue(entry)) continue; + LocalDate date = entry.getCreatedAt().toLocalDate(); + long[] sums = buckets.computeIfAbsent(date, key -> new long[4]); + sums[0] += entry.getGrossCents(); + sums[1] += entry.getCreatorCents(); + sums[2] += entry.getPlatformCents(); + sums[3] += (entry.getProcessorFeeCents() == null ? 0 : entry.getProcessorFeeCents()); + } + + List<Map<String, Object>> response = new ArrayList<>(); + for (LocalDate day = start; !day.isAfter(LocalDate.now()); day = day.plusDays(1)) { + long[] sums = buckets.getOrDefault(day, new long[4]); + Map<String, Object> item = new HashMap<>(); + item.put("date", day.format(RevenueOpsSupport.DATE_FMT)); + item.put("grossCents", sums[0]); + item.put("creatorCents", sums[1]); + item.put("platformCents", sums[2]); + item.put("processorFeeCents", sums[3]); + response.add(item); + } + + return response; + } + + /** Earned creator funds are never forfeited. Unclaimed-property handling requires a separate reviewed process. */ + @Deprecated + public void expireCreatorFunds() { + // Intentionally no database writes and no scheduled invocation. + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/StripeCostEvidence.java b/backend/src/main/java/net/modtale/service/finance/StripeCostEvidence.java new file mode 100644 index 000000000..5633175ec --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/StripeCostEvidence.java @@ -0,0 +1,55 @@ +package net.modtale.service.finance; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.*; + +/** Strict adapter for standalone Stripe service-fee transactions, not payment fees or principal. */ +final class StripeCostEvidence { + private StripeCostEvidence() {} + record Snapshot(String transactionId, String currency, String type, String reportingCategory, String source, + long amount, long fee, long net, long cost, Instant created, Instant available) { + String digest(String account, boolean testMode) { + try { + var fields = List.of(account, Boolean.toString(testMode), transactionId, currency, type, reportingCategory, + source == null ? "" : source, Long.toString(amount), Long.toString(fee), Long.toString(net), + Long.toString(created.getEpochSecond()), Long.toString(available.getEpochSecond())); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(String.join("\n", fields).getBytes(StandardCharsets.UTF_8))); + } catch (Exception impossible) { throw new IllegalStateException("Could not hash cost evidence."); } + } + } + static Snapshot parse(String expectedId, Map<String, Object> data, Instant now) { + if (!Objects.equals(expectedId, data.get("id")) || !"balance_transaction".equals(data.get("object"))) invalid(); + // Historical adjustments can contain principal. FX/tax/other fee categories need their own reviewed adapters. + if (!"stripe_fee".equals(data.get("type")) || !"fee".equals(data.get("reporting_category")) + || !"available".equals(data.get("status"))) invalid(); + String currency = data.get("currency") instanceof String text ? text : ""; + if (!currency.matches("[a-z]{3}")) invalid(); + try { if (Currency.getInstance(currency.toUpperCase(Locale.ROOT)).getDefaultFractionDigits() < 0) invalid(); } catch (IllegalArgumentException unsupported) { invalid(); } + if (!data.containsKey("source")) invalid(); + String source = null; + if (data.get("source") != null) { + if (!(data.get("source") instanceof String text) || !text.matches("[A-Za-z][A-Za-z0-9_]{0,119}")) invalid(); + source = (String) data.get("source"); + if (source.matches("(?:ch|py|re|dp|du|tr|po|pi|in)_.+")) invalid(); + } + long amount = integer(data.get("amount")), fee = integer(data.get("fee")), net = integer(data.get("net")); + if (amount == 0 || amount < -1_000_000_000_000L || amount > 1_000_000_000_000L || fee != 0 || net != amount + || !(data.get("fee_details") instanceof List<?> details) || !details.isEmpty()) invalid(); + long created = integer(data.get("created")), available = integer(data.get("available_on")); + if (created <= 0 || available <= 0 || created > now.getEpochSecond() + 300 || available > now.getEpochSecond()) invalid(); + return new Snapshot(expectedId, currency, "stripe_fee", "fee", source, amount, fee, net, -net, + Instant.ofEpochSecond(created), Instant.ofEpochSecond(available)); + } + private static long integer(Object value) { + try { + if (value instanceof Long l) return l; + if (value instanceof Integer i) return i.longValue(); + if (value instanceof BigInteger b) return b.longValueExact(); + } catch (ArithmeticException overflow) { invalid(); } + invalid(); return 0; + } + private static void invalid() { throw new IllegalArgumentException("Expected settled standalone Stripe service-fee evidence. Principal, payment/refund/dispute fees, pending amounts and unsupported categories require their existing reconciliation paths or separate review."); } +} 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 000000000..080f5a498 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/StripeGatewayService.java @@ -0,0 +1,391 @@ +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.time.Duration; +import java.util.UUID; +import java.util.HashMap; +import java.util.Map; + +@Service +public class StripeGatewayService { + + public static final String API_VERSION = "2026-08-26.dahlia"; + + public record StripeResult(boolean success, String id, String url, String error, Map<String, Object> raw) { + } + + private final WebClient webClient; + private volatile String platformAccountId; + + @Value("${app.finance.stripe.secret-key:}") + private String stripeSecretKey; + + @Value("${app.frontend.url:http://localhost:5173}") + private String frontendUrl; + + @Value("${app.finance.stripe.mock-enabled:false}") + private boolean mockEnabled; + + @Value("${app.finance.live-payments-enabled:false}") + private boolean livePaymentsEnabled; + + @Value("${app.finance.stripe.platform-account-id:}") + private String expectedPlatformAccountId; + + @Value("${app.finance.stripe.portal-configuration-id:}") + private String portalConfigurationId; + + public boolean isAnonymousSandbox() { return isEnabled() && stripeSecretKey.startsWith("rkcs_"); } + public boolean isLivePaymentsEnabled() { return livePaymentsEnabled; } + public String getExpectedPlatformAccountId() { return expectedPlatformAccountId == null ? "" : expectedPlatformAccountId; } + public String getPortalConfigurationId() { return portalConfigurationId == null ? "" : portalConfigurationId; } + + public boolean isMockEnabled() { return mockEnabled; } + + public boolean isTestMode() { + return isEnabled() && (stripeSecretKey.startsWith("sk_test_") || stripeSecretKey.startsWith("rk_test_") || isAnonymousSandbox()); + } + + public boolean isLiveMode() { + return isEnabled() && (stripeSecretKey.startsWith("sk_live_") || stripeSecretKey.startsWith("rk_live_")); + } + + public boolean isReconciliationEnabled() { return isTestMode() || isLiveMode(); } + public boolean isOperational() { return (isTestMode() && !isAnonymousSandbox()) || (isLiveMode() && livePaymentsEnabled); } + public boolean isCheckoutAvailable() { return mockEnabled || isOperational(); } + + public String getAvailabilityMessage() { + if (isAnonymousSandbox()) return "This limited test sandbox cannot verify the platform account. A full test credential is required before application payments, onboarding and withdrawals can be enabled."; + return isOperational() ? (isTestMode() ? "Test mode: payments and transfers do not move real money." : "Creator payments are available. Settlement and account eligibility determine withdrawals.") : "Creator payments are being prepared. Live payments and withdrawals require provider approval and completed launch checks."; + } + + // Live money is deliberately fail-closed. This is not a substitute for the launch checklist. + private StripeResult unavailableForLiveMoney() { + return new StripeResult(false, null, null, getAvailabilityMessage(), Map.of()); + } + + public StripeGatewayService() { + this(WebClient.builder().baseUrl("https://api.stripe.com/v1")); + } + + StripeGatewayService(WebClient.Builder builder) { + this.webClient = builder.defaultHeader("Stripe-Version", API_VERSION).build(); + } + + public String getPlatformAccountId() { + if (platformAccountId != null) return platformAccountId; + Map<String, Object> account = getProviderObject("/account", Map.of()); + if (!(account.get("id") instanceof String id) || !id.startsWith("acct_")) throw new IllegalStateException("Could not verify the platform payment account."); + if (!getExpectedPlatformAccountId().isBlank() && !id.equals(getExpectedPlatformAccountId())) + throw new IllegalStateException("The payment credential does not match the configured platform account."); + platformAccountId = id; + return id; + } + + public boolean isEnabled() { + return stripeSecretKey != null && !stripeSecretKey.isBlank(); + } + + public StripeResult createOrSimulateConnectAccount(String email, String country, boolean forceMock, String idempotencyKey) { + if (forceMock) { + return new StripeResult(true, "sim_acct_" + UUID.randomUUID(), null, null, Map.of("simulated", true)); + } + if (!isOperational()) return unavailableForLiveMoney(); + + MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); + form.add("type", "express"); + form.add("capabilities[transfers][requested]", "true"); + if (email != null && !email.isBlank()) form.add("email", email); + if (country != null && !country.isBlank()) form.add("country", country.toUpperCase()); + + return postForm("/accounts", form, idempotencyKey); + } + + public StripeResult createOrSimulateOnboardingLink(String accountId, String returnPath, boolean forceMock) { + if (forceMock) { + String url = normalizeFrontendUrl() + (returnPath.startsWith("/") ? returnPath : "/" + returnPath); + return new StripeResult(true, "sim_link_" + UUID.randomUUID(), url, null, Map.of("simulated", true)); + } + if (!isOperational()) return unavailableForLiveMoney(); + + String returnUrl = normalizeFrontendUrl() + (returnPath.startsWith("/") ? returnPath : "/" + returnPath); + String refreshUrl = normalizeFrontendUrl() + "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/dashboard/finance?stripe=refresh"; + + MultiValueMap<String, String> 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<String, Object> getAccountStatus(String accountId, boolean forceMock) { + if (forceMock) { + return Map.of( + "details_submitted", true, + "charges_enabled", true, + "payouts_enabled", true, + "country", "US", + "simulated", true + ); + } + if (!isEnabled()) { + return Map.of("error", "Stripe secret key is not configured."); + } + + try { + Map<String, Object> result = webClient.get() + .uri("/accounts/{id}", accountId) + .headers(headers -> headers.setBasicAuth(stripeSecretKey, "")) + .retrieve() + .bodyToMono(Map.class) + .block(Duration.ofSeconds(20)); + return result == null ? Map.of() : result; + } catch (Exception e) { + return Map.of("error", "The payment provider could not be reached. Please try again later."); + } + } + + public StripeResult createOrSimulateDonationCheckout( + String intentId, + String projectTitle, + long amountCents, + boolean recurring, + String successUrl, + String cancelUrl, + String currency, + boolean forceMock + ) { + if (forceMock) { + return new StripeResult(true, "sim_cs_" + UUID.randomUUID(), null, null, Map.of("simulated", true)); + } + // Anonymous sandboxes support isolated Checkout contract tests, but cannot enable the application integration. + if (!isOperational() && !isAnonymousSandbox()) return unavailableForLiveMoney(); + + MultiValueMap<String, String> 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_creator_support"); + String metadataPrefix = recurring ? "subscription_data" : "payment_intent_data"; + form.add(metadataPrefix + "[metadata][intentId]", intentId); + form.add(metadataPrefix + "[metadata][source]", "modtale_creator_support"); + + StripeResult result = postForm("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/checkout/sessions", form, "donation-checkout-" + intentId); + if (!result.success()) return result; + return new StripeResult(true, result.id(), (String) result.raw().get("url"), null, result.raw()); + } + + public Map<String, Object> getCheckoutSession(String sessionId, boolean forceMock) { + if (forceMock) { + return Map.of("id", sessionId, "status", "open", "payment_status", "unpaid", "simulated", true); + } + if (!isEnabled()) { + return Map.of("error", "Stripe secret key is not configured."); + } + + try { + Map<String, Object> result = webClient.get() + .uri("/checkout/sessions/{id}", sessionId) + .headers(headers -> headers.setBasicAuth(stripeSecretKey, "")) + .retrieve() + .bodyToMono(Map.class) + .block(Duration.ofSeconds(20)); + return result == null ? Map.of() : result; + } catch (Exception e) { + return Map.of("error", "The payment provider could not be reached. Please try again later."); + } + } + + public StripeResult createOrSimulateTransfer(String destinationAccountId, long amountCents, String currency, String description, Map<String, String> metadata, boolean forceMock) { + return createTransfer(destinationAccountId, amountCents, currency, description, metadata, forceMock, null); + } + + public StripeResult createTransfer(String destinationAccountId, long amountCents, String currency, String description, + Map<String, String> metadata, boolean forceMock, String idempotencyKey) { + if (forceMock) { + return new StripeResult(true, "sim_tr_" + UUID.randomUUID(), null, null, Map.of( + "simulated", true, + "createdAt", LocalDateTime.now().toString(), + "destination", destinationAccountId, + "amount", amountCents + )); + } + if (!isOperational()) return unavailableForLiveMoney(); + + MultiValueMap<String, String> 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); + } + }); + } + + if (metadata != null && metadata.get("transferGroup") != null) form.add("transfer_group", metadata.get("transferGroup")); + if (idempotencyKey == null || idempotencyKey.isBlank()) return new StripeResult(false, null, null, "A durable payout key is required.", Map.of()); + return postForm("/transfers", form, idempotencyKey); + } + + public boolean verifyPlatformAccountId(String expected) { + if (expected == null || !expected.matches("acct_[A-Za-z0-9]+")) return false; + return expected.equals(getProviderObject("/account", Map.of()).get("id")); + } + + public Map<String, Object> getBalanceTransaction(String id) { + if (id == null || !id.matches("txn_[A-Za-z0-9]+")) return Map.of(); + return getProviderObject("/balance_transactions/" + id, Map.of()); + } + + public Map<String, Object> getCurrentAccount() { return getProviderObject("/account", Map.of()); } + public Map<String, Object> getBalance() { return getProviderObject("/balance", Map.of()); } + public Map<String, Object> getWebhookEndpoint(String id) { + if (id == null || !id.matches("we_[A-Za-z0-9]+")) return Map.of(); + return getProviderObject("/webhook_endpoints/" + id, Map.of()); + } + public Map<String, Object> getPortalConfiguration(String id) { + if (id == null || !id.matches("bpc_[A-Za-z0-9]+")) return Map.of(); + return getProviderObject("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/billing_portal/configurations/" + id, Map.of()); + } + + public Map<String, Object> getTransfer(String transferId) { + if (transferId == null || !transferId.matches("tr_[A-Za-z0-9]+")) return Map.of(); + return getProviderObject("/transfers/" + transferId, Map.of()); + } + + public Map<String, Object> getChargeDisputes(String chargeId, String after) { + if (chargeId == null || !chargeId.matches("ch_[A-Za-z0-9]+") || (after != null && !after.matches("d[pu]_[A-Za-z0-9]+"))) return Map.of(); + Map<String, String> query = new HashMap<>(); query.put("charge", chargeId); query.put("limit", "100"); + if (after != null) query.put("starting_after", after); + return getProviderObject("/disputes", query); + } + + public Map<String, Object> getDispute(String disputeId) { + if (disputeId == null || !disputeId.matches("d[pu]_[A-Za-z0-9]+")) return Map.of(); + return getProviderObject("/disputes/" + disputeId, Map.of()); + } + + public Map<String, Object> getCharge(String chargeId) { + if (chargeId == null || !chargeId.startsWith("ch_")) return Map.of(); + return getProviderObject("/charges/" + chargeId, Map.of()); + } + + public Map<String, Object> getChargeRefunds(String chargeId, String after) { + if (chargeId == null || !chargeId.startsWith("ch_")) return Map.of(); + Map<String, String> query = new HashMap<>(); + query.put("charge", chargeId); query.put("limit", "100"); query.put("expand[]", "data.balance_transaction"); + if (after != null) query.put("starting_after", after); + return getProviderObject("/refunds", query); + } + + public Map<String, Object> getInvoicePayments(String invoiceId) { + if (invoiceId == null || !invoiceId.startsWith("in_")) return Map.of(); + return getProviderObject("/invoice_payments", Map.of("invoice", invoiceId, "status", "paid", "limit", "100")); + } + + public Map<String, Object> getSubscription(String subscriptionId) { + if (subscriptionId == null || !subscriptionId.startsWith("sub_")) return Map.of(); + return getProviderObject("/subscriptions/" + subscriptionId, Map.of()); + } + + private Map<String, Object> getProviderObject(String path, Map<String, String> query) { + if (!isReconciliationEnabled()) return Map.of(); + try { + Map<String, Object> result = webClient.get().uri(builder -> { + builder.path(path); query.forEach(builder::queryParam); return builder.build(); + }).headers(headers -> headers.setBasicAuth(stripeSecretKey, "")) + .retrieve().bodyToMono(Map.class).block(Duration.ofSeconds(20)); + return result == null ? Map.of() : result; + } catch (Exception unavailable) { return Map.of(); } + } + + public StripeResult createBillingPortalSession(String customerId, String returnUrl) { + if (!isReconciliationEnabled()) return unavailableForLiveMoney(); + if (customerId == null || !customerId.startsWith("cus_")) return new StripeResult(false, null, null, "Invalid billing account.", Map.of()); + MultiValueMap<String, String> form = new LinkedMultiValueMap<>(); + form.add("customer", customerId); form.add("return_url", returnUrl); + if (!getPortalConfigurationId().isBlank()) { + if (!getPortalConfigurationId().matches("bpc_[A-Za-z0-9]+")) return new StripeResult(false, null, null, "Invalid billing portal configuration.", Map.of()); + form.add("configuration", getPortalConfigurationId()); + } + return postForm("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/billing_portal/sessions", form); + } + + public Map<String, Object> getPaymentWithBalanceTransaction(String paymentId) { + if (!isReconciliationEnabled() || paymentId == null || !paymentId.startsWith("pi_")) return Map.of(); + try { + Map<String, Object> result = webClient.get() + .uri(builder -> builder.path("/payment_intents/{id}").queryParam("expand[]", "latest_charge.balance_transaction").build(paymentId)) + .headers(headers -> headers.setBasicAuth(stripeSecretKey, "")) + .retrieve().bodyToMono(Map.class).block(Duration.ofSeconds(20)); + return result == null ? Map.of() : result; + } catch (Exception unavailable) { + return Map.of(); + } + } + + private StripeResult postForm(String path, MultiValueMap<String, String> form) { + return postForm(path, form, null); + } + + private StripeResult postForm(String path, MultiValueMap<String, String> form, String idempotencyKey) { + try { + Map<String, Object> result = webClient.post() + .uri(path) + .headers(headers -> { + headers.setBasicAuth(stripeSecretKey, ""); + if (idempotencyKey != null) headers.set("Idempotency-Key", idempotencyKey); + }) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(form)) + .retrieve() + .bodyToMono(Map.class) + .block(Duration.ofSeconds(20)); + + 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, "The payment provider could not be reached. Please try again later.", Map.of()); + } + } + + 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/finance/StripeReadinessService.java b/backend/src/main/java/net/modtale/service/finance/StripeReadinessService.java new file mode 100644 index 000000000..f2bbb77b4 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/StripeReadinessService.java @@ -0,0 +1,89 @@ +package net.modtale.service.finance; + +import java.net.URI; +import java.time.Instant; +import java.util.*; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** Read-only diagnostics. Provider configuration checks cannot establish business approval or delivery. */ +@Service +public class StripeReadinessService { + public record Check(String code, boolean passed, String action) {} + public record Report(String mode, String apiVersion, boolean livePaymentsEnabled, + boolean providerConfigurationVerified, Instant verifiedAt, List<Check> checks, List<String> remainingChecks) {} + private final StripeGatewayService gateway; + private final String webhookSecret; + private final String webhookEndpointId; + private final String backendUrl; + private final String frontendUrl; + public StripeReadinessService(StripeGatewayService gateway, + @Value("${app.finance.stripe.webhook-secret:}") String webhookSecret, + @Value("${app.finance.stripe.webhook-endpoint-id:}") String webhookEndpointId, + @Value("${app.backend.url:http://localhost:8080}") String backendUrl, + @Value("${app.frontend.url:http://localhost:5173}") String frontendUrl) { + this.gateway = gateway; this.webhookSecret = webhookSecret; this.webhookEndpointId = webhookEndpointId; + this.backendUrl = backendUrl; this.frontendUrl = frontendUrl; + } + public Report configuration() { return report(false); } + public Report verifyProviderConfiguration() { return report(true); } + private Report report(boolean verify) { + List<Check> checks = new ArrayList<>(); + String mode = gateway.isTestMode() ? "TEST" : gateway.isLiveMode() ? "LIVE" : gateway.isEnabled() ? "UNKNOWN" : "UNCONFIGURED"; + add(checks, "credential_mode", gateway.isReconciliationEnabled(), "Set STRIPE_SECRET_KEY to a recognized test or live credential using the deployment secret store."); + add(checks, "simulation_disabled", !gateway.isMockEnabled(), "Disable STRIPE_MOCK_ENABLED when testing or operating the real provider integration."); + add(checks, "platform_account", gateway.getExpectedPlatformAccountId().matches("acct_[A-Za-z0-9]+"), "Set STRIPE_PLATFORM_ACCOUNT_ID to the exact platform account for this credential and environment."); + add(checks, "webhook_signing_secret", webhookSecret != null && webhookSecret.startsWith("whsec_") && webhookSecret.length() > 6, + "Set STRIPE_WEBHOOK_SECRET from this endpoint and environment. Its presence does not verify event delivery."); + add(checks, "webhook_endpoint", webhookEndpointId != null && webhookEndpointId.matches("we_[A-Za-z0-9]+"), "Set STRIPE_WEBHOOK_ENDPOINT_ID to the platform event endpoint, not a connected-account event endpoint."); + add(checks, "portal_configuration", gateway.getPortalConfigurationId().matches("bpc_[A-Za-z0-9]+"), "Set STRIPE_PORTAL_CONFIGURATION_ID to an active configuration with cancellation at period end and payment-method updates enabled."); + add(checks, "backend_url", validBaseUrl(backendUrl, gateway.isTestMode()), "Set BACKEND_URL to the deployed HTTPS API origin. Loopback HTTP is only supported for local test forwarding."); + add(checks, "frontend_url", validBaseUrl(frontendUrl, gateway.isTestMode()), "Set FRONTEND_URL to the deployed HTTPS site origin. Loopback HTTP is only supported for local tests."); + if (verify && gateway.isReconciliationEnabled()) { + Map<String, Object> account = gateway.getCurrentAccount(); + add(checks, "provider_account", "account".equals(account.get("object")) && gateway.getExpectedPlatformAccountId().equals(account.get("id")) && !gateway.getExpectedPlatformAccountId().isBlank(), + "Verify account-read permission and the expected platform account. Anonymous sandboxes can require claiming before this API is available."); + Map<String, Object> balance = gateway.getBalance(); + add(checks, "provider_mode", "balance".equals(balance.get("object")) && sameMode(balance), "Verify balance-read permission and ensure the credential belongs to the intended test/live environment."); + Map<String, Object> endpoint = gateway.getWebhookEndpoint(webhookEndpointId); + String expectedUrl = backendUrl == null ? "" : backendUrl.replaceAll("/+$", "") + "/api/v1/finance/webhooks/stripe"; + add(checks, "provider_webhook_fields", webhookEndpointId != null && webhookEndpointId.equals(endpoint.get("id")) + && "webhook_endpoint".equals(endpoint.get("object")) && endpoint.containsKey("application") && endpoint.get("application") == null && sameMode(endpoint) + && "enabled".equals(endpoint.get("status")) && StripeGatewayService.API_VERSION.equals(endpoint.get("api_version")) + && expectedUrl.equals(endpoint.get("url")) && containsRequiredEvents(endpoint.get("enabled_events")), + "Verify an enabled endpoint with no associated Connect application, the exact API URL, pinned API version, matching mode and required events. Delivery scope still requires an authentic platform-event test. Required events: " + String.join(", ", new TreeSet<>(StripeWebhookEvents.REQUIRED))); + Map<String, Object> portal = gateway.getPortalConfiguration(gateway.getPortalConfigurationId()); + Map<?, ?> features = map(portal.get("features")); + Map<?, ?> cancellation = map(features.get("subscription_cancel")); + add(checks, "provider_portal", gateway.getPortalConfigurationId().equals(portal.get("id")) + && "billing_portal.configuration".equals(portal.get("object")) && sameMode(portal) && Boolean.TRUE.equals(portal.get("active")) + && Boolean.TRUE.equals(cancellation.get("enabled")) && "at_period_end".equals(cancellation.get("mode")) + && Boolean.TRUE.equals(map(features.get("payment_method_update")).get("enabled")), + "Enable the configured portal in this environment with cancellation at period end and payment-method updates. Verify it with the customer's actual subscription."); + } + List<String> remaining = new ArrayList<>(List.of( + "Platform-event delivery scope is not proven by endpoint fields. Deliver and replay an authentic signed platform webhook to this deployment; confirm the matching signing secret and idempotent persisted fulfillment.", + "Complete sandbox Checkout, renewal, cancellation, actual fee settlement, refunds, disputes, Connect onboarding and payout reconciliation checks.", + "Verify creator-country eligibility, tax collection and withholding, content-creator approval, applicable agreements and privacy disclosures before live activation.", + "Reconcile actual Billing, Connect, routing and payout costs before allocating those costs; charge-level fees do not include every later fee.", + "Resolve the creator zero-net/100% platform-share fee policy before permitting that live edge case.")); + if (gateway.isAnonymousSandbox()) remaining.add("Anonymous sandbox credentials have limited permissions and expire unless claimed. A full test credential is required for account, balance and Connect verification."); + return new Report(mode, StripeGatewayService.API_VERSION, gateway.isLivePaymentsEnabled(), + verify && checks.stream().allMatch(Check::passed), verify ? Instant.now() : null, List.copyOf(checks), List.copyOf(remaining)); + } + private boolean sameMode(Map<String, Object> value) { return value.get("livemode") instanceof Boolean live && live != gateway.isTestMode(); } + private static void add(List<Check> checks, String code, boolean passed, String action) { checks.add(new Check(code, passed, action)); } + private static Map<?, ?> map(Object value) { return value instanceof Map<?, ?> m ? m : Map.of(); } + private static boolean containsRequiredEvents(Object value) { + return value instanceof List<?> events && (events.contains("*") || events.containsAll(StripeWebhookEvents.REQUIRED)); + } + static boolean validBaseUrl(String value, boolean test) { + try { + URI uri = URI.create(value); + if (uri.getUserInfo() != null || uri.getQuery() != null || uri.getFragment() != null || uri.getHost() == null + || !(uri.getPath().isEmpty() || "/".equals(uri.getPath()))) return false; + return "https".equals(uri.getScheme()) || (test && "http".equals(uri.getScheme()) + && Set.of("localhost", "127.0.0.1", "[::1]").contains(uri.getHost())); + } catch (Exception invalid) { return false; } + } +} diff --git a/backend/src/main/java/net/modtale/service/finance/StripeWebhookEvents.java b/backend/src/main/java/net/modtale/service/finance/StripeWebhookEvents.java new file mode 100644 index 000000000..b7db48bd3 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/StripeWebhookEvents.java @@ -0,0 +1,12 @@ +package net.modtale.service.finance; + +import java.util.Set; + +public final class StripeWebhookEvents { + private StripeWebhookEvents() {} + public static final Set<String> REQUIRED = Set.of( + "checkout.session.completed", "checkout.session.async_payment_succeeded", + "invoice.paid", "invoice.payment_failed", "customer.subscription.updated", "customer.subscription.deleted", + "charge.refunded", "refund.created", "refund.updated", "charge.dispute.created", "charge.dispute.updated", + "charge.dispute.closed", "charge.dispute.funds_withdrawn", "charge.dispute.funds_reinstated"); +} diff --git a/backend/src/main/java/net/modtale/service/finance/StripeWebhookSignature.java b/backend/src/main/java/net/modtale/service/finance/StripeWebhookSignature.java new file mode 100644 index 000000000..175fdda54 --- /dev/null +++ b/backend/src/main/java/net/modtale/service/finance/StripeWebhookSignature.java @@ -0,0 +1,47 @@ +package net.modtale.service.finance; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +/** Verifies the unchanged request body and accepts multiple v1 signatures during secret rotation. */ +public final class StripeWebhookSignature { + private static final long TOLERANCE_SECONDS = 300; + private StripeWebhookSignature() {} + + public static boolean verify(byte[] body, String signatureHeader, String secret, long nowSeconds) { + if (body == null || signatureHeader == null || secret == null || secret.isBlank()) return false; + String timestamp = null; + for (String part : signatureHeader.split(",")) { + String[] pair = part.trim().split("=", 2); + if (pair.length == 2 && pair[0].equals("t")) { + if (timestamp != null) return false; + timestamp = pair[1]; + } + } + if (timestamp == null) return false; + try { + long signedAt = Long.parseLong(timestamp); + if (signedAt < nowSeconds - TOLERANCE_SECONDS || signedAt > nowSeconds + TOLERANCE_SECONDS) return false; + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); + byte[] expected = mac.doFinal(body); + for (String part : signatureHeader.split(",")) { + String[] pair = part.trim().split("=", 2); + if (pair.length != 2 || !pair[0].equals("v1") || pair[1].length() != 64) continue; + try { + if (MessageDigest.isEqual(expected, HexFormat.of().parseHex(pair[1]))) return true; + } catch (IllegalArgumentException malformedSignature) { + // Continue: Stripe can send more than one signature while rotating a secret. + } + } + return false; + } catch (NumberFormatException | GeneralSecurityException invalid) { + return false; + } + } +} diff --git a/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java b/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java index 694aa7fcc..105d8801d 100644 --- a/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java +++ b/backend/src/main/java/net/modtale/service/project/metadata/MetadataService.java @@ -107,6 +107,8 @@ else if (!newSlug.equals(existing.getSlug())) { existing.setTypes(updated.getTypes()); existing.setAllowModpacks(updated.isAllowModpacks()); existing.setAllowComments(updated.isAllowComments()); + // Revenue policies use the dedicated owner-authorized finance endpoint. + // Ordinary metadata updates must neither reset nor override those settings. existing.setHmWikiEnabled(updated.isHmWikiEnabled()); existing.setHmWikiSlug(updated.getHmWikiSlug() != null ? updated.getHmWikiSlug().trim() : null); existing.setGalleryCarouselEnabled(updated.isGalleryCarouselEnabled()); diff --git a/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java b/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java index b6c5e5bb5..445092fbd 100644 --- a/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java +++ b/backend/src/main/java/net/modtale/service/project/version/VersionDownloadOrchestrationService.java @@ -322,7 +322,7 @@ private void ensureBundleDownloadable(ProjectVersion version, List<String> selec private void trackDownload(Project project, String versionId, DownloadContext context) { if (analyticsEligibilityService.shouldCountProjectEngagement(project, context.currentUser())) { - trackingService.logDownload(project.getId(), versionId, project.getAuthor(), context.apiRequest(), context.clientIp(), context.launcherClient()); + trackingService.logDownload(project.getId(), versionId, project.getAuthorId(), context.apiRequest(), context.clientIp(), context.launcherClient()); } } @@ -336,7 +336,7 @@ private void trackDependencyDownload(ProjectDependency dependency, DownloadConte trackingService.logDownload( dependency.getProjectId(), null, - dependencyProject != null ? dependencyProject.getAuthor() : null, + dependencyProject != null ? dependencyProject.getAuthorId() : null, context.apiRequest(), context.clientIp(), context.launcherClient() diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 040abf71d..d5028f1de 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -158,6 +158,16 @@ app.limits.modpack-gen-per-hour=10 app.limits.reports-per-day=10 app.limits.rescans-per-day=5 +app.finance.live-payments-enabled=${FINANCE_LIVE_PAYMENTS_ENABLED:false} +app.finance.stripe.secret-key=${STRIPE_SECRET_KEY:} +app.finance.stripe.publishable-key=${STRIPE_PUBLISHABLE_KEY:} +app.finance.stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET:} +app.finance.stripe.mock-enabled=${STRIPE_MOCK_ENABLED:false} +app.finance.stripe.platform-account-id=${STRIPE_PLATFORM_ACCOUNT_ID:} +app.finance.stripe.webhook-endpoint-id=${STRIPE_WEBHOOK_ENDPOINT_ID:} +app.finance.stripe.portal-configuration-id=${STRIPE_PORTAL_CONFIGURATION_ID:} +app.finance.ads.test-mode-enabled=${AD_TEST_ENABLED:false} + app.security.auto-approve-delay-minutes-min=${SECURITY_AUTO_APPROVE_DELAY_MINUTES_MIN:2} app.security.auto-approve-delay-minutes-max=${SECURITY_AUTO_APPROVE_DELAY_MINUTES_MAX:12} app.security.known-risk-delay-minutes-min=${SECURITY_KNOWN_RISK_DELAY_MINUTES_MIN:15} diff --git a/backend/src/test/java/net/modtale/config/security/ApiCsrfRequestMatcherTest.java b/backend/src/test/java/net/modtale/config/security/ApiCsrfRequestMatcherTest.java index 52b2ffc35..a6470283a 100644 --- a/backend/src/test/java/net/modtale/config/security/ApiCsrfRequestMatcherTest.java +++ b/backend/src/test/java/net/modtale/config/security/ApiCsrfRequestMatcherTest.java @@ -15,7 +15,9 @@ class ApiCsrfRequestMatcherTest { @ValueSource(strings = { "/api/v1/auth/change-password", "/api/v1/auth/credentials", "/api/v1/auth/password", "/api/v1/auth/mfa/verify", "/api/v1/auth/launcher/issue", "/api/v1/auth/logout", - "/api/v1/user/api-keys", "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/api/v1/projects/example" + "/api/v1/user/api-keys", "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/api/v1/projects/example", + "/api/v1/finance/projects/example/donations/checkout-url", "/api/v1/finance/donations/confirm", + "/api/v1/finance/creator/payouts/request" }) void sessionMutationsRequireTokenEvenWithEmptyKey(String path) throws Exception { var request = new MockHttpServletRequest("POST", path); @@ -44,7 +46,7 @@ void validCsrfTokenAllowsSessionMutation() throws Exception { } @ParameterizedTest - @ValueSource(strings = {"/api/v1/auth/signin", "/api/v1/auth/launcher/exchange", "/api/v1/users/batch", "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/api/v1/projects/external/identify"}) + @ValueSource(strings = {"/api/v1/auth/signin", "/api/v1/auth/launcher/exchange", "/api/v1/users/batch", "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/api/v1/projects/external/identify", "/api/v1/finance/webhooks/stripe"}) void preservesPublicPostOperations(String path) { assertFalse(new ApiCsrfRequestMatcher().matches(new MockHttpServletRequest("POST", path))); assertTrue(new ApiCsrfRequestMatcher().matches(new MockHttpServletRequest("DELETE", path))); diff --git a/backend/src/test/java/net/modtale/controller/finance/FinanceFrontendIntegrationTest.java b/backend/src/test/java/net/modtale/controller/finance/FinanceFrontendIntegrationTest.java new file mode 100644 index 000000000..daf00cca5 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/finance/FinanceFrontendIntegrationTest.java @@ -0,0 +1,173 @@ +package net.modtale.controller.finance; + +import jakarta.servlet.Filter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import net.modtale.config.properties.AppSecurityProperties; +import net.modtale.controller.auth.AuthController; +import net.modtale.controller.auth.CsrfController; +import net.modtale.model.finance.*; +import net.modtale.model.project.Project; +import net.modtale.model.user.User; +import net.modtale.repository.admin.BannedEmailRepository; +import net.modtale.repository.finance.*; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.analytics.TrackingService; +import net.modtale.service.auth.*; +import net.modtale.service.communication.EmailService; +import net.modtale.service.finance.*; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.PermissionProjectLookupService; +import net.modtale.service.user.account.AccountService; +import org.apache.catalina.startup.Tomcat; +import org.apache.tomcat.util.descriptor.web.FilterDef; +import org.apache.tomcat.util.descriptor.web.FilterMap; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.context.annotation.*; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.DispatcherServlet; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** No component scanning or public fixture endpoint: every fixture exists only in this test JVM. */ +class FinanceFrontendIntegrationTest { + @TempDir Path temporary; + + @Configuration + @Import(FinanceHttpSecurityTest.Config.class) + static class Config { + @Bean @Primary DonationCheckoutService realDonations() { return new DonationCheckoutService(); } + @Bean CsrfController csrfController() { return new CsrfController(); } + @Bean AuthenticationService fixtureAuthentication(UserRepository users) { + return new AuthenticationService(users, mock(BannedEmailRepository.class), mock(TrackingService.class), + new BCryptPasswordEncoder(), mock(EmailService.class), mock(ReservedAccountGuardService.class), + mock(AppSecurityProperties.class), mock(OAuthUserLoginService.class), mock(OAuthAccountLinkingService.class)); + } + @Bean AuthController authenticationController(AuthenticationService auth, AccountService accounts) { + return new AuthController(auth, mock(AuthenticationMutationService.class), accounts, mock(TwoFactorService.class), + mock(LauncherAuthService.class), new HttpSessionSecurityContextRepository(), mock(MfaEnrollmentService.class)); + } + } + + @Test void realFrontendUsesSessionCookiesCsrfAndAuthoritativeSupportTerms() throws Exception { + runFrontend(false); + } + + @Test void realChromiumUsesSessionCookiesCsrfAndAuthoritativeSupportTerms() throws Exception { + runFrontend(true); + } + + private void runFrontend(boolean browser) throws Exception { + Path frontend = Path.of("../frontend").toAbsolutePath().normalize(); + assertTrue(Files.isRegularFile(frontend.resolve("node_modules/vitest/vitest.mjs")), "Install frontend dependencies before this integration task"); + var tomcat = new Tomcat(); + tomcat.setBaseDir(temporary.resolve("tomcat").toString()); + tomcat.setHostname("127.0.0.1"); tomcat.setPort(0); + tomcat.getConnector().setProperty("address", "127.0.0.1"); + var servlet = tomcat.addContext("", temporary.toString()); + servlet.setParentClassLoader(getClass().getClassLoader()); + var application = new AnnotationConfigWebApplicationContext(); + application.setServletContext(servlet.getServletContext()); + application.register(Config.class); application.refresh(); + Map<String, DonationIntent> intents = configureFixtures(application); + var dispatcher = Tomcat.addServlet(servlet, "dispatcher", new DispatcherServlet(application)); + dispatcher.setLoadOnStartup(1); servlet.addServletMappingDecoded("/", "dispatcher"); + var filter = new FilterDef(); filter.setFilterName("security"); + filter.setFilter(application.getBean("springSecurityFilterChain", Filter.class)); servlet.addFilterDef(filter); + var mapping = new FilterMap(); mapping.setFilterName("security"); mapping.addURLPattern("/*"); servlet.addFilterMap(mapping); + Process child = null; + try { + tomcat.start(); + Path output = temporary.resolve("frontend-output.txt"); + Path report = Path.of(browser ? "build/test-results/finance-browser-ui.xml" : "build/test-results/finance-frontend-ui.xml").toAbsolutePath(); + Files.createDirectories(report.getParent()); + List<String> command = browser ? List.of("node", "scripts/run-finance-browser-tests.mjs") + : List.of("node", "node_modules/vitest/vitest.mjs", "run", "--config", "vitest.finance-integration.config.ts", + "--reporter=default", "--reporter=junit", "--outputFile.junit=" + report); + var process = new ProcessBuilder(command) + .directory(frontend.toFile()).redirectErrorStream(true).redirectOutput(output.toFile()); + // This subprocess only needs local Node and the disposable server. Do not inherit provider/cloud credentials. + String path = process.environment().get("PATH"); + process.environment().clear(); + process.environment().put("PATH", path == null ? "/usr/bin:/bin" : path); + process.environment().put("HOME", temporary.toString()); + process.environment().put("CI", "true"); + process.environment().put("MODTALE_FINANCE_TEST_ORIGIN", (browser ? "http://localhost:" : "http://127.0.0.1:") + tomcat.getConnector().getLocalPort()); + if (browser) { + process.environment().put("PLAYWRIGHT_JUNIT_OUTPUT_FILE", report.toString()); + process.environment().put("PLAYWRIGHT_BROWSERS_PATH", "0"); + String executable = System.getenv("MODTALE_FINANCE_CHROMIUM_PATH"); + if (executable != null && !executable.isBlank()) process.environment().put("MODTALE_FINANCE_CHROMIUM_PATH", executable); + } + child = process.start(); + assertTrue(child.waitFor(browser ? 180 : 120, TimeUnit.SECONDS), "Frontend integration timed out"); + String result = Files.readString(output); System.out.println(result); + assertEquals(0, child.exitValue(), "Frontend integration failed:\n" + result); + assertEquals(3, intents.size(), "Rejected, repeated, and stale requests must not create extra intents"); + assertEquals(1, intents.values().stream().filter(DonationIntent::isRecurring).count()); + for (var intent : intents.values()) { + assertEquals(1234, intent.getPlatformCutBps()); + assertEquals(500, intent.getAmountCents()); assertEquals(62, intent.getPlatformCents()); assertEquals(438, intent.getCreatorCents()); + assertEquals("acct_fixture", intent.getStripePlatformAccountId()); assertEquals(Boolean.TRUE, intent.getStripeTestMode()); + assertEquals(DonationIntent.DonationStatus.PENDING, intent.getStatus()); + assertEquals(intent.isRecurring() ? "owner" : null, intent.getDonorUserId()); + } + verifyNoInteractions(application.getBean(FinanceLedgerEntryRepository.class), application.getBean(FinanceWalletService.class)); + verify(application.getBean(StripeGatewayService.class), times(1)).createBillingPortalSession(eq("cus_owner"), anyString()); + } finally { + if (child != null && child.isAlive()) child.destroyForcibly().waitFor(); + tomcat.stop(); tomcat.destroy(); application.close(); + } + } + + private Map<String, DonationIntent> configureFixtures(AnnotationConfigWebApplicationContext app) { + var users = app.getBean(UserRepository.class); + for (String id : List.of("owner", "other")) { + var user = new User(); user.setId(id); user.setUsername(id); user.setRoles(List.of("USER")); + // Fictional, test-only password; no fixture user is persisted or accepted by a running application. + user.setPassword(new BCryptPasswordEncoder().encode("isolated-fixture-password")); + when(users.findByUsernameIgnoreCase(id)).thenReturn(Optional.of(user)); + when(users.findById(id)).thenReturn(Optional.of(user)); + } + var project = new Project(); project.setId("project"); project.setAuthorId("owner"); project.setTitle("Isolated support fixture"); + project.setDonationsEnabled(true); project.setDonationPlatformCutBps(1234); project.setSuggestedDonationCents(500); + var projects = app.getBean(ProjectService.class); + when(projects.getProjectById("project")).thenReturn(project); + when(projects.getRawProjectById("project")).thenReturn(project); + when(projects.getProjectLink(project)).thenReturn("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/mod/isolated-fixture"); + when(app.getBean(PermissionProjectLookupService.class).findProject("project")).thenReturn(project); + when(app.getBean(ProjectRepository.class).save(any(Project.class))).thenAnswer(call -> call.getArgument(0)); + var settings = new PlatformFinanceSettings(); settings.setCurrency("usd"); settings.setMonetizationPolicyVersion(2); + when(app.getBean(PlatformFinanceSettingsRepository.class).findById("platform")).thenReturn(Optional.of(settings)); + Map<String, DonationIntent> intents = new ConcurrentHashMap<>(); + var repository = app.getBean(DonationIntentRepository.class); + when(repository.save(any(DonationIntent.class))).thenAnswer(call -> { DonationIntent intent = call.getArgument(0); intents.put(intent.getId(), intent); return intent; }); + when(repository.findById(anyString())).thenAnswer(call -> Optional.ofNullable(intents.get(call.getArgument(0)))); + var gateway = app.getBean(StripeGatewayService.class); + when(gateway.isTestMode()).thenReturn(true); when(gateway.isOperational()).thenReturn(true); when(gateway.isCheckoutAvailable()).thenReturn(true); + when(gateway.getPlatformAccountId()).thenReturn("acct_fixture"); + when(gateway.getAvailabilityMessage()).thenReturn("Isolated provider fixture"); + when(gateway.createOrSimulateDonationCheckout(anyString(), anyString(), anyLong(), anyBoolean(), anyString(), anyString(), anyString(), eq(false))) + .thenAnswer(call -> new StripeGatewayService.StripeResult(true, "cs_" + call.getArgument(0), "https://checkout.stripe.com/c/pay/fixture", null, Map.of())); + when(gateway.getCheckoutSession(anyString(), eq(false))).thenReturn(Map.of("payment_status", "unpaid", "livemode", false)); + when(gateway.createBillingPortalSession(eq("cus_owner"), anyString())) + .thenReturn(new StripeGatewayService.StripeResult(true, "bps_fixture", "https://billing.stripe.com/p/session/fixture", null, Map.of())); + var subscriptions = app.getBean(CreatorSupportSubscriptionRepository.class); + var subscription = new CreatorSupportSubscription(); subscription.setId("sub_owner"); subscription.setDonorUserId("owner"); + subscription.setProjectId("project"); subscription.setCustomerId("cus_owner"); subscription.setProviderAccountId("acct_fixture"); + subscription.setTestMode(true); subscription.setAmountCents(500); subscription.setCurrency("usd"); subscription.setStatus("active"); + when(subscriptions.findById("sub_owner")).thenReturn(Optional.of(subscription)); + when(subscriptions.findByDonorUserId("owner")).thenReturn(List.of(subscription)); + when(subscriptions.findByDonorUserId("other")).thenReturn(List.of()); + return intents; + } +} diff --git a/backend/src/test/java/net/modtale/controller/finance/FinanceHttpSecurityTest.java b/backend/src/test/java/net/modtale/controller/finance/FinanceHttpSecurityTest.java new file mode 100644 index 000000000..56e62e246 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/finance/FinanceHttpSecurityTest.java @@ -0,0 +1,507 @@ +package net.modtale.controller.finance; + +import jakarta.servlet.http.Cookie; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import net.modtale.config.auth.ApiKeyAuthFilter; +import net.modtale.config.properties.AppFrontendProperties; +import net.modtale.config.security.*; +import net.modtale.exception.GlobalExceptionHandler; +import net.modtale.model.finance.*; +import net.modtale.model.project.Project; +import net.modtale.model.user.*; +import net.modtale.repository.finance.*; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import net.modtale.service.auth.*; +import net.modtale.service.finance.*; +import net.modtale.service.project.query.ProjectService; +import net.modtale.service.security.access.*; +import net.modtale.service.user.account.AccountService; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.context.annotation.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.mock.web.MockServletContext; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.*; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** Real MVC mappings, production filter chain and method security; repositories/provider transport are mocked. */ +class FinanceHttpSecurityTest { + private static final String CHECKOUT = "/api/v1/finance/projects/project/donations/checkout-url"; + private static final String PAYOUT = "/api/v1/finance/creator/payouts/request"; + private static final String PORTAL = "/api/v1/finance/support/subscriptions/sub_fixture/billing-portal"; + private static final String POLICY = "/api/v1/finance/projects/project/settings"; + private static final String WEBHOOK = "/api/v1/finance/webhooks/stripe"; + private static final String RECONCILE = "/api/v1/admin/finance/payout-reconciliation"; + private static final String DISPUTES = "/api/v1/admin/finance/dispute-reconciliation"; + private static final String READINESS = "/api/v1/admin/finance/stripe-readiness"; + private static final String COSTS = "/api/v1/admin/finance/provider-costs"; + private static final String SECRET = "fixture-signing-secret"; + private AnnotationConfigWebApplicationContext context; + private MockMvc mvc; + private User owner, other, reviewer; + private Project project; + + @Configuration @EnableWebMvc @EnableWebSecurity @EnableMethodSecurity + static class Config { + @Bean AccountService accounts() { + var service = mock(AccountService.class); + when(service.getCurrentUser()).thenAnswer(call -> principal(SecurityContextHolder.getContext().getAuthentication())); + when(service.getCurrentUser(any(Authentication.class))).thenAnswer(call -> principal(call.getArgument(0))); + return service; + } + private static User principal(Authentication auth) { return auth != null && auth.getPrincipal() instanceof User user ? user : null; } + @Bean UserRepository users() { return mock(UserRepository.class); } + @Bean ProjectRepository projects() { return mock(ProjectRepository.class); } + @Bean ProjectService projectService() { return mock(ProjectService.class); } + @Bean PermissionProjectLookupService permissionProjects() { return mock(PermissionProjectLookupService.class); } + @Bean MongoTemplate mongo() { return mock(MongoTemplate.class); } + @Bean(name = "apiSecurity") AccessControlService access(AccountService accounts, UserRepository users, PermissionProjectLookupService projects, MongoTemplate mongo) { + return new AccessControlService(accounts, users, projects, mongo); + } + @Bean RevenueOpsSupport core() { return new RevenueOpsSupport(); } + @Bean EarningsAccountService earnings() { return new EarningsAccountService(); } + @Bean PlatformFinanceSettingsRepository settings() { return mock(PlatformFinanceSettingsRepository.class); } + @Bean FinanceLedgerEntryRepository ledger() { return mock(FinanceLedgerEntryRepository.class); } + @Bean FinanceWalletService wallets() { return mock(FinanceWalletService.class); } + @Bean CreatorPayoutService payouts() { return mock(CreatorPayoutService.class); } + @Bean DonationCheckoutService donations() { return mock(DonationCheckoutService.class); } + @Bean StripeGatewayService gateway() { return mock(StripeGatewayService.class); } + @Bean DonationIntentRepository intents() { return mock(DonationIntentRepository.class); } + @Bean CreatorSupportSubscriptionRepository subscriptions() { return mock(CreatorSupportSubscriptionRepository.class); } + @Bean RecurringSupportService recurring(CreatorSupportSubscriptionRepository subscriptions, DonationIntentRepository intents, + FinanceLedgerEntryRepository ledger, StripeGatewayService gateway, RevenueOpsSupport core, ProjectService projects) { + return new RecurringSupportService(subscriptions, intents, ledger, gateway, core, projects); + } + @Bean PaymentAdjustmentService adjustments() { return mock(PaymentAdjustmentService.class); } + @Bean PaymentWebhookReceiptRepository receipts() { return mock(PaymentWebhookReceiptRepository.class); } + @Bean AdSettlementStagingService staging() { return mock(AdSettlementStagingService.class); } + @Bean ApiKeyService keys() { return mock(ApiKeyService.class); } + @Bean ClientRegistrationRepository clients() { return mock(ClientRegistrationRepository.class); } + @Bean GlobalExceptionHandler errors() { return new GlobalExceptionHandler(); } + @Bean PayoutReconciliationController reconciliationController(CreatorPayoutService payouts, AccountService accounts) { return new PayoutReconciliationController(payouts, accounts); } + @Bean DisputeReconciliationController disputeController(PaymentAdjustmentService adjustments, AccountService accounts) { return new DisputeReconciliationController(adjustments, accounts); } + @Bean StripeReadinessService readiness() { return mock(StripeReadinessService.class); } + @Bean StripeReadinessController readinessController(StripeReadinessService readiness) { return new StripeReadinessController(readiness); } + @Bean ProviderCostEvidenceService costs() { return mock(ProviderCostEvidenceService.class); } + @Bean ProviderCostEvidenceController costController(ProviderCostEvidenceService costs, AccountService accounts) { return new ProviderCostEvidenceController(costs, accounts); } + @Bean DonationController donationController() { return new DonationController(); } + @Bean CreatorRevenueController creatorController() { return new CreatorRevenueController(); } + @Bean RecurringSupportController recurringController(RecurringSupportService service, AccountService accounts) { return new RecurringSupportController(service, accounts); } + @Bean AdSettlementAdminController adController(AdSettlementStagingService service, AccountService accounts) { return new AdSettlementAdminController(service, accounts); } + @Bean StripeWebhookController webhook(DonationCheckoutService donations, PaymentWebhookReceiptRepository receipts, + StripeGatewayService gateway, RecurringSupportService recurring, PaymentAdjustmentService adjustments) { + return new StripeWebhookController(donations, receipts, gateway, recurring, adjustments, SECRET); + } + @Bean SecurityFilterChain security(HttpSecurity http, ApiKeyService keys, AccountService accounts) throws Exception { + var config = new SecurityConfig(new ApiKeyAuthFilter(keys, (req, res, handler, failure) -> { + res.setStatus(401); return new org.springframework.web.servlet.ModelAndView(); + }), new RateLimitFilter(keys), mock(OAuth2LoginService.class), mock(OidcLoginService.class), + mock(OAuth2AuthorizedClientRepository.class), mock(LocalUserDetailsService.class), mock(PasswordEncoder.class), + accounts, mock(AuthenticationService.class), mock(LauncherAuthService.class), new AppFrontendProperties("http://localhost:3000")); + return config.securityFilterChain(http, mock(OAuth2AuthorizationRequestResolver.class)); + } + } + + @BeforeEach void setup() { + context = new AnnotationConfigWebApplicationContext(); context.setServletContext(new MockServletContext()); + context.register(Config.class); context.refresh(); + mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build(); + owner = user("owner"); other = user("other"); reviewer = user("reviewer"); + reviewer.setAdminPermissions(Set.of(AdminPermission.PLATFORM_FINANCE_MANAGE)); + var users = bean(UserRepository.class); + when(users.findById(owner.getId())).thenReturn(Optional.of(owner)); + when(users.findById(other.getId())).thenReturn(Optional.of(other)); + when(users.findById(reviewer.getId())).thenReturn(Optional.of(reviewer)); + project = new Project(); project.setId("project"); project.setAuthorId(owner.getId()); + when(bean(ProjectService.class).getRawProjectById("project")).thenReturn(project); + when(bean(PermissionProjectLookupService.class).findProject("project")).thenReturn(project); + var settings = new PlatformFinanceSettings(); settings.setMonetizationPolicyVersion(2); + when(bean(PlatformFinanceSettingsRepository.class).findById("platform")).thenReturn(Optional.of(settings)); + when(bean(StripeGatewayService.class).isTestMode()).thenReturn(true); + when(bean(StripeGatewayService.class).isReconciliationEnabled()).thenReturn(true); + when(bean(StripeGatewayService.class).getPlatformAccountId()).thenReturn("acct_fixture"); + } + @AfterEach void cleanup() { context.close(); SecurityContextHolder.clearContext(); } + private <T> T bean(Class<T> type) { return context.getBean(type); } + private static User user(String id) { var user = new User(); user.setId(id); user.setUsername(id); return user; } + private static Authentication session(User user) { return new UsernamePasswordAuthenticationToken(user, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); } + private static MockHttpServletRequestBuilder browser(MockHttpServletRequestBuilder request) { + return request.header("Origin", "http://localhost:3000").header("User-Agent", "Mozilla/5.0"); + } + private static MockHttpServletRequestBuilder csrf(MockHttpServletRequestBuilder request) { + return request.cookie(new Cookie("XSRF-TOKEN", "fixture-csrf")).header("X-XSRF-TOKEN", "fixture-csrf"); + } + private void apiKey(User user) { + var key = new ApiKey(); key.setId("fixture-key-id"); key.setContextPermissions(Map.of("PERSONAL", Set.of(ApiKey.ApiPermission.PROFILE_READ))); + when(bean(ApiKeyService.class).resolveKey("fixture-key")).thenReturn(key); + when(bean(ApiKeyService.class).getUserFromKey(key)).thenReturn(user); + } + private static String checkoutBody() { return "{\"amountCents\":500,\"recurring\":false,\"guestCheckout\":true,\"expectedPlatformCutBps\":1000}"; } + + @Test void publicConfigurationWorksButCheckoutCannotBeCreatedWithGet() throws Exception { + when(bean(DonationCheckoutService.class).getDonationConfig("project")).thenReturn(Map.of("donationPlatformCutPercent", 10)); + mvc.perform(browser(get("/api/v1/finance/projects/project/donation-config"))).andExpect(status().isOk()).andExpect(jsonPath("$.donationPlatformCutPercent").value(10)); + mvc.perform(browser(get(CHECKOUT)).with(authentication(session(owner)))).andExpect(status().isMethodNotAllowed()); + verify(bean(DonationCheckoutService.class), never()).createDonationCheckout(anyString(), anyLong(), anyBoolean(), any(), anyBoolean(), anyInt()); + } + @Test void guestCheckoutRequiresMatchingCookieAndHeaderAndDoesNotAdoptBodyIdentity() throws Exception { + var donations = bean(DonationCheckoutService.class); + when(donations.createDonationCheckout(eq("project"), eq(500L), eq(false), isNull(), eq(true), eq(1000))).thenReturn(Map.of("url", "https://checkout.stripe.test/fixture")); + mvc.perform(browser(post(CHECKOUT)).contentType("application/json").content(checkoutBody())).andExpect(status().isForbidden()); + mvc.perform(browser(post(CHECKOUT)).cookie(new Cookie("XSRF-TOKEN", "one")).header("X-XSRF-TOKEN", "two") + .contentType("application/json").content(checkoutBody())).andExpect(status().isForbidden()); + mvc.perform(csrf(browser(post(CHECKOUT))).contentType("application/json").content(checkoutBody())).andExpect(status().isOk()); + verify(donations, times(1)).createDonationCheckout("project", 500L, false, null, true, 1000); + } + @ParameterizedTest @ValueSource(strings = {"500.99", "0", "-1", "100001", "null"}) + void malformedAmountsAreRejectedByHttpValidationBeforeCheckout(String amount) throws Exception { + mvc.perform(csrf(browser(post(CHECKOUT))).contentType("application/json").content("{\"amountCents\":" + amount + ",\"recurring\":false,\"guestCheckout\":true,\"expectedPlatformCutBps\":1000}")) + .andExpect(status().isBadRequest()); + verifyNoInteractions(bean(DonationCheckoutService.class)); + } + @Test void checkoutUsesAuthenticatedSessionIdentity() throws Exception { + when(bean(DonationCheckoutService.class).createDonationCheckout(eq("project"), eq(500L), eq(false), same(owner), eq(false), eq(1000))).thenReturn(Map.of("url", "https://checkout.stripe.test/fixture")); + mvc.perform(csrf(browser(post(CHECKOUT))).with(authentication(session(owner))).contentType("application/json") + .content("{\"amountCents\":500,\"recurring\":false,\"guestCheckout\":false,\"expectedPlatformCutBps\":1000,\"donorUserId\":\"other\"}")).andExpect(status().isOk()); + verify(bean(DonationCheckoutService.class)).createDonationCheckout("project", 500L, false, owner, false, 1000); + } + @ParameterizedTest @ValueSource(strings = {PAYOUT, PORTAL, "/api/v1/finance/admin/ad-settlements"}) + void authenticatedFinanceMutationsRequireCsrf(String path) throws Exception { + mvc.perform(browser(post(path)).with(authentication(session(reviewer))).contentType("application/json").content("{}")) + .andExpect(status().isForbidden()); + verifyNoInteractions(bean(CreatorPayoutService.class), bean(AdSettlementStagingService.class)); + } + @Test void apiKeyCannotUseBrowserOnlyFinanceRoutesEvenForFinanceAdmin() throws Exception { + apiKey(reviewer); + for (String path : List.of(PAYOUT, PORTAL, "/api/v1/finance/admin/ad-settlements")) { + mvc.perform(browser(post(path)).header("X-MODTALE-KEY", "fixture-key").contentType("application/json").content("{}")) + .andExpect(status().isForbidden()); + } + verifyNoInteractions(bean(CreatorPayoutService.class), bean(AdSettlementStagingService.class)); + } + @Test void apiKeyCannotCreateDonorCheckoutWithAnUnscopedPersonalKey() throws Exception { + apiKey(owner); + mvc.perform(browser(post(CHECKOUT)).header("X-MODTALE-KEY", "fixture-key").contentType("application/json").content(checkoutBody())) + .andExpect(status().isForbidden()); + verifyNoInteractions(bean(DonationCheckoutService.class)); + } + @Test void invalidApiKeyCannotFallBackToAuthenticatedSession() throws Exception { + mvc.perform(csrf(browser(post(PAYOUT))).with(authentication(session(owner))).header("X-MODTALE-KEY", "invalid") + .contentType("application/json").content("{}")) .andExpect(status().isUnauthorized()); + verifyNoInteractions(bean(CreatorPayoutService.class)); + } + @Test void anonymousCannotReadPrivateSubscriptionsOrRequestPayout() throws Exception { + mvc.perform(browser(get("/api/v1/finance/support/subscriptions"))).andExpect(status().isUnauthorized()); + mvc.perform(csrf(browser(post(PAYOUT))).contentType("application/json").content("{}")) .andExpect(status().isUnauthorized()); + verifyNoInteractions(bean(CreatorPayoutService.class), bean(CreatorSupportSubscriptionRepository.class)); + } + @Test void otherUsersPayoutAccountCannotBeSelectedInRequestBody() throws Exception { + mvc.perform(csrf(browser(post(PAYOUT))).with(authentication(session(owner))).contentType("application/json") + .content("{\"ownerId\":\"other\",\"amountCents\":1000,\"requestKey\":\"fixture\"}")) .andExpect(status().isForbidden()); + verifyNoInteractions(bean(CreatorPayoutService.class)); + } + @Test void anotherDonorsBillingPortalCannotBeOpened() throws Exception { + var subscription = new CreatorSupportSubscription(); subscription.setId("sub_fixture"); subscription.setDonorUserId(other.getId()); + when(bean(CreatorSupportSubscriptionRepository.class).findById("sub_fixture")).thenReturn(Optional.of(subscription)); + mvc.perform(csrf(browser(post(PORTAL))).with(authentication(session(owner)))).andExpect(status().isForbidden()); + verify(bean(StripeGatewayService.class), never()).createBillingPortalSession(anyString(), anyString()); + } + @Test void financialReviewerCanReviewReportsButCannotEditAnotherCreatorsPolicy() throws Exception { + when(bean(AdSettlementStagingService.class).list(reviewer)).thenReturn(List.of()); + mvc.perform(browser(get("/api/v1/finance/admin/ad-settlements")).with(authentication(session(reviewer)))).andExpect(status().isOk()); + mvc.perform(browser(get("/api/v1/finance/admin/ad-settlements")).with(authentication(session(other)))).andExpect(status().isForbidden()); + mvc.perform(csrf(browser(put(POLICY))).with(authentication(session(reviewer))).contentType("application/json").content("{\"adsEnabled\":true}")) + .andExpect(status().isForbidden()); + verify(bean(ProjectRepository.class), never()).save(any(Project.class)); + } + @Test void projectEditorCannotChangeOwnerOnlyMoneyPolicyButOwnerCan() throws Exception { + project.setTeamMembers(List.of(new Project.ProjectMember(other.getId(), "editor"))); + project.setProjectRoles(List.of(new Project.ProjectRole("editor", "Editor", "blue", Set.of(ApiKey.ApiPermission.PROJECT_EDIT_METADATA)))); + mvc.perform(csrf(browser(put(POLICY))).with(authentication(session(other))).contentType("application/json").content("{\"donationPlatformCutBps\":2500}")) + .andExpect(status().isForbidden()); + verify(bean(ProjectRepository.class), never()).save(any(Project.class)); + mvc.perform(csrf(browser(put(POLICY))).with(authentication(session(owner))).contentType("application/json").content("{\"donationPlatformCutBps\":2500}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.donationPlatformCutBps").value(2500)); + verify(bean(ProjectRepository.class)).save(project); + } + @Test void missingOrFractionalDisplayedShareCannotCreateCheckout() throws Exception { + for (String body : List.of( + "{\"amountCents\":500,\"recurring\":false,\"guestCheckout\":true}", + "{\"amountCents\":500,\"recurring\":false,\"guestCheckout\":true,\"expectedPlatformCutBps\":1000.5}")) { + mvc.perform(csrf(browser(post(CHECKOUT))).contentType("application/json").content(body)).andExpect(status().isBadRequest()); + } + verifyNoInteractions(bean(DonationCheckoutService.class)); + } + @Test void staleDisplayedShareReturnsAnExplicitConflict() throws Exception { + when(bean(DonationCheckoutService.class).createDonationCheckout("project", 500L, false, null, true, 1000)) + .thenThrow(new DonationCheckoutService.SupportTermsChangedException()); + mvc.perform(csrf(browser(post(CHECKOUT))).contentType("application/json").content(checkoutBody())) + .andExpect(status().isConflict()).andExpect(jsonPath("$.code").value("SUPPORT_TERMS_CHANGED")); + } + @Test void malformedJsonAndWrongContentTypeAreClientErrorsNotServerFailures() throws Exception { + mvc.perform(csrf(browser(post(CHECKOUT))).contentType("application/json").content("{broken")) + .andExpect(status().isBadRequest()); + mvc.perform(csrf(browser(post(CHECKOUT))).contentType("text/plain").content(checkoutBody())) + .andExpect(status().isUnsupportedMediaType()); + verifyNoInteractions(bean(DonationCheckoutService.class)); + } + @Test void foreignBrowserOriginCannotPreflightTheRequiredCsrfHeader() throws Exception { + // Spring may allow the public Content-Type header while omitting the forbidden + // CSRF header. Browsers must reject that incomplete preflight grant. + var response = mvc.perform(options(CHECKOUT).header("Origin", "https://untrusted.example") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "Content-Type,X-XSRF-TOKEN")) + .andExpect(status().isOk()).andReturn().getResponse(); + assertEquals("*", response.getHeader("Access-Control-Allow-Origin")); + assertNull(response.getHeader("Access-Control-Allow-Credentials")); + assertNotNull(response.getHeader("Access-Control-Allow-Headers")); + assertFalse(response.getHeader("Access-Control-Allow-Headers").toLowerCase(Locale.ROOT).contains("x-xsrf-token")); + mvc.perform(options(CHECKOUT).header("Origin", "https://untrusted.example") + .header("Access-Control-Request-Method", "POST") + .header("Access-Control-Request-Headers", "X-XSRF-TOKEN")) + .andExpect(status().isForbidden()); + verifyNoInteractions(bean(DonationCheckoutService.class)); + } + @Test void apiKeyCannotUseBrowserConfirmationEndpoint() throws Exception { + apiKey(owner); + mvc.perform(browser(post("/api/v1/finance/donations/confirm")).header("X-MODTALE-KEY", "fixture-key") + .contentType("application/json").content("{\"intentId\":\"fixture-intent\"}")).andExpect(status().isForbidden()); + verifyNoInteractions(bean(DonationCheckoutService.class)); + } + @Test void ownPayoutUsesSessionOwnerAndPassesTheExactAmountAndRequestKey() throws Exception { + var result = new CreatorPayoutRequest(); result.setId("payout_fixture"); result.setAmountCents(1000); + result.setStatus(CreatorPayoutRequest.Status.RESERVED); result.setTestMode(true); + when(bean(CreatorPayoutService.class).request(same(owner), same(owner), eq("usd"), eq(1000L), anyLong(), eq("fixture-request"))).thenReturn(result); + mvc.perform(csrf(browser(post(PAYOUT))).with(authentication(session(owner))).contentType("application/json") + .content("{\"amountCents\":1000,\"requestKey\":\"fixture-request\"}")) + .andExpect(status().isOk()).andExpect(jsonPath("$.amountCents").value(1000)).andExpect(jsonPath("$.testMode").value(true)); + verify(bean(CreatorPayoutService.class)).request(same(owner), same(owner), eq("usd"), eq(1000L), anyLong(), eq("fixture-request")); + } + @Test void orgMetadataEditorCannotWithdrawOrChangePolicyButOrgOwnerCanChangePolicy() throws Exception { + var org = user("org"); org.setAccountType(User.AccountType.ORGANIZATION); + var ownerRole = new User.OrganizationRole("owner-role", "Owner", "blue", Set.of()); ownerRole.setOwner(true); + var editorRole = new User.OrganizationRole("editor-role", "Editor", "blue", Set.of(ApiKey.ApiPermission.ORG_EDIT_METADATA)); + org.setOrganizationRoles(List.of(ownerRole, editorRole)); + org.setOrganizationMembers(List.of(new User.OrganizationMember(owner.getId(), "owner-role"), new User.OrganizationMember(other.getId(), "editor-role"))); + when(bean(UserRepository.class).findById("org")).thenReturn(Optional.of(org)); + String path = "/api/v1/finance/creator/orgs/org/payout-policy"; + String body = "{\"payoutMode\":\"DIRECT_TO_ORG_STRIPE\",\"shares\":[]}"; + mvc.perform(csrf(browser(put(path))).with(authentication(session(other))).contentType("application/json").content(body)).andExpect(status().isForbidden()); + mvc.perform(csrf(browser(post(PAYOUT))).with(authentication(session(other))).contentType("application/json") + .content("{\"ownerId\":\"org\",\"amountCents\":1000,\"requestKey\":\"fixture\"}")) .andExpect(status().isForbidden()); + verify(bean(UserRepository.class), never()).save(any(User.class)); verifyNoInteractions(bean(CreatorPayoutService.class)); + mvc.perform(csrf(browser(put(path))).with(authentication(session(owner))).contentType("application/json").content(body)).andExpect(status().isOk()); + verify(bean(UserRepository.class)).save(org); + } + private static String reconciliationBody() { return "{\"requestId\":\"payout_fixture\",\"recipientIndex\":0,\"transferId\":\"tr_fixture\",\"reason\":\"Verified provider receipt\",\"reviewerId\":\"other\"}"; } + @Test void reconciliationQueueRequiresFinanceManagerAndRejectsApiKeys() throws Exception { + mvc.perform(browser(get(RECONCILE))).andExpect(status().isForbidden()); + mvc.perform(browser(get(RECONCILE)).with(authentication(session(owner)))).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(get(RECONCILE)).header("X-MODTALE-KEY", "fixture-key")).andExpect(status().isForbidden()); + verifyNoInteractions(bean(CreatorPayoutService.class)); + when(bean(CreatorPayoutService.class).getReviewRequests()).thenReturn(List.of()); + mvc.perform(browser(get(RECONCILE)).with(authentication(session(reviewer)))).andExpect(status().isOk()); + verify(bean(CreatorPayoutService.class)).getReviewRequests(); + } + @Test void reconciliationMutationRequiresMatchingCsrfAndFinanceSession() throws Exception { + String path = RECONCILE + "/confirm-existing-transfer"; + mvc.perform(browser(post(path)).with(authentication(session(reviewer))).contentType("application/json").content(reconciliationBody())).andExpect(status().isForbidden()); + mvc.perform(csrf(browser(post(path))).with(authentication(session(owner))).contentType("application/json").content(reconciliationBody())).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(post(path)).header("X-MODTALE-KEY", "fixture-key").contentType("application/json").content(reconciliationBody())).andExpect(status().isForbidden()); + verifyNoInteractions(bean(CreatorPayoutService.class)); + } + @Test void reconciliationUsesAuthenticatedReviewerAndExactReceiptArguments() throws Exception { + var result = new CreatorPayoutRequest(); result.setId("payout_fixture"); result.setAmountCents(1000); + result.setStatus(CreatorPayoutRequest.Status.RESERVED); result.setTestMode(true); + when(bean(CreatorPayoutService.class).reconcileKnownTransfer("payout_fixture", 0, "tr_fixture", reviewer, "Verified provider receipt")).thenReturn(result); + mvc.perform(csrf(browser(post(RECONCILE + "/confirm-existing-transfer"))).with(authentication(session(reviewer))) + .contentType("application/json").content(reconciliationBody())).andExpect(status().isOk()).andExpect(jsonPath("$.requestId").value("payout_fixture")); + verify(bean(CreatorPayoutService.class)).reconcileKnownTransfer("payout_fixture", 0, "tr_fixture", reviewer, "Verified provider receipt"); + } + @ParameterizedTest @ValueSource(strings = { + "{\"requestId\":\"\",\"recipientIndex\":0,\"transferId\":\"tr_fixture\",\"reason\":\"verified\"}", + "{\"requestId\":\"payout_fixture\",\"recipientIndex\":-1,\"transferId\":\"tr_fixture\",\"reason\":\"verified\"}", + "{\"requestId\":\"payout_fixture\",\"recipientIndex\":0,\"transferId\":\"tr_bad/path\",\"reason\":\"verified\"}", + "{\"requestId\":\"payout_fixture\",\"recipientIndex\":0,\"transferId\":\"tr_fixture\",\"reason\":\" \"}"}) + void invalidReconciliationEvidenceCannotReachAccounting(String body) throws Exception { + mvc.perform(csrf(browser(post(RECONCILE + "/confirm-existing-transfer"))).with(authentication(session(reviewer))) + .contentType("application/json").content(body)).andExpect(status().isBadRequest()); + verifyNoInteractions(bean(CreatorPayoutService.class)); + } + private static String disputeBody(String fee, String digest, String reason) { + return "{\"caseId\":\"case_fixture\",\"expectedEvidenceDigest\":\"" + digest + "\",\"creatorFeeCents\":" + fee + ",\"reason\":\"" + reason + "\",\"reviewerId\":\"other\"}"; + } + @Test void disputeQueueRequiresFinanceManagerAndRejectsApiKeys() throws Exception { + mvc.perform(browser(get(DISPUTES))).andExpect(status().isForbidden()); + mvc.perform(browser(get(DISPUTES)).with(authentication(session(owner)))).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(get(DISPUTES)).header("X-MODTALE-KEY", "fixture-key")).andExpect(status().isForbidden()); + verifyNoInteractions(bean(PaymentAdjustmentService.class)); + when(bean(PaymentAdjustmentService.class).getDisputeCases()).thenReturn(List.of()); + mvc.perform(browser(get(DISPUTES)).with(authentication(session(reviewer)))).andExpect(status().isOk()); + verify(bean(PaymentAdjustmentService.class)).getDisputeCases(); + } + @Test void immutableDisputeDecisionHistoryRequiresBrowserFinanceManager() throws Exception { + String path = DISPUTES + "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/case_fixture/decisions"; + mvc.perform(browser(get(path))).andExpect(status().isForbidden()); + mvc.perform(browser(get(path)).with(authentication(session(owner)))).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(get(path)).header("X-MODTALE-KEY", "fixture-key")).andExpect(status().isForbidden()); + verifyNoInteractions(bean(PaymentAdjustmentService.class)); + when(bean(PaymentAdjustmentService.class).getDisputeDecisions("case_fixture")).thenReturn(List.of()); + mvc.perform(browser(get(path)).with(authentication(session(reviewer)))).andExpect(status().isOk()); + verify(bean(PaymentAdjustmentService.class)).getDisputeDecisions("case_fixture"); + } + @Test void selectedDisputeRefreshRequiresFinanceSessionAndCsrf() throws Exception { + String path = DISPUTES + "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/case_fixture/refresh"; + mvc.perform(browser(post(path)).with(authentication(session(reviewer)))).andExpect(status().isForbidden()); + mvc.perform(csrf(browser(post(path))).with(authentication(session(owner)))).andExpect(status().isForbidden()); + apiKey(reviewer); mvc.perform(browser(post(path)).header("X-MODTALE-KEY", "fixture-key")).andExpect(status().isForbidden()); + verifyNoInteractions(bean(PaymentAdjustmentService.class)); + mvc.perform(csrf(browser(post(path))).with(authentication(session(reviewer)))).andExpect(status().isOk()); + verify(bean(PaymentAdjustmentService.class)).refreshCase("case_fixture"); + verify(bean(PaymentAdjustmentService.class), never()).resolveCase(anyString(), anyString(), anyLong(), any(), anyString()); + } + @Test void disputeResolutionRequiresCsrfAndFinanceSession() throws Exception { + String body = disputeBody("125", "a".repeat(64), "Verified actual fee"); + mvc.perform(browser(post(DISPUTES + "/resolve")).with(authentication(session(reviewer))).contentType("application/json").content(body)).andExpect(status().isForbidden()); + mvc.perform(csrf(browser(post(DISPUTES + "/resolve"))).with(authentication(session(owner))).contentType("application/json").content(body)).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(post(DISPUTES + "/resolve")).header("X-MODTALE-KEY", "fixture-key").contentType("application/json").content(body)).andExpect(status().isForbidden()); + verifyNoInteractions(bean(PaymentAdjustmentService.class)); + } + @Test void disputeResolutionBindsEvidenceAmountAndAuthenticatedReviewer() throws Exception { + String digest = "a".repeat(64); + var result = new FinanceDisputeResolution("resolution_fixture", "case_fixture", "dp_fixture", digest, "acct_fixture", true, "won", 125, 125, reviewer.getId(), "Verified actual fee", Instant.now(), "usd", 1000, 0, 1000, List.of()); + when(bean(PaymentAdjustmentService.class).resolveCase("case_fixture", digest, 125L, reviewer, "Verified actual fee")).thenReturn(result); + mvc.perform(csrf(browser(post(DISPUTES + "/resolve"))).with(authentication(session(reviewer))) + .contentType("application/json").content(disputeBody("125", digest, "Verified actual fee"))) + .andExpect(status().isOk()).andExpect(jsonPath("$.reviewerId").value("reviewer")).andExpect(jsonPath("$.creatorFeeCents").value(125)); + verify(bean(PaymentAdjustmentService.class)).resolveCase("case_fixture", digest, 125L, reviewer, "Verified actual fee"); + } + @ParameterizedTest @ValueSource(strings = {"-1", "0.5", "null", "1000000000000"}) + void invalidDisputeFeeCannotReachAccounting(String fee) throws Exception { + mvc.perform(csrf(browser(post(DISPUTES + "/resolve"))).with(authentication(session(reviewer))) + .contentType("application/json").content(disputeBody(fee, "a".repeat(64), "Verified actual fee"))).andExpect(status().isBadRequest()); + verifyNoInteractions(bean(PaymentAdjustmentService.class)); + } + @Test void invalidEvidenceDigestAndBlankReasonCannotReachAccounting() throws Exception { + for (String body : List.of(disputeBody("0", "abc", "Verified"), disputeBody("0", "A".repeat(64), "Verified"), disputeBody("0", "a".repeat(64), " "))) { + mvc.perform(csrf(browser(post(DISPUTES + "/resolve"))).with(authentication(session(reviewer))) + .contentType("application/json").content(body)).andExpect(status().isBadRequest()); + } + verifyNoInteractions(bean(PaymentAdjustmentService.class)); + } + @Test void stripeReadinessIsPrivateAndUnavailableToApiKeys() throws Exception { + mvc.perform(browser(get(READINESS))).andExpect(status().isForbidden()); + mvc.perform(browser(get(READINESS)).with(authentication(session(owner)))).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(get(READINESS)).header("X-MODTALE-KEY", "fixture-key")).andExpect(status().isForbidden()); + verifyNoInteractions(bean(StripeReadinessService.class)); + } + @Test void stripeReadinessGetUsesLocalConfigurationAndDoesNotVerifyProvider() throws Exception { + var report = new StripeReadinessService.Report("TEST", StripeGatewayService.API_VERSION, false, false, null, List.of(), List.of("Signed delivery remains unverified")); + when(bean(StripeReadinessService.class).configuration()).thenReturn(report); + mvc.perform(browser(get(READINESS)).with(authentication(session(reviewer)))) + .andExpect(status().isOk()).andExpect(jsonPath("$.mode").value("TEST")) + .andExpect(jsonPath("$.providerConfigurationVerified").value(false)); + verify(bean(StripeReadinessService.class)).configuration(); + verify(bean(StripeReadinessService.class), never()).verifyProviderConfiguration(); + } + @Test void stripeProviderVerificationRequiresExplicitManagerCsrfRequest() throws Exception { + String path = READINESS + "/verify"; + mvc.perform(browser(post(path)).with(authentication(session(reviewer)))).andExpect(status().isForbidden()); + mvc.perform(csrf(browser(post(path))).with(authentication(session(owner)))).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(post(path)).header("X-MODTALE-KEY", "fixture-key")).andExpect(status().isForbidden()); + verifyNoInteractions(bean(StripeReadinessService.class)); + var report = new StripeReadinessService.Report("TEST", StripeGatewayService.API_VERSION, false, false, Instant.now(), List.of(new StripeReadinessService.Check("provider_account", false, "Verify account read permission")), List.of("Signed delivery remains unverified")); + when(bean(StripeReadinessService.class).verifyProviderConfiguration()).thenReturn(report); + mvc.perform(csrf(browser(post(path))).with(authentication(session(reviewer)))) + .andExpect(status().isOk()).andExpect(jsonPath("$.providerConfigurationVerified").value(false)) + .andExpect(jsonPath("$.checks[0].passed").value(false)); + verify(bean(StripeReadinessService.class)).verifyProviderConfiguration(); + verify(bean(StripeReadinessService.class), never()).configuration(); + } + private static String costBody() { return "{\"balanceTransactionId\":\"txn_fixture\",\"expectedAccountId\":\"acct_fixture\",\"expectedTestMode\":true,\"reason\":\"Verified provider record\",\"recordedBy\":\"other\"}"; } + @Test void providerCostEvidenceListRequiresBrowserFinanceManager() throws Exception { + mvc.perform(browser(get(COSTS))).andExpect(status().isForbidden()); + mvc.perform(browser(get(COSTS)).with(authentication(session(owner)))).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(get(COSTS)).header("X-MODTALE-KEY", "fixture-key")).andExpect(status().isForbidden()); + verifyNoInteractions(bean(ProviderCostEvidenceService.class)); + when(bean(ProviderCostEvidenceService.class).list(reviewer)).thenReturn(List.of()); + mvc.perform(browser(get(COSTS)).with(authentication(session(reviewer)))).andExpect(status().isOk()); + verify(bean(ProviderCostEvidenceService.class)).list(reviewer); + } + @Test void costImportRequiresCsrfAndBindsAuthenticatedReviewer() throws Exception { + String path = COSTS + "/import-stripe"; + mvc.perform(browser(post(path)).with(authentication(session(reviewer))).contentType("application/json").content(costBody())).andExpect(status().isForbidden()); + mvc.perform(csrf(browser(post(path))).with(authentication(session(owner))).contentType("application/json").content(costBody())).andExpect(status().isForbidden()); + apiKey(reviewer); + mvc.perform(browser(post(path)).header("X-MODTALE-KEY", "fixture-key").contentType("application/json").content(costBody())).andExpect(status().isForbidden()); + verifyNoInteractions(bean(ProviderCostEvidenceService.class)); + mvc.perform(csrf(browser(post(path))).with(authentication(session(reviewer))).contentType("application/json").content(costBody())).andExpect(status().isOk()); + verify(bean(ProviderCostEvidenceService.class)).retrieveAndImport(reviewer, + new ProviderCostEvidenceService.ImportRequest("txn_fixture", "acct_fixture", true, "Verified provider record")); + verifyNoInteractions(bean(FinanceWalletService.class)); + } + @Test void malformedCostScopeAndMissingModeNeverReachImport() throws Exception { + for (String body : List.of(costBody().replace("txn_fixture", "txn_bad/path"), costBody().replace("acct_fixture", "acct_bad?query"), + costBody().replace("true", "null"), costBody().replace("Verified provider record", " "))) { + mvc.perform(csrf(browser(post(COSTS + "/import-stripe"))).with(authentication(session(reviewer))) + .contentType("application/json").content(body)).andExpect(status().isBadRequest()); + } + verifyNoInteractions(bean(ProviderCostEvidenceService.class)); + } + private static String event() { return "{\"id\":\"evt_fixture\",\"type\":\"checkout.session.completed\",\"livemode\":false,\"api_version\":\"" + StripeGatewayService.API_VERSION + "\",\"data\":{\"object\":{\"id\":\"cs_fixture\"}}}"; } + private static String signature(String body) throws Exception { + long now = Instant.now().getEpochSecond(); var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "t=" + now + ",v1=" + HexFormat.of().formatHex(mac.doFinal((now + "." + body).getBytes(StandardCharsets.UTF_8))); + } + @Test void signedWebhookIsCsrfExemptAndUsesExactRawHttpBody() throws Exception { + String body = event(); + mvc.perform(post(WEBHOOK).header("User-Agent", "Stripe/1.0").header("Stripe-Signature", signature(body)) + .contentType("application/json").content(body.getBytes(StandardCharsets.UTF_8))).andExpect(status().isOk()); + verify(bean(DonationCheckoutService.class)).handlePaidCheckout(anyMap()); + verify(bean(PaymentWebhookReceiptRepository.class)).insert(any(PaymentWebhookReceipt.class)); + } + @Test void unsignedOrTamperedWebhookNeverReachesAccountingEvenWithBrowserCsrf() throws Exception { + String body = event(); + mvc.perform(post(WEBHOOK).header("User-Agent", "Stripe/1.0").contentType("application/json").content(body)).andExpect(status().isBadRequest()); + mvc.perform(csrf(browser(post(WEBHOOK))).header("Stripe-Signature", signature(body)).contentType("application/json").content(body + " ")) + .andExpect(status().isBadRequest()); + verifyNoInteractions(bean(DonationCheckoutService.class), bean(PaymentWebhookReceiptRepository.class)); + } +} diff --git a/backend/src/test/java/net/modtale/controller/finance/StripeWebhookControllerTest.java b/backend/src/test/java/net/modtale/controller/finance/StripeWebhookControllerTest.java new file mode 100644 index 000000000..0a74b3392 --- /dev/null +++ b/backend/src/test/java/net/modtale/controller/finance/StripeWebhookControllerTest.java @@ -0,0 +1,60 @@ +package net.modtale.controller.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import net.modtale.repository.finance.PaymentWebhookReceiptRepository; +import net.modtale.service.finance.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class StripeWebhookControllerTest { + private DonationCheckoutService donations; + private PaymentWebhookReceiptRepository receipts; + private StripeGatewayService gateway; + private StripeWebhookController controller; + @BeforeEach void setup() { + donations = mock(DonationCheckoutService.class); receipts = mock(PaymentWebhookReceiptRepository.class); gateway = mock(StripeGatewayService.class); + when(gateway.isTestMode()).thenReturn(true); when(gateway.isReconciliationEnabled()).thenReturn(true); + when(gateway.getPlatformAccountId()).thenReturn("acct_platform"); + controller = new StripeWebhookController(donations, receipts, gateway, mock(RecurringSupportService.class), mock(PaymentAdjustmentService.class), "secret"); + } + private String signature(String body) throws Exception { + long time = Instant.now().getEpochSecond(); var mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec("secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "t=" + time + ",v1=" + HexFormat.of().formatHex(mac.doFinal((time + "." + body).getBytes(StandardCharsets.UTF_8))); + } + private String body(boolean live) { return "{\"id\":\"evt_test\",\"api_version\":\"" + StripeGatewayService.API_VERSION + "\",\"livemode\":" + live + ",\"type\":\"checkout.session.completed\",\"data\":{\"object\":{\"id\":\"cs_test\"}}}"; } + @Test void unsignedOrModifiedBodyCannotReachAccounting() throws Exception { + assertEquals(400, controller.receive(body(false).getBytes(StandardCharsets.UTF_8), null).getStatusCode().value()); + assertEquals(400, controller.receive((body(false) + " ").getBytes(StandardCharsets.UTF_8), signature(body(false))).getStatusCode().value()); + verifyNoInteractions(donations, receipts); + } + @Test void duplicateEventDoesNotRunFulfillmentTwice() throws Exception { + when(receipts.existsById("stripe:test:acct_platform:event:evt_test")).thenReturn(false, true); + String body = body(false); + assertEquals(200, controller.receive(body.getBytes(StandardCharsets.UTF_8), signature(body)).getStatusCode().value()); + assertEquals(200, controller.receive(body.getBytes(StandardCharsets.UTF_8), signature(body)).getStatusCode().value()); + verify(donations, times(1)).handlePaidCheckout(anyMap()); verify(receipts, times(1)).insert(any(net.modtale.model.finance.PaymentWebhookReceipt.class)); + } + @Test void eventReceiptsAreScopedToConfiguredProviderAccount() throws Exception { + String body = body(false); + controller.receive(body.getBytes(StandardCharsets.UTF_8), signature(body)); + verify(receipts).existsById("stripe:test:acct_platform:event:evt_test"); + when(gateway.getPlatformAccountId()).thenReturn("acct_other"); + controller.receive(body.getBytes(StandardCharsets.UTF_8), signature(body)); + verify(receipts).existsById("stripe:test:acct_other:event:evt_test"); + } + @Test void liveEventsFailClosedAndFailedFulfillmentIsNotAcknowledged() throws Exception { + String body = body(true); + assertEquals(503, controller.receive(body.getBytes(StandardCharsets.UTF_8), signature(body)).getStatusCode().value()); + verifyNoInteractions(receipts, donations); + doThrow(new IllegalArgumentException("not recorded yet")).when(donations).handlePaidCheckout(anyMap()); + body = body(false); + assertEquals(503, controller.receive(body.getBytes(StandardCharsets.UTF_8), signature(body)).getStatusCode().value()); + verify(receipts, never()).insert(any(net.modtale.model.finance.PaymentWebhookReceipt.class)); + } +} diff --git a/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java b/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java index 3781b58c0..7765a8299 100644 --- a/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java +++ b/backend/src/test/java/net/modtale/controller/project/VersionControllerTest.java @@ -206,7 +206,7 @@ void downloadWithTokenReturnsGeneratedModpackZipAndTracksDependencies() throws E ByteArrayResource body = assertInstanceOf(ByteArrayResource.class, response.getBody()); assertArrayEquals(new byte[]{9, 8, 7}, body.getByteArray()); - verify(trackingService).logDownload("project-1", "version-1", "Ada", false, "198.51.100.8", false); + verify(trackingService).logDownload("project-1", "version-1", "author-1", false, "198.51.100.8", false); verify(trackingService).logDownload("dep-1", null, null, false, "198.51.100.8", false); } @@ -239,7 +239,7 @@ void downloadWithTokenStripsGeneratedPrefixFromStoredFilenames() throws Exceptio assertArrayEquals(new byte[]{1, 2, 3, 4}, body.getByteArray()); verify(storageService).download(version.getFileUrl()); - verify(trackingService).logDownload("project-1", "version-1", "Ada", false, "203.0.113.5", false); + verify(trackingService).logDownload("project-1", "version-1", "author-1", false, "203.0.113.5", false); } @Test @@ -271,7 +271,7 @@ void authorizedDirectDownloadsRedirectWithoutProxyingFileBytes() throws Exceptio assertEquals("no-referrer", response.getHeaders().getFirst("Referrer-Policy")); org.junit.jupiter.api.Assertions.assertNull(response.getBody()); verify(storageService, never()).download(anyString()); - verify(trackingService).logDownload("project-1", "version-1", "Ada", false, "203.0.113.5", false); + verify(trackingService).logDownload("project-1", "version-1", "author-1", false, "203.0.113.5", false); } @Test @@ -309,8 +309,8 @@ void downloadBundleTracksOnlySelectedDependencies() throws Exception { ByteArrayResource body = assertInstanceOf(ByteArrayResource.class, response.getBody()); assertArrayEquals(new byte[]{6, 5, 4}, body.getByteArray()); - verify(trackingService).logDownload("project-1", "version-1", "Ada", true, "192.0.2.11", false); - verify(trackingService).logDownload("dep-b", null, "Ada", true, "192.0.2.11", false); + verify(trackingService).logDownload("project-1", "version-1", "author-1", true, "192.0.2.11", false); + verify(trackingService).logDownload("dep-b", null, "author-1", true, "192.0.2.11", false); verify(trackingService, never()).logDownload(eq("dep-a"), isNull(), isNull(), anyBoolean(), anyString(), anyBoolean()); verify(trackingService, never()).logDownload(eq("dep-c"), isNull(), isNull(), anyBoolean(), anyString(), anyBoolean()); } diff --git a/backend/src/test/java/net/modtale/service/analytics/TrackingFlushServiceTest.java b/backend/src/test/java/net/modtale/service/analytics/TrackingFlushServiceTest.java index 710af2e77..b8f6cc74a 100644 --- a/backend/src/test/java/net/modtale/service/analytics/TrackingFlushServiceTest.java +++ b/backend/src/test/java/net/modtale/service/analytics/TrackingFlushServiceTest.java @@ -2,6 +2,7 @@ import java.time.LocalDate; import net.modtale.model.analytics.PlatformMonthlyStats; +import net.modtale.model.analytics.ProjectMonthlyStats; import net.modtale.service.project.query.ProjectService; import org.bson.Document; import org.junit.jupiter.api.Test; @@ -37,5 +38,10 @@ void persistsExclusiveSourcesEvenWhenLauncherHasApiRole() { assertEquals(1, increments.get("frontendDownloads")); assertEquals(2, increments.get("launcherDownloads")); assertEquals(2, increments.get("days." + LocalDate.now().getDayOfMonth() + ".l")); + var projectUpdate = ArgumentCaptor.forClass(Update.class); + verify(mongo).upsert(any(Query.class), projectUpdate.capture(), eq(ProjectMonthlyStats.class)); + Document projectIncrements = (Document) projectUpdate.getValue().getUpdateObject().get("$inc"); + assertEquals(2, projectIncrements.get("launcherDownloads")); + assertEquals(2, projectIncrements.get("days." + LocalDate.now().getDayOfMonth() + ".l")); } } diff --git a/backend/src/test/java/net/modtale/service/finance/AdSettlementStagingIntegrationTest.java b/backend/src/test/java/net/modtale/service/finance/AdSettlementStagingIntegrationTest.java new file mode 100644 index 000000000..cbb88502d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/AdSettlementStagingIntegrationTest.java @@ -0,0 +1,66 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import net.modtale.model.dto.request.finance.StageAdSettlementRequest; +import net.modtale.model.finance.*; +import net.modtale.model.user.AdminPermission; +import net.modtale.model.user.User; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; + +@EnabledIfEnvironmentVariable(named = "FINANCE_TEST_MONGO_URI", matches = ".+") +class AdSettlementStagingIntegrationTest { + private MongoClient client; private MongoTemplate mongo; private AdSettlementStagingService service; + private User reviewer; private StageAdSettlementRequest report; + @BeforeEach void setup() { + client = MongoClients.create(System.getenv("FINANCE_TEST_MONGO_URI")); + var factory = new SimpleMongoClientDatabaseFactory(client, "ad_stage_test_" + UUID.randomUUID().toString().replace("-", "")); + mongo = new MongoTemplate(factory); mongo.createCollection(AdSettlementStage.class); mongo.createCollection(AdSettlementDepositClaim.class); + var activity = mock(AdSettlementActivityService.class); + when(activity.snapshot(any(), any())).thenReturn(List.of( + new AdSettlementStage.Activity("a", "owner-a", "Alpha", 10, 5, 0, 100, 15, 0, "Provisional"), + new AdSettlementStage.Activity("b", "owner-b", "Beta", 20, 10, 0, 100, 30, 0, "Provisional"))); + service = new AdSettlementStagingService(mongo, factory, activity); + reviewer = new User(); reviewer.setId("reviewer"); reviewer.setAdminPermissions(Set.of(AdminPermission.PLATFORM_FINANCE_MANAGE)); + report = new StageAdSettlementRequest("provider", "account", "report-1", "deposit-1", "usd", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), 10001, "a".repeat(64)); + } + @AfterEach void cleanup() { if (mongo != null) mongo.getDb().drop(); if (client != null) client.close(); } + @Test void stagingDeduplicatesSourcesAndNeverFundsAWallet() { + var stage = service.create(reviewer, report); assertEquals(stage.id(), service.create(reviewer, report).id()); + var snapshot = stage.snapshots().getFirst(); + assertEquals(7501, snapshot.provisionalCreatorPoolCents()); assertEquals(2500, snapshot.provisionalPlatformCents()); + assertEquals(7501, snapshot.projects().stream().mapToLong(AdSettlementStage.Activity::provisionalCreatorCents).sum()); + assertThrows(IllegalArgumentException.class, () -> service.create(reviewer, changedReport("report-other", 10001))); + assertThrows(IllegalArgumentException.class, () -> service.create(reviewer, changedReport("report-1", 9999))); + assertEquals(0, mongo.getCollection("creator_wallets").countDocuments()); + assertEquals(0, mongo.getCollection("finance_ledger_entries").countDocuments()); + assertEquals(1, mongo.getCollection("ad_settlement_stages").countDocuments()); + } + @Test void reviewedSnapshotsSurviveExplicitAmendmentAndReviewReplays() { + var stage = service.create(reviewer, report); + var review = new AdSettlementStagingService.Review(1, UUID.randomUUID().toString(), "REVIEWED_PROVISIONAL", "Inputs reviewed; provider verification still required."); + stage = service.review(reviewer, stage.id(), review); service.review(reviewer, stage.id(), review); + assertEquals(2, stage.audit().size()); + var amendment = new AdSettlementStagingService.Amendment(1, UUID.randomUUID().toString(), changedReport("report-1", 12000), "Provider report was corrected."); + stage = service.amend(reviewer, stage.id(), amendment); service.amend(reviewer, stage.id(), amendment); + assertEquals(2, stage.revision()); assertEquals(2, stage.snapshots().size()); + assertEquals(10001, stage.snapshots().getFirst().reportedCollectedCents()); + assertEquals(12000, stage.snapshots().getLast().reportedCollectedCents()); + assertEquals("AWAITING_REVIEW", stage.status()); assertEquals(3, stage.audit().size()); + final String id = stage.id(); + assertThrows(IllegalArgumentException.class, () -> service.review(reviewer, id, new AdSettlementStagingService.Review(2, UUID.randomUUID().toString(), "FUNDED", "Do not allow this."))); + assertEquals(0, mongo.getCollection("creator_wallets").countDocuments()); + } + private StageAdSettlementRequest changedReport(String id, long cents) { + return new StageAdSettlementRequest(report.provider(), report.providerAccount(), id, report.depositId(), report.currency(), report.from(), report.through(), cents, report.reportSha256()); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/AdSettlementStagingTest.java b/backend/src/test/java/net/modtale/service/finance/AdSettlementStagingTest.java new file mode 100644 index 000000000..b6e871d76 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/AdSettlementStagingTest.java @@ -0,0 +1,66 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.time.LocalDate; +import java.util.List; +import java.util.Set; +import net.modtale.model.dto.request.finance.StageAdSettlementRequest; +import net.modtale.model.project.Project; +import net.modtale.model.user.AdminPermission; +import net.modtale.model.user.User; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import org.bson.Document; +import org.junit.jupiter.api.Test; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.query.Query; +import org.springframework.test.util.ReflectionTestUtils; + +class AdSettlementStagingTest { + @Test void financeReviewPermissionDoesNotGrantCreatorPolicyOwnership() { + var reviewer = new User(); reviewer.setId("reviewer"); reviewer.setAdminPermissions(Set.of(AdminPermission.PLATFORM_FINANCE_MANAGE)); + var owner = new User(); owner.setId("owner"); var project = new Project(); project.setAuthorId("owner"); + assertDoesNotThrow(() -> AdSettlementStagingService.requireReviewer(reviewer)); + assertThrows(SecurityException.class, () -> AdSettlementStagingService.requireReviewer(owner)); + assertThrows(SecurityException.class, () -> AdSettlementStagingService.requireReviewer(null)); + var core = new RevenueOpsSupport(); var users = mock(UserRepository.class); + when(users.findById("owner")).thenReturn(java.util.Optional.of(owner)); + ReflectionTestUtils.setField(core, "userRepository", users); + assertDoesNotThrow(() -> core.requireProjectMonetizationOwner(owner, project)); + assertThrows(SecurityException.class, () -> core.requireProjectMonetizationOwner(reviewer, project)); + var organization = new User(); organization.setAccountType(User.AccountType.ORGANIZATION); + assertThrows(SecurityException.class, () -> core.requireOrganizationOwner(reviewer, organization)); + var member = new User.OrganizationMember("reviewer", "editor"); member.setRole("OWNER"); + organization.setOrganizationMembers(List.of(member)); + organization.setOrganizationRoles(List.of(new User.OrganizationRole("editor", "Editor", "blue", Set.of()))); + assertThrows(SecurityException.class, () -> core.requireOrganizationOwner(reviewer, organization)); + var ownerRole = new User.OrganizationRole("owner", "Owner", "blue", Set.of()); ownerRole.setOwner(true); + organization.setOrganizationRoles(List.of(ownerRole)); member.setRoleId("owner"); + assertDoesNotThrow(() -> core.requireOrganizationOwner(reviewer, organization)); + } + @Test void reportValidationRequiresClosedDatesAndNonSecretIdentities() { + var valid = new StageAdSettlementRequest("provider", "publisher-1", "report-1", "deposit-1", "usd", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), 10001, "a".repeat(64)); + assertDoesNotThrow(() -> AdSettlementStagingService.validate(valid)); + assertEquals(AdSettlementStagingService.digest(valid), AdSettlementStagingService.digest(valid)); + assertThrows(IllegalArgumentException.class, () -> AdSettlementStagingService.validate(new StageAdSettlementRequest("https://provider.example", "x", "r", "d", "usd", valid.from(), valid.through(), 100, valid.reportSha256()))); + assertThrows(IllegalArgumentException.class, () -> AdSettlementStagingService.validate(new StageAdSettlementRequest("provider", "x", "r", "d", "usd", valid.from(), LocalDate.now().plusDays(1), 100, valid.reportSha256()))); + } + @Test void activityExcludesClicksGenericApiDownloadsAndUncertainHistoricOwners() { + var mongo = mock(MongoTemplate.class); var projects = mock(ProjectRepository.class); var users = mock(UserRepository.class); + var service = new AdSettlementActivityService(mongo, projects, users); + var owner = new User(); owner.setId("owner"); + var eligible = new Project(); eligible.setId("eligible"); eligible.setAuthorId("owner"); eligible.setAdsEnabled(true); + var legacy = new Project(); legacy.setId("legacy"); legacy.setAuthorId("owner"); legacy.setAdsEnabled(true); + when(projects.findAllById(any())).thenReturn(List.of(eligible, legacy)); when(users.findAllById(any())).thenReturn(List.of(owner)); + var counts = new Document("v", 10).append("l", 3).append("a", 9999).append("f", 500).append("clicks", 9000); + when(mongo.find(any(Query.class), eq(Document.class), eq("project_monthly_stats"))).thenReturn(List.of( + new Document("projectId", "eligible").append("authorId", "owner").append("year", 2026).append("month", 8).append("days", new Document("2", counts)), + new Document("projectId", "legacy").append("authorId", "display-name").append("year", 2026).append("month", 8).append("days", new Document("2", counts)))); + var rows = service.snapshot(LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31)); + assertEquals(13, rows.getFirst().provisionalPoints()); + assertEquals(0, rows.getLast().provisionalPoints()); + assertTrue(rows.getLast().eligibilityNote().contains("historical owner")); + assertTrue(rows.stream().allMatch(row -> row.provisionalCreatorCents() == 0)); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/CreatorPayoutServiceTest.java b/backend/src/test/java/net/modtale/service/finance/CreatorPayoutServiceTest.java new file mode 100644 index 000000000..bb78957a9 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/CreatorPayoutServiceTest.java @@ -0,0 +1,180 @@ +package net.modtale.service.finance; + +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import net.modtale.model.finance.CreatorPayoutRequest; +import net.modtale.model.finance.CreatorWallet; +import net.modtale.repository.user.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class CreatorPayoutServiceTest { + private FinanceWalletService wallets; + private StripeGatewayService gateway; + private CreatorPayoutService service; + private CreatorPayoutRequest request; + @BeforeEach void setUp() { + wallets = mock(FinanceWalletService.class); gateway = mock(StripeGatewayService.class); + service = new CreatorPayoutService(wallets, gateway, mock(UserRepository.class), new RevenueOpsSupport()); + request = new CreatorPayoutRequest(); request.setId("test:creator:usd:key"); request.setCreatorId("creator"); request.setCurrency("usd"); request.setTestMode(true); + request.setProviderAccountId("acct_platform"); request.setTransferGroup("group_fixture"); + when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(true); + request.setStatus(CreatorPayoutRequest.Status.PROCESSING); request.setFirstAttemptAt(Instant.now()); + var recipient = new CreatorPayoutRequest.Recipient(); recipient.setAccountId("acct_recipient"); recipient.setAmountCents(1000); recipient.setCorrelationId("nonce_fixture"); recipient.setAuthorizedAt(Instant.now()); request.setRecipients(List.of(recipient)); + when(gateway.isTestMode()).thenReturn(true); when(gateway.isOperational()).thenReturn(true); when(wallets.authorizeRecipientTransfer(anyString(), anyInt())).thenReturn(true); when(wallets.getRequest(request.getId())).thenReturn(request); + when(wallets.getWallet("creator", "usd", true)).thenReturn(new CreatorWallet()); + } + @Test void reviewQueueRemainsVisibleWhileProviderAccessIsUnavailable() { + when(gateway.isReconciliationEnabled()).thenReturn(false); when(wallets.getReviewRequests()).thenReturn(List.of(request)); + assertEquals(List.of(request), service.getReviewRequests()); verify(gateway, never()).getPlatformAccountId(); + } + @Test void missingOrFractionalTransferNumbersCannotPassVerification() { + var transfer = new java.util.HashMap<String, Object>(); transfer.put("id", "tr_verified"); transfer.put("object", "transfer"); + transfer.put("livemode", false); transfer.put("destination", "acct_recipient"); transfer.put("currency", "usd"); transfer.put("amount", 1000); + transfer.put("amount_reversed", 0); transfer.put("reversed", false); transfer.put("created", Instant.now().getEpochSecond()); + transfer.put("transfer_group", request.getTransferGroup()); transfer.put("metadata", CreatorPayoutService.transferMetadata(request, 0)); + assertTrue(CreatorPayoutService.matchesTransfer(request, 0, "tr_verified", transfer, Instant.now())); + for (String field : List.of("amount", "amount_reversed", "created", "livemode", "reversed", "metadata")) { + var missing = new java.util.HashMap<>(transfer); missing.remove(field); + assertFalse(CreatorPayoutService.matchesTransfer(request, 0, "tr_verified", missing, Instant.now()), field); + } + for (String field : List.of("amount", "amount_reversed", "created")) { + for (Object invalid : List.of(0.5, "0", java.math.BigInteger.ONE.shiftLeft(80))) { + var changed = new java.util.HashMap<>(transfer); changed.put(field, invalid); + assertFalse(CreatorPayoutService.matchesTransfer(request, 0, "tr_verified", changed, Instant.now()), field + ":" + invalid); + } + } + } + @Test void aMalformedRequestDoesNotStopLaterScheduledRequests() { + var next = new CreatorPayoutRequest(); next.setId("next"); next.setTestMode(true); + when(wallets.getUnfinishedRequests(true)).thenReturn(List.of(request, next)); + var dispatcher = spy(service); doThrow(new IllegalArgumentException("legacy data")).when(dispatcher).dispatch(request.getId()); + doNothing().when(dispatcher).dispatch("next"); dispatcher.dispatchReservedPayouts(); + verify(wallets).requireReview(eq(request.getId()), anyString()); verify(dispatcher).dispatch("next"); + } + @Test void uncertainProviderResponseKeepsFundsReservedAndSameRetryKey() { + when(gateway.createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), eq(false), anyString())) + .thenReturn(new StripeGatewayService.StripeResult(false, null, null, "timeout", Map.of())); + service.dispatch(request.getId()); service.dispatch(request.getId()); + verify(gateway, times(2)).createTransfer(eq("acct_recipient"), eq(1000L), eq("usd"), anyString(), anyMap(), eq(false), eq("modtale-payout:test:creator:usd:key:0")); + verify(wallets, never()).completeTransfers(anyString()); + } + @Test void doesNotResendAnAlreadyConfirmedRecipient() { + request.getRecipients().getFirst().setTransferId("tr_confirmed"); + service.dispatch(request.getId()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + verify(wallets).completeTransfers(request.getId()); + } + @Test void confirmationBeforeAuthorizationDoesNotBlockTheNextOrganizationRecipient() { + addRemainingRecipient(); + when(wallets.authorizeRecipientTransfer(request.getId(), 0)).thenAnswer(call -> { + request.getRecipients().getFirst().setTransferId("tr_competing"); + return false; + }); + when(gateway.createTransfer(eq("acct_remaining"), eq(500L), eq("usd"), anyString(), anyMap(), eq(false), anyString())) + .thenReturn(new StripeGatewayService.StripeResult(true, "tr_remaining", null, null, transferResponse(1, "tr_remaining"))); + service.dispatch(request.getId()); + verify(wallets, never()).requireReview(anyString(), anyString()); + verify(wallets).authorizeRecipientTransfer(request.getId(), 1); + verify(gateway).createTransfer(eq("acct_remaining"), eq(500L), eq("usd"), anyString(), anyMap(), eq(false), + eq("modtale-payout:" + request.getId() + ":1")); + verify(gateway, never()).createTransfer(eq("acct_recipient"), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + verify(wallets).recordTransfer(eq(request.getId()), eq(1), eq("tr_remaining"), anyString(), anyString()); + } + @Test void confirmationAfterAuthorizationStillAllowsTheRemainingOrganizationRecipient() { + addRemainingRecipient(); + when(wallets.authorizeRecipientTransfer(request.getId(), 0)).thenAnswer(call -> { + request.getRecipients().getFirst().setTransferId("tr_competing"); + return true; + }); + when(gateway.createTransfer(eq("acct_remaining"), eq(500L), eq("usd"), anyString(), anyMap(), eq(false), anyString())) + .thenReturn(new StripeGatewayService.StripeResult(true, "tr_remaining", null, null, transferResponse(1, "tr_remaining"))); + service.dispatch(request.getId()); + verify(wallets).authorizeRecipientTransfer(request.getId(), 1); + verify(gateway, never()).createTransfer(eq("acct_recipient"), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + verify(wallets).recordTransfer(eq(request.getId()), eq(1), eq("tr_remaining"), anyString(), anyString()); + } + @Test void confirmedRecipientDoesNotBypassTheRemainingRecipientsRiskAuthorization() { + addRemainingRecipient(); + when(wallets.authorizeRecipientTransfer(request.getId(), 0)).thenAnswer(call -> { + request.getRecipients().getFirst().setTransferId("tr_competing"); + return false; + }); + when(wallets.authorizeRecipientTransfer(request.getId(), 1)).thenReturn(false); + service.dispatch(request.getId()); + verify(wallets).authorizeRecipientTransfer(request.getId(), 1); + verify(wallets).requireRecipientReview(eq(request.getId()), eq(1), anyString()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + verify(wallets, never()).completeTransfers(anyString()); + } + @Test void confirmationAfterAuthorizationDoesNotResendTheRecipient() { + when(gateway.createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString())) + .thenReturn(new StripeGatewayService.StripeResult(false, null, null, "fixture", Map.of())); + when(wallets.authorizeRecipientTransfer(request.getId(), 0)).thenAnswer(call -> { + request.getRecipients().getFirst().setTransferId("tr_competing"); + request.setStatus(CreatorPayoutRequest.Status.TRANSFERRED); + return true; + }); + service.dispatch(request.getId()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + verify(wallets, never()).requireReview(anyString(), anyString()); + } + @Test void reviewAfterAuthorizationStopsTheOutboundCall() { + when(gateway.createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString())) + .thenReturn(new StripeGatewayService.StripeResult(false, null, null, "fixture", Map.of())); + when(wallets.authorizeRecipientTransfer(request.getId(), 0)).thenAnswer(call -> { + request.setStatus(CreatorPayoutRequest.Status.REQUIRES_REVIEW); + return true; + }); + service.dispatch(request.getId()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + } + private void addRemainingRecipient() { + var remaining = new CreatorPayoutRequest.Recipient(); remaining.setAccountId("acct_remaining"); + remaining.setAmountCents(500); remaining.setCorrelationId("nonce_remaining"); remaining.setAuthorizedAt(Instant.now()); + request.setRecipients(List.of(request.getRecipients().getFirst(), remaining)); + } + private Map<String, Object> transferResponse(int index, String id) { + var recipient = request.getRecipients().get(index); var transfer = new java.util.HashMap<String, Object>(); + transfer.put("id", id); transfer.put("object", "transfer"); transfer.put("livemode", false); + transfer.put("destination", recipient.getAccountId()); transfer.put("amount", recipient.getAmountCents()); transfer.put("currency", request.getCurrency()); + transfer.put("transfer_group", request.getTransferGroup()); transfer.put("metadata", CreatorPayoutService.transferMetadata(request, index)); + transfer.put("created", Instant.now().getEpochSecond()); transfer.put("reversed", false); transfer.put("amount_reversed", 0); + return transfer; + } + @Test void staleIdempotencyWindowRequiresReviewWithoutRetry() { + request.setFirstAttemptAt(Instant.now().minusSeconds(24 * 3600)); + service.dispatch(request.getId()); + verify(wallets).requireReview(eq(request.getId()), anyString()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + } + @Test void mismatchedSuccessfulTransferIsNotMarkedComplete() { + when(gateway.createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), eq(false), anyString())) + .thenReturn(new StripeGatewayService.StripeResult(true, "tr_wrong", null, null, Map.of("destination", "acct_other", "amount", 1000, "currency", "usd"))); + service.dispatch(request.getId()); + verify(wallets).requireReview(eq(request.getId()), anyString()); verify(wallets, never()).completeTransfers(anyString()); + } + @Test void accountHoldStopsDispatch() { + var wallet = new CreatorWallet(); wallet.setPayoutHold(true); when(wallets.getWallet("creator", "usd", true)).thenReturn(wallet); + service.dispatch(request.getId()); + verify(wallets).requireReview(eq(request.getId()), anyString()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + } + @Test void openRiskIdsAreIncludedInPayoutHold() { + var wallet = new CreatorWallet(); wallet.setOpenRiskIds(List.of("dp_late")); + when(wallets.getWallet("creator", "usd", true)).thenReturn(wallet); + service.dispatch(request.getId()); + verify(wallets).requireReview(eq(request.getId()), anyString()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + } + @Test void failedTransactionalAuthorizationStopsTheOutboundCall() { + when(wallets.authorizeRecipientTransfer(anyString(), anyInt())).thenReturn(false); + service.dispatch(request.getId()); + verify(wallets).requireRecipientReview(eq(request.getId()), eq(0), anyString()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/finance/DisputeEvidenceTest.java b/backend/src/test/java/net/modtale/service/finance/DisputeEvidenceTest.java new file mode 100644 index 000000000..56bc520d4 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/DisputeEvidenceTest.java @@ -0,0 +1,29 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.*; +import org.junit.jupiter.api.Test; + +class DisputeEvidenceTest { + private Map<String, Object> balance(String id, long amount, long fee) { return Map.of("id", id, "source", "dp_case", "type", "adjustment", "currency", "usd", "status", "available", "amount", amount, "fee", fee, "net", amount - fee); } + private DisputeEvidence read(Object balances) { return DisputeEvidence.read("acct_platform", true, "dp_case", "ch_source", "usd", 10000, "won", balances); } + @Test void deduplicatesBySourceIdAndSeparatesPrincipalFromFees() { + var loss = balance("txn_loss", -10000, 1500); var returned = balance("txn_return", 10000, -1500); + var evidence = read(List.of(loss, loss, returned)); assertTrue(evidence.ready()); assertEquals(0, evidence.principalMovementCents()); + assertEquals(0, evidence.actualFeeCents()); assertEquals(10000, evidence.returnedPrincipalCents()); assertEquals(2, evidence.balances().size()); + assertEquals(evidence.digest(), read(List.of(returned, loss)).digest()); + } + @Test void conflictingDuplicatesAndUnlinkedOrUnsettledMovementsCannotAuthorizeRelease() { + var valid = balance("txn_loss", -10000, 1500); + assertFalse(read(List.of(valid, balance("txn_loss", -9999, 1500))).ready()); + for (var change : List.of(Map.<String,Object>of("source", "dp_other"), Map.<String,Object>of("type", "charge"), Map.<String,Object>of("currency", "eur"), Map.<String,Object>of("status", "pending"), + Map.<String,Object>of("net", -10000), Map.<String,Object>of("fee", 1.5), Map.<String,Object>of("amount", "-10000"), Map.<String,Object>of("id", ""))) { + var altered = new HashMap<>(valid); altered.putAll(change); assertFalse(read(List.of(altered)).ready(), change.toString()); + } + } + @Test void missingEvidenceIsDifferentFromACompleteEmptyNonFinancialWarning() { + assertFalse(read(null).ready()); assertFalse(read(Map.of()).ready()); assertTrue(read(List.of()).ready()); + var invalid = new HashMap<>(balance("txn_loss", -10000, 1500)); invalid.remove("fee"); + assertFalse(read(List.of(invalid)).ready()); assertNull(read(List.of(invalid)).actualFeeCents()); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/DisputeResolutionIntegrationTest.java b/backend/src/test/java/net/modtale/service/finance/DisputeResolutionIntegrationTest.java new file mode 100644 index 000000000..7657d549f --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/DisputeResolutionIntegrationTest.java @@ -0,0 +1,106 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import net.modtale.model.finance.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "FINANCE_TEST_MONGO_URI", matches = ".+") +class DisputeResolutionIntegrationTest extends FinancePipelineFixture { + private Map<String, Object> balance(String id, String disputeId, long amount, long fee) { + return Map.of("id", id, "source", disputeId, "type", "adjustment", "currency", "usd", "status", "available", "amount", amount, "fee", fee, "net", amount - fee); + } + private Map<String, Object> dispute(String id, String status, List<?> balances) { + return Map.of("id", id, "charge", "ch_pi_first", "currency", "usd", "livemode", false, "amount", 10000, "status", status, "balance_transactions", balances); + } + private FinanceDisputeCase current(String id) { return mongo.findById(FinanceSourceKey.stripe(true, "acct_platform", "dispute-case:" + id), FinanceDisputeCase.class); } + private void refresh(String id, String status, List<?> balances) throws Exception { + when(gateway.getDispute(id)).thenReturn(dispute(id, status, balances)); + assertEquals(200, event("evt_" + UUID.randomUUID(), "charge.dispute.updated", Map.of("id", id, "charge", "ch_pi_first"))); + } + private FinanceDisputeResolution resolve(String id, long fee) { + when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(true); + var review = current(id); return adjustments.resolveCase(review.id(), review.evidenceDigest(), fee, donor, "Explicit policy review using verified provider costs"); + } + @Test void lossDoesNotAutomaticallyChargeDiscretionaryFeesAndReviewIsIdempotent() throws Exception { + settleOneTime(); wallets.holdForRisk("creator", "usd", true, "unrelated-review"); + var loss = balance("txn_loss", "dp_case", -10000, 1500); refresh("dp_case", "lost", List.of(loss, loss)); + assertEquals(-321, available()); assertEquals(1500, current("dp_case").actualFeeCents()); + resolve("dp_case", 1000); assertEquals(-1321, available()); resolve("dp_case", 1000); assertEquals(-1321, available()); + assertEquals(List.of("unrelated-review"), wallets.getWallet("creator", "usd", true).getOpenRiskIds()); + assertEquals(1, mongo.getCollection("finance_dispute_resolutions").countDocuments()); + var fee = ledger.findAll().stream().filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT).findFirst().orElseThrow(); + assertEquals(-1000, fee.getCreatorCents()); assertEquals(-500, fee.getPlatformCents()); assertEquals(1500, fee.getProcessorFeeCents()); + } + @Test void lateWinRestoresOnlyProvenPrincipalAndActualReturnedFeesWithoutRewritingLoss() throws Exception { + settleOneTime(); var loss = balance("txn_loss", "dp_case", -10000, 1500); refresh("dp_case", "lost", List.of(loss)); + resolve("dp_case", 1500); assertEquals(-1821, available()); + refresh("dp_case", "won", List.of(loss, balance("txn_return", "dp_case", 10000, -1500))); + assertEquals(-1821, available()); assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + resolve("dp_case", 0); assertEquals(8445, available()); assertFalse(wallets.getWallet("creator", "usd", true).isPayoutHold()); + event("evt_old_payload", "charge.dispute.closed", Map.of("id", "dp_case", "charge", "ch_pi_first", "status", "lost")); + resolve("dp_case", 0); assertEquals(8445, available()); + assertEquals(1, ledger.findAll().stream().filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_REVERSAL).count()); + assertEquals(-10000, ledger.findById(FinanceSourceKey.stripe(true, "acct_platform", "dispute-principal:dp_case")).orElseThrow().getGrossCents()); + assertEquals(2, mongo.getCollection("finance_dispute_resolutions").countDocuments()); + } + @Test void wonStatusWithoutLinkedPrincipalReturnCannotReleaseOrRestoreFunds() throws Exception { + settleOneTime(); refresh("dp_case", "lost", List.of(balance("txn_loss", "dp_case", -10000, 1500))); + refresh("dp_case", "won", List.of()); assertFalse(current("dp_case").evidenceReady()); + assertThrows(IllegalArgumentException.class, () -> resolve("dp_case", 0)); assertEquals(-321, available()); + assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + } + @Test void feeBoundsStaleEvidenceAndDifferentPriorDecisionAreRejected() throws Exception { + settleOneTime(); refresh("dp_case", "lost", List.of(balance("txn_loss", "dp_case", -10000, 1500))); + assertThrows(IllegalArgumentException.class, () -> resolve("dp_case", 1501)); assertThrows(IllegalArgumentException.class, () -> resolve("dp_case", -1)); + var review = current("dp_case"); assertThrows(IllegalArgumentException.class, () -> adjustments.resolveCase(review.id(), "0".repeat(64), 0, donor, "Stale review")); + assertEquals(-321, available()); resolve("dp_case", 0); assertThrows(IllegalArgumentException.class, () -> resolve("dp_case", 1)); assertEquals(-321, available()); + } + @Test void warningAndPreventedCasesClearOnlyTheReviewedDispute() throws Exception { + settleOneTime(); wallets.holdForRisk("creator", "usd", true, "unrelated-review"); + refresh("dp_warning", "warning_closed", List.of()); resolve("dp_warning", 0); + refresh("dp_prevented", "prevented", List.of()); resolve("dp_prevented", 0); + assertEquals(8445, available()); assertEquals(List.of("unrelated-review"), wallets.getWallet("creator", "usd", true).getOpenRiskIds()); + refresh("dp_unknown", "future_status", List.of()); assertThrows(IllegalArgumentException.class, () -> resolve("dp_unknown", 0)); + } + @Test void genericHistoricalChargeHoldNeedsEveryPaginatedDisputeReviewed() throws Exception { + settleOneTime(); wallets.holdForRisk("creator", "usd", true, "ch_pi_first"); wallets.holdForRisk("creator", "usd", true, "unrelated-review"); + when(gateway.getCharge("ch_pi_first")).thenReturn(Map.of("id", "ch_pi_first", "livemode", false, "currency", "usd", "amount_refunded", 0, "disputed", true)); + when(gateway.getChargeRefunds("ch_pi_first", null)).thenReturn(Map.of("has_more", false, "data", List.of())); + var one = dispute("dp_one", "warning_closed", List.of()); var two = dispute("dp_two", "prevented", List.of()); + when(gateway.getDispute("dp_one")).thenReturn(one); when(gateway.getDispute("dp_two")).thenReturn(two); + when(gateway.getChargeDisputes("ch_pi_first", null)).thenReturn(Map.of("has_more", true, "data", List.of(one))); + when(gateway.getChargeDisputes("ch_pi_first", "dp_one")).thenReturn(Map.of("has_more", false, "data", List.of(two))); + adjustments.synchronizeCharge("ch_pi_first"); resolve("dp_one", 0); + assertTrue(wallets.getWallet("creator", "usd", true).getOpenRiskIds().contains("ch_pi_first")); + assertTrue(wallets.getWallet("creator", "usd", true).getOpenRiskIds().contains("dp_two")); + resolve("dp_two", 0); assertEquals(List.of("unrelated-review"), wallets.getWallet("creator", "usd", true).getOpenRiskIds()); + } + @Test void aNewObservationFencesAnOlderReviewUntilCanonicalEvidenceIsSaved() throws Exception { + settleOneTime(); refresh("dp_case", "warning_closed", List.of()); var previous = current("dp_case"); + wallets.beginDisputeRefresh(previous.id(), previous.disputeId(), previous.chargeId(), credit("pi_first"), true); + assertThrows(IllegalArgumentException.class, () -> wallets.resolveDispute(previous.id(), previous.evidenceDigest(), 0, "reviewer", "Old evidence")); + assertThrows(org.springframework.dao.OptimisticLockingFailureException.class, () -> mongo.save(previous)); + assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); assertEquals(8445, available()); + } + @Test void explicitProviderRefreshValidatesAccountAndModeAndNeverAllocatesFees() throws Exception { + settleOneTime(); var pending = new HashMap<>(balance("txn_loss", "dp_case", -10000, 1500)); pending.put("status", "pending"); + refresh("dp_case", "lost", List.of(pending)); var review = current("dp_case"); clearInvocations(gateway); + when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(false); + assertThrows(IllegalArgumentException.class, () -> adjustments.refreshCase(review.id())); verify(gateway, never()).getDispute("dp_case"); + when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(true); when(gateway.isTestMode()).thenReturn(false); + assertThrows(IllegalArgumentException.class, () -> adjustments.refreshCase(review.id())); + when(gateway.isTestMode()).thenReturn(true); when(gateway.getDispute("dp_case")).thenReturn(dispute("dp_case", "lost", List.of(balance("txn_loss", "dp_case", -10000, 1500)))); + assertEquals("POLICY_REVIEW_REQUIRED", adjustments.refreshCase(review.id()).reviewStatus()); assertEquals(-321, available()); + assertEquals(0, mongo.getCollection("finance_dispute_resolutions").countDocuments()); + assertEquals(0, ledger.findAll().stream().filter(entry -> entry.getType() == FinanceLedgerEntry.LedgerType.DISPUTE_FEE_ADJUSTMENT).count()); + } + @Test void invalidSourceOrPendingBalanceCannotBookALoss() throws Exception { + settleOneTime(); var pending = new HashMap<>(balance("txn_loss", "dp_case", -10000, 1500)); pending.put("status", "pending"); + refresh("dp_case", "lost", List.of(pending)); assertEquals(8445, available()); + refresh("dp_case", "lost", List.of(balance("txn_wrong", "dp_other", -10000, 1500))); assertEquals(8445, available()); + assertThrows(IllegalArgumentException.class, () -> resolve("dp_case", 0)); assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/EarningsReadModelTest.java b/backend/src/test/java/net/modtale/service/finance/EarningsReadModelTest.java new file mode 100644 index 000000000..1e3684649 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/EarningsReadModelTest.java @@ -0,0 +1,103 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import net.modtale.model.finance.CreatorWallet; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.finance.PlatformFinanceSettings; +import net.modtale.model.user.User; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import net.modtale.repository.finance.PlatformFinanceSettingsRepository; +import net.modtale.repository.project.ProjectRepository; +import net.modtale.repository.user.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +class EarningsReadModelTest { + private EarningsAccountService service; + private FinanceLedgerEntryRepository ledger; + private FinanceWalletService wallets; + private StripeGatewayService gateway; + private User creator; + @BeforeEach void setup() { + service = new EarningsAccountService(); + ledger = mock(FinanceLedgerEntryRepository.class); wallets = mock(FinanceWalletService.class); gateway = mock(StripeGatewayService.class); + var settingsRepository = mock(PlatformFinanceSettingsRepository.class); + var settings = new PlatformFinanceSettings(); settings.setMonetizationPolicyVersion(2); + when(settingsRepository.findById("platform")).thenReturn(Optional.of(settings)); + var core = mock(RevenueOpsSupport.class); creator = new User(); creator.setId("creator"); + when(core.resolveFinanceOwner(any(), any(), eq(false))).thenReturn(creator); + when(core.parseRangeDays(any())).thenReturn(30); + ReflectionTestUtils.setField(service, "ledgerRepository", ledger); + ReflectionTestUtils.setField(service, "settingsRepository", settingsRepository); + ReflectionTestUtils.setField(service, "wallets", wallets); + ReflectionTestUtils.setField(service, "stripeGatewayService", gateway); + ReflectionTestUtils.setField(service, "core", core); + ReflectionTestUtils.setField(service, "projectRepository", mock(ProjectRepository.class)); + ReflectionTestUtils.setField(service, "userRepository", mock(UserRepository.class)); + ReflectionTestUtils.setField(service, "creatorCountries", "US"); + var live = new CreatorWallet(); live.setAvailableCents(1500); + var test = new CreatorWallet(); test.setAvailableCents(-45); test.setReservedCents(1000); test.setPayoutHold(true); + when(wallets.getWallet("creator", "usd", false)).thenReturn(live); + when(wallets.getWallet("creator", "usd", true)).thenReturn(test); + } + private FinanceLedgerEntry entry(boolean testMode, FinanceLedgerEntry.LedgerType type, long cents) { + var entry = new FinanceLedgerEntry(); entry.setType(type); entry.setCurrency("usd"); entry.setCreatorCents(cents); + entry.setCreatedAt(LocalDateTime.now()); entry.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); + entry.getMetadata().put("testMode", String.valueOf(testMode)); entry.getMetadata().put("settlement", "settled"); + return entry; + } + @Test void testDashboardKeepsRefundsAndBalancesInTheSameMode() { + when(gateway.isTestMode()).thenReturn(true); + when(ledger.findByCreatorId("creator")).thenReturn(List.of( + entry(false, FinanceLedgerEntry.LedgerType.DONATION, 9000), + entry(true, FinanceLedgerEntry.LedgerType.DONATION, 450), + entry(true, FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT, -90))); + var response = service.getCreatorOverview(creator, "creator", "30d"); + assertEquals(360L, response.get("periodDonationRevenueCents")); + assertEquals(0L, response.get("testAvailableCents")); + assertEquals(45L, response.get("adjustmentOwedCents")); + assertEquals(1000L, response.get("reservedCents")); + assertEquals(true, response.get("payoutHold")); + assertEquals(1500L, response.get("availableCents")); + } + @Test void fundedLegacyWalletRemainsVisibleButRequiresScopeReconciliation() { + when(gateway.isOperational()).thenReturn(true); + when(ledger.findByCreatorId("creator")).thenReturn(List.of()); + var response = service.getCreatorOverview(creator, "creator", "30d"); + assertEquals(1500L, response.get("availableCents")); assertEquals(true, response.get("payoutHold")); + assertEquals(false, response.get("fundingScopeVerified")); + } + @Test void matchingVerifiedFundingAccountCanProceedWithoutHidingItsBalance() { + when(gateway.isReconciliationEnabled()).thenReturn(true); when(gateway.getPlatformAccountId()).thenReturn("acct_platform"); + wallets.getWallet("creator", "usd", false).setProviderAccountId("acct_platform"); + when(ledger.findByCreatorId("creator")).thenReturn(List.of()); + var response = service.getCreatorOverview(creator, "creator", "30d"); + assertEquals(1500L, response.get("availableCents")); assertEquals(false, response.get("payoutHold")); + assertEquals(true, response.get("fundingScopeVerified")); + when(gateway.getPlatformAccountId()).thenReturn("acct_other"); + assertEquals(true, service.getCreatorOverview(creator, "creator", "30d").get("payoutHold")); + } + @Test void partialTransferHistoryShowsOnlyTheUnconfirmedRemainderAsReserved() { + var request = new net.modtale.model.finance.CreatorPayoutRequest(); request.setId("request"); request.setAmountCents(1000); + var sent = new net.modtale.model.finance.CreatorPayoutRequest.Recipient(); sent.setAmountCents(400); sent.setTransferId("tr_sent"); + var pending = new net.modtale.model.finance.CreatorPayoutRequest.Recipient(); pending.setAmountCents(600); request.setRecipients(List.of(sent, pending)); + when(wallets.getRecentRequests("creator", false)).thenReturn(List.of(request)); + when(ledger.findByCreatorId("creator")).thenReturn(List.of()); + var response = service.getCreatorOverview(creator, "creator", "30d"); + var history = (List<java.util.Map<String, Object>>) response.get("payoutRequests"); + assertEquals(400L, history.getFirst().get("transferredCents")); assertEquals(600L, history.getFirst().get("remainingReservedCents")); + } + @Test void adminAvailableUsesRemainingWalletFundsInsteadOfHistoricEarnings() { + when(wallets.getTotalAvailable("usd", false)).thenReturn(1500L); + var response = service.getAdminOverview("30d"); + assertEquals(1500L, response.get("totalCreatorAvailableCents")); + verify(wallets).getTotalAvailable("usd", false); + verify(ledger, never()).findAll(); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/FinancePaymentPipelineIntegrationTest.java b/backend/src/test/java/net/modtale/service/finance/FinancePaymentPipelineIntegrationTest.java new file mode 100644 index 000000000..a1fe0a544 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/FinancePaymentPipelineIntegrationTest.java @@ -0,0 +1,127 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "FINANCE_TEST_MONGO_URI", matches = ".+") +class FinancePaymentPipelineIntegrationTest extends FinancePipelineFixture { + @Test void exactSavedBasisPointsSurviveQuoteCheckoutWebhookAndActualFeeSettlement() throws Exception { + assertEquals(1234, donations.getDonationConfig("project").get("donationPlatformCutBps")); + assertEquals(12.34, donations.getDonationConfig("project").get("donationPlatformCutPercent")); + var intent = checkout(false); assertEquals(1234, intent.getPlatformCents()); assertEquals(1234, intent.getPlatformCutBps()); + project.setDonationPlatformCutBps(2500); + assertEquals(200, event("evt_paid", "checkout.session.completed", session(intent))); + assertEquals(0, available()); assertEquals(1, ledger.count()); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment("pi_first", "available")); + settlement.reconcilePendingPayments(); settlement.reconcilePendingPayments(); + assertEquals(8445, available()); assertEquals(1234, credit("pi_first").getPlatformCents()); + assertEquals(321, credit("pi_first").getProcessorFeeCents()); assertEquals(8766, credit("pi_first").getCreatorGrossCents()); + assertEquals(0, wallets.getWallet("creator", "usd", false).getAvailableCents()); assertEquals(2, ledger.count()); + } + @Test void aLegacyWalletConflictDoesNotStarveOtherCreatorsSettledPayments() throws Exception { + var legacy = new net.modtale.model.finance.CreatorWallet(); legacy.setId(FinanceWalletService.walletId("creator", "usd", true)); + legacy.setCreatorId("creator"); legacy.setCurrency("usd"); legacy.setTestMode(true); legacy.setAvailableCents(2000); mongo.insert(legacy); + var first = checkout(false); event("evt_first", "checkout.session.completed", session(first)); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment("pi_first", "available")); + project.setAuthorId("creator2"); var next = checkout(false); var nextSession = session(next); nextSession.put("payment_intent", "pi_next"); + event("evt_next", "checkout.session.completed", nextSession); when(gateway.getPaymentWithBalanceTransaction("pi_next")).thenReturn(payment("pi_next", "available")); + settlement.reconcilePendingPayments(); + assertEquals(2000, available()); assertEquals(8445, wallets.getWallet("creator2", "usd", true).getAvailableCents()); + assertEquals("source_or_wallet_scope_conflict", ledger.findById(FinanceSourceKey.stripe(true, "acct_platform", "checkout:" + first.getStripeSessionId())).orElseThrow().getMetadata().get("reconciliationReview")); + } + @Test void staleShareIsRejectedBeforeCreatingIntentOrProviderCheckout() { + assertThrows(DonationCheckoutService.SupportTermsChangedException.class, + () -> donations.createDonationCheckout("project", 10000, false, donor, false, 1000)); + assertEquals(0, intents.count()); verify(gateway, never()).createOrSimulateDonationCheckout(anyString(), anyString(), anyLong(), anyBoolean(), anyString(), anyString(), anyString(), anyBoolean()); + } + @Test void unpaidCompletionAndReturnUrlCannotCreditButLaterAsyncSuccessCan() throws Exception { + var intent = checkout(false); var session = session(intent); session.put("payment_status", "unpaid"); + when(gateway.getCheckoutSession(intent.getStripeSessionId(), false)).thenReturn(session); + assertEquals(200, event("evt_unpaid", "checkout.session.completed", session)); + assertEquals(false, donations.confirmDonationIntent(intent.getId()).get("ok")); assertEquals(0, ledger.count()); + session.put("payment_status", "paid"); assertEquals(200, event("evt_async", "checkout.session.async_payment_succeeded", session)); + assertEquals(1, ledger.count()); assertEquals(0, available()); + } + @Test void duplicateEventAndSeparateEventForSameCheckoutNeverDoubleCredit() throws Exception { + var intent = checkout(false); var session = session(intent); + assertEquals(200, event("evt_same", "checkout.session.completed", session)); + assertEquals(200, event("evt_same", "checkout.session.completed", session)); + assertEquals(200, event("evt_other", "checkout.session.async_payment_succeeded", session)); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment("pi_first", "available")); + settlement.reconcilePendingPayments(); donations.confirmDonationIntent(intent.getId()); settlement.reconcilePendingPayments(); + assertEquals(8445, available()); assertEquals(2, ledger.count()); + assertEquals(2, mongo.getCollection("payment_webhook_receipts").countDocuments()); + } + @Test void pendingOrMissingActualFeeNeverMakesMoneyAvailable() throws Exception { + var intent = checkout(false); event("evt_paid", "checkout.session.completed", session(intent)); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment("pi_first", "pending")); + settlement.reconcilePendingPayments(); assertEquals(0, available()); + var pending = new HashMap<>(payment("pi_first", "available")); pending.put("latest_charge", Map.of("id", "ch_pi_first", "paid", true, "captured", true)); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(pending); + settlement.reconcilePendingPayments(); assertEquals(0, available()); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment("pi_first", "available")); + settlement.reconcilePendingPayments(); assertEquals(8445, available()); + } + @Test void wrongEventAccountModeApiVersionAndWrongPaymentCurrencyFailClosed() throws Exception { + var intent = checkout(false); var session = session(intent); + assertEquals(503, event("evt_account", "checkout.session.completed", session, Map.of("account", "acct_other"))); + assertEquals(503, event("evt_mode", "checkout.session.completed", session, Map.of("livemode", true))); + assertEquals(503, event("evt_version", "checkout.session.completed", session, Map.of("api_version", "wrong"))); + session.put("livemode", true); assertEquals(503, event("evt_object_mode", "checkout.session.completed", session)); assertEquals(0, ledger.count()); + session.put("livemode", false); event("evt_valid", "checkout.session.completed", session); + var payment = new HashMap<>(payment("pi_first", "available")); payment.put("currency", "eur"); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment); settlement.reconcilePendingPayments(); assertEquals(0, available()); + when(gateway.getPlatformAccountId()).thenReturn("acct_other"); settlement.reconcilePendingPayments(); assertEquals(0, available()); + } + @Test void switchedPlatformCannotConfirmAnEarlierCheckoutInTheWrongAccount() throws Exception { + var intent = checkout(false); when(gateway.getPlatformAccountId()).thenReturn("acct_other"); + assertThrows(IllegalArgumentException.class, () -> donations.confirmDonationIntent(intent.getId())); + assertEquals(503, event("evt_new_platform", "checkout.session.completed", session(intent))); + assertEquals(0, ledger.count()); verify(gateway, never()).getCheckoutSession(intent.getStripeSessionId(), false); + } + @Test void concurrentSignedDeliveriesAndSettlementWorkersCreditOnlyOnce() throws Exception { + var intent = checkout(false); var session = session(intent); + try (var executor = java.util.concurrent.Executors.newFixedThreadPool(4)) { + var results = new ArrayList<java.util.concurrent.Future<Integer>>(); + for (int i = 0; i < 4; i++) { final int index = i; results.add(executor.submit(() -> event("evt_concurrent_" + index, "checkout.session.completed", session))); } + for (var result : results) assertEquals(200, result.get(20, java.util.concurrent.TimeUnit.SECONDS)); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment("pi_first", "available")); + var jobs = new ArrayList<java.util.concurrent.Future<?>>(); + for (int i = 0; i < 4; i++) jobs.add(executor.submit(() -> settlement.reconcilePendingPayments())); + for (var result : jobs) result.get(20, java.util.concurrent.TimeUnit.SECONDS); + } + assertEquals(8445, available()); assertEquals(2, ledger.count()); + } + @Test void invoiceBeforeCheckoutAndRenewalUseFrozenShareAndEachCashPaymentExactlyOnce() throws Exception { + var intent = checkout(true); when(gateway.getCheckoutSession(intent.getStripeSessionId(), false)).thenReturn(session(intent)); + cashInvoice("in_first", "pi_first"); assertEquals(200, event("evt_invoice_first", "invoice.paid", invoice(intent, "in_first"))); + assertEquals(1, subscriptions.count()); assertEquals(1234, subscriptions.findById("sub_synthetic").orElseThrow().getPlatformCutBps()); + project.setDonationPlatformCutBps(2500); assertEquals(200, event("evt_checkout_later", "checkout.session.completed", session(intent))); + cashInvoice("in_renewal", "pi_renewal"); event("evt_renewal", "invoice.paid", invoice(intent, "in_renewal")); + event("evt_renewal_duplicate", "invoice.paid", invoice(intent, "in_renewal")); settlement.reconcilePendingPayments(); settlement.reconcilePendingPayments(); + assertEquals(16890, available()); assertTrue(credit("pi_renewal").isRecurring()); assertEquals(1234, credit("pi_renewal").getPlatformCents()); assertEquals(4, ledger.count()); + } + @Test void manualCreditMultipleAndPartialInvoicePaymentsRemainUncredited() throws Exception { + var intent = checkout(true); recurring.registerCheckout(intent, session(intent)); var invoice = invoice(intent, "in_review"); + List<Map<String, Object>> fixtures = List.of(Map.of("has_more", false, "data", List.of()), Map.of("has_more", true, "data", List.of()), + Map.of("has_more", false, "data", List.of(Map.of("status", "paid", "amount_paid", 9999, "payment", Map.of("type", "payment_intent", "payment_intent", "pi_partial"))))); + for (var payments : fixtures) { + when(gateway.getInvoicePayments("in_review")).thenReturn(payments); + assertEquals(503, event("evt_needs_review", "invoice.paid", invoice)); assertEquals(0, ledger.count()); + } + assertEquals(0, available()); + } + @Test void cancellationAndUnpaidEventsFetchCurrentProviderStateWithoutCrediting() throws Exception { + var intent = checkout(true); recurring.registerCheckout(intent, session(intent)); + subscriptionStatus("active", true); event("evt_cancel_scheduled", "customer.subscription.updated", Map.of("id", "sub_synthetic", "status", "old_payload")); + assertTrue(subscriptions.findById("sub_synthetic").orElseThrow().isCancelAtPeriodEnd()); + subscriptionStatus("canceled", false); event("evt_deleted", "customer.subscription.deleted", Map.of("id", "sub_synthetic")); + event("evt_delayed_old_update", "customer.subscription.updated", Map.of("id", "sub_synthetic", "status", "active")); + assertEquals("canceled", subscriptions.findById("sub_synthetic").orElseThrow().getStatus()); + event("evt_unpaid", "invoice.payment_failed", invoice(intent, "in_failed")); assertEquals(0, ledger.count()); assertEquals(0, available()); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/FinancePipelineFixture.java b/backend/src/test/java/net/modtale/service/finance/FinancePipelineFixture.java new file mode 100644 index 000000000..7cc9888d6 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/FinancePipelineFixture.java @@ -0,0 +1,107 @@ +package net.modtale.service.finance; + +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.util.*; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import net.modtale.controller.finance.StripeWebhookController; +import net.modtale.model.finance.*; +import net.modtale.model.project.Project; +import net.modtale.model.user.User; +import net.modtale.repository.finance.*; +import net.modtale.service.project.query.ProjectService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; +import org.springframework.data.mongodb.repository.support.MongoRepositoryFactory; +import org.springframework.test.util.ReflectionTestUtils; + +/** Disposable persistence and actual finance services; only external provider/project lookup are fixtures. */ +abstract class FinancePipelineFixture { + MongoClient client; MongoTemplate mongo; FinanceWalletService wallets; StripeGatewayService gateway; + DonationCheckoutService donations; RecurringSupportService recurring; PaymentSettlementService settlement; + PaymentAdjustmentService adjustments; StripeWebhookController webhook; DonationIntentRepository intents; + FinanceLedgerEntryRepository ledger; CreatorSupportSubscriptionRepository subscriptions; + Project project; User donor; + static final String SECRET = "whsec_synthetic_pipeline_only"; + @BeforeEach void setUpPipeline() { + client = MongoClients.create(System.getenv("FINANCE_TEST_MONGO_URI")); + var factory = new SimpleMongoClientDatabaseFactory(client, "finance_pipeline_" + UUID.randomUUID().toString().replace("-", "")); + mongo = new MongoTemplate(factory); + for (Class<?> type : List.of(CreatorWallet.class, CreatorPayoutRequest.class, FinanceLedgerEntry.class, + DonationIntent.class, CreatorSupportSubscription.class, PaymentWebhookReceipt.class, FinanceDisputeCase.class, FinanceTransferReceipt.class, FinanceDisputeResolution.class)) mongo.createCollection(type); + var repositories = new MongoRepositoryFactory(mongo); + intents = repositories.getRepository(DonationIntentRepository.class); ledger = repositories.getRepository(FinanceLedgerEntryRepository.class); + subscriptions = repositories.getRepository(CreatorSupportSubscriptionRepository.class); + gateway = mock(StripeGatewayService.class); + when(gateway.isOperational()).thenReturn(true); when(gateway.isCheckoutAvailable()).thenReturn(true); + when(gateway.isReconciliationEnabled()).thenReturn(true); when(gateway.isTestMode()).thenReturn(true); + when(gateway.getPlatformAccountId()).thenReturn("acct_platform"); + when(gateway.createOrSimulateDonationCheckout(anyString(), anyString(), anyLong(), anyBoolean(), anyString(), anyString(), anyString(), eq(false))) + .thenAnswer(call -> new StripeGatewayService.StripeResult(true, "cs_" + call.getArgument(0), "https://checkout.stripe.com/c/pay/synthetic", null, Map.of())); + var projects = mock(ProjectService.class); project = new Project(); project.setId("project"); project.setAuthorId("creator"); + project.setTitle("Synthetic project"); project.setDonationsEnabled(true); project.setDonationPlatformCutBps(1234); + when(projects.getProjectById("project")).thenReturn(project); when(projects.getProjectLink(project)).thenReturn("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/mod/synthetic"); + var accounts = mock(EarningsAccountService.class); when(accounts.getSettings()).thenReturn(new PlatformFinanceSettings()); + var core = mock(RevenueOpsSupport.class); when(core.normalizeFrontendUrl()).thenReturn("https://example.test"); + wallets = new FinanceWalletService(mongo, factory); adjustments = new PaymentAdjustmentService(mongo, gateway, wallets); + recurring = new RecurringSupportService(subscriptions, intents, ledger, gateway, core, projects); + donations = new DonationCheckoutService(); + ReflectionTestUtils.setField(donations, "financeAccountService", accounts); ReflectionTestUtils.setField(donations, "donationIntentRepository", intents); + ReflectionTestUtils.setField(donations, "ledgerRepository", ledger); ReflectionTestUtils.setField(donations, "projectService", projects); + ReflectionTestUtils.setField(donations, "stripeGatewayService", gateway); ReflectionTestUtils.setField(donations, "core", core); + ReflectionTestUtils.setField(donations, "recurringSupport", recurring); + settlement = new PaymentSettlementService(ledger, gateway, wallets, mongo, adjustments); + webhook = new StripeWebhookController(donations, repositories.getRepository(PaymentWebhookReceiptRepository.class), gateway, recurring, adjustments, SECRET); + donor = new User(); donor.setId("donor"); + } + @AfterEach void closePipeline() { if (mongo != null) mongo.getDb().drop(); if (client != null) client.close(); } + DonationIntent checkout(boolean monthly) { + var result = donations.createDonationCheckout("project", 10000, monthly, donor, false, 1234); + return intents.findById((String) result.get("intentId")).orElseThrow(); + } + Map<String, Object> session(DonationIntent intent) { + var value = new HashMap<String, Object>(); value.put("id", intent.getStripeSessionId()); value.put("livemode", false); value.put("status", "complete"); + value.put("payment_status", "paid"); value.put("mode", intent.isRecurring() ? "subscription" : "payment"); value.put("currency", "usd"); + value.put("amount_total", 10000); value.put("metadata", Map.of("intentId", intent.getId())); value.put("payment_intent", "pi_first"); + value.put("subscription", "sub_synthetic"); value.put("customer", "cus_synthetic"); return value; + } + Map<String, Object> payment(String id, String balanceStatus) { + return Map.of("id", id, "livemode", false, "status", "succeeded", "currency", "usd", "amount_received", 10000, + "latest_charge", Map.of("id", "ch_" + id, "paid", true, "captured", true, "disputed", false, "amount_refunded", 0, + "balance_transaction", Map.of("id", "txn_" + id, "status", balanceStatus, "currency", "usd", "amount", 10000, "fee", 321, "net", 9679))); + } + Map<String, Object> invoice(DonationIntent intent, String id) { + return Map.of("id", id, "livemode", false, "status", "paid", "currency", "usd", "customer", "cus_synthetic", "amount_paid", 10000, + "parent", Map.of("type", "subscription_details", "subscription_details", Map.of("subscription", "sub_synthetic", "metadata", Map.of("intentId", intent.getId())))); + } + void cashInvoice(String invoiceId, String paymentId) { + when(gateway.getInvoicePayments(invoiceId)).thenReturn(Map.of("has_more", false, "data", List.of(Map.of("status", "paid", "amount_paid", 10000, + "payment", Map.of("type", "payment_intent", "payment_intent", paymentId))))); + when(gateway.getPaymentWithBalanceTransaction(paymentId)).thenReturn(payment(paymentId, "available")); subscriptionStatus("active", false); + } + void subscriptionStatus(String status, boolean cancel) { + when(gateway.getSubscription("sub_synthetic")).thenReturn(Map.of("id", "sub_synthetic", "customer", "cus_synthetic", "livemode", false, "status", status, "cancel_at_period_end", cancel)); + } + int event(String id, String type, Map<String, Object> object) throws Exception { return event(id, type, object, Map.of()); } + int event(String id, String type, Map<String, Object> object, Map<String, Object> overrides) throws Exception { + var payload = new HashMap<String, Object>(Map.of("id", id, "type", type, "livemode", false, "api_version", StripeGatewayService.API_VERSION, "data", Map.of("object", object))); + payload.putAll(overrides); byte[] body = new ObjectMapper().writeValueAsBytes(payload); + String timestamp = String.valueOf(Instant.now().getEpochSecond()); Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); mac.update((timestamp + ".").getBytes(StandardCharsets.UTF_8)); + return webhook.receive(body, "t=" + timestamp + ",v1=" + HexFormat.of().formatHex(mac.doFinal(body))).getStatusCode().value(); + } + long available() { return wallets.getWallet("creator", "usd", true).getAvailableCents(); } + FinanceLedgerEntry credit(String paymentId) { return ledger.findById(FinanceSourceKey.stripe(true, "acct_platform", "settlement:txn_" + paymentId)).orElseThrow(); } + void settleOneTime() throws Exception { + var intent = checkout(false); event("evt_checkout", "checkout.session.completed", session(intent)); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment("pi_first", "available")); settlement.reconcilePendingPayments(); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/FinanceRequestAmountTest.java b/backend/src/test/java/net/modtale/service/finance/FinanceRequestAmountTest.java new file mode 100644 index 000000000..363322642 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/FinanceRequestAmountTest.java @@ -0,0 +1,28 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.math.BigDecimal; +import java.time.LocalDate; +import jakarta.validation.Validation; +import net.modtale.model.dto.request.finance.CreateSupportCheckoutRequest; +import net.modtale.model.dto.request.finance.StageAdSettlementRequest; +import org.junit.jupiter.api.Test; + +class FinanceRequestAmountTest { + @Test void checkoutJsonPreservesAndRejectsFractionalCents() throws Exception { + var mapper = new ObjectMapper(); + try (var factory = Validation.buildDefaultValidatorFactory()) { + var valid = mapper.readValue("{\"amountCents\":500,\"recurring\":false,\"guestCheckout\":true,\"expectedPlatformCutBps\":1000}", CreateSupportCheckoutRequest.class); + assertTrue(factory.getValidator().validate(valid).isEmpty()); + var fractional = mapper.readValue("{\"amountCents\":500.99}", CreateSupportCheckoutRequest.class); + assertEquals(new BigDecimal("500.99"), fractional.amountCents()); + assertFalse(factory.getValidator().validate(fractional).isEmpty()); + assertFalse(factory.getValidator().validate(mapper.readValue("{}", CreateSupportCheckoutRequest.class)).isEmpty()); + } + } + @Test void stagedReportsCannotTruncateFractionalDepositCents() { + var report = new StageAdSettlementRequest("provider", "account", "report", "deposit", "usd", LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), new BigDecimal("1000.01"), "a".repeat(64)); + assertThrows(IllegalArgumentException.class, () -> AdSettlementStagingService.validate(report)); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/FinanceSafetyTest.java b/backend/src/test/java/net/modtale/service/finance/FinanceSafetyTest.java new file mode 100644 index 000000000..b25933e10 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/FinanceSafetyTest.java @@ -0,0 +1,149 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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.repository.finance.AdCampaignRepository; +import net.modtale.repository.finance.DonationIntentRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import net.modtale.service.project.query.ProjectService; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.test.util.ReflectionTestUtils; + +class FinanceSafetyTest { + private DonationIntent intent() { + var intent = new DonationIntent(); + intent.setId("intent-1"); intent.setStripePlatformAccountId("acct_platform"); intent.setStripeSessionId("cs_test_1"); + intent.setStripeTestMode(true); + intent.setAmountCents(500); intent.setCreatorCents(450); intent.setPlatformCents(50); + intent.setCreatorId("creator"); intent.setProjectId("project"); + return intent; + } + + private Map<String, Object> paidSession() { + return new HashMap<>(Map.of("id", "cs_test_1", "mode", "payment", "payment_status", "paid", + "status", "complete", "currency", "usd", "amount_total", 500, "livemode", false, + "metadata", Map.of("intentId", "intent-1"))); + } + + @ParameterizedTest @ValueSource(longs = {-1, 0, 99, 100001, Long.MAX_VALUE}) + void invalidSupportAmountsAreRejectedRatherThanChanged(long cents) { + assertThrows(IllegalArgumentException.class, () -> FinanceAmounts.validateSupportAmount(cents)); + } + + @Test void integerSplitConservesEveryCent() { + for (long amount = 100; amount <= 100000; amount++) { + long platform = FinanceAmounts.share(amount, 1000); + assertEquals(amount, platform + (amount - platform)); + } + assertEquals(4, FinanceAmounts.share(5, 7500)); + } + + @Test void unpaidCompletedSessionNeverCredits() { + var session = paidSession(); session.put("payment_status", "unpaid"); + assertFalse(DonationCheckoutService.isVerifiedPayment(intent(), session)); + } + + @Test void paymentMustMatchAllRecordedCheckoutFields() { + assertTrue(DonationCheckoutService.isVerifiedPayment(intent(), paidSession())); + for (var entry : Map.<String, Object>of("id", "cs_other", "mode", "subscription", "currency", "eur", + "amount_total", 499, "metadata", Map.of("intentId", "other")).entrySet()) { + var session = paidSession(); session.put(entry.getKey(), entry.getValue()); + assertFalse(DonationCheckoutService.isVerifiedPayment(intent(), session), entry.getKey()); + } + var session = paidSession(); session.put("simulated", true); + assertFalse(DonationCheckoutService.isVerifiedPayment(intent(), session)); + session = paidSession(); session.put("livemode", true); + assertFalse(DonationCheckoutService.isVerifiedPayment(intent(), session)); + var legacy = intent(); legacy.setStripeTestMode(null); + assertFalse(DonationCheckoutService.isVerifiedPayment(legacy, paidSession())); + } + + @Test void duplicateWebhookDoesNotOverwriteOrDuplicateCredit() { + var service = new DonationCheckoutService(); + var intents = mock(DonationIntentRepository.class); + var ledger = mock(FinanceLedgerEntryRepository.class); + var intent = intent(); + when(intents.findByStripeSessionId("cs_test_1")).thenReturn(Optional.of(intent)); + when(ledger.insert(any(FinanceLedgerEntry.class))).thenThrow(new DuplicateKeyException("already inserted")); + ReflectionTestUtils.setField(service, "donationIntentRepository", intents); + ReflectionTestUtils.setField(service, "ledgerRepository", ledger); + var gateway = mock(StripeGatewayService.class); when(gateway.isTestMode()).thenReturn(true); + when(gateway.getPlatformAccountId()).thenReturn("acct_platform"); + ReflectionTestUtils.setField(service, "stripeGatewayService", gateway); + service.handlePaidCheckout(paidSession()); + assertEquals(DonationIntent.DonationStatus.COMPLETED, intent.getStatus()); + var captured = ArgumentCaptor.forClass(FinanceLedgerEntry.class); + verify(ledger).insert(captured.capture()); + assertEquals("stripe:test:acct_platform:checkout:cs_test_1", captured.getValue().getId()); + assertEquals(FinanceLedgerEntry.EntryStatus.PENDING, captured.getValue().getStatus()); + assertNull(captured.getValue().getExpiresAt()); + assertFalse(FinanceLedgerRules.isReal(captured.getValue())); + verify(ledger, never()).save(any()); + } + + @Test void legacyForfeitureEntryPointDoesNothing() { + var service = new RevenueReportingService(); + var ledger = mock(FinanceLedgerEntryRepository.class); + var intents = mock(DonationIntentRepository.class); + ReflectionTestUtils.setField(service, "ledgerRepository", ledger); + ReflectionTestUtils.setField(service, "donationIntentRepository", intents); + service.expireCreatorFunds(); + verifyNoInteractions(ledger, intents); + } + + @Test void publicRevenueExcludesTransfersTestMoneyAndUnsettledEntries() { + var earned = new FinanceLedgerEntry(); + earned.setCreatedAt(LocalDateTime.now()); earned.setGrossCents(1000); earned.setCreatorCents(850); earned.setPlatformCents(100); + earned.setType(FinanceLedgerEntry.LedgerType.DONATION); earned.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); + earned.getMetadata().put("settlement", "settled"); + var payout = new FinanceLedgerEntry(); payout.setType(FinanceLedgerEntry.LedgerType.PAYOUT); payout.setGrossCents(850); payout.setCreatedAt(LocalDateTime.now()); + var simulated = new FinanceLedgerEntry(); simulated.setType(FinanceLedgerEntry.LedgerType.DONATION); simulated.setGrossCents(3000); simulated.getMetadata().put("simulated", "true"); simulated.setCreatedAt(LocalDateTime.now()); + var service = new RevenueReportingService(); var ledger = mock(FinanceLedgerEntryRepository.class); + ReflectionTestUtils.setField(service, "ledgerRepository", ledger); + when(ledger.findByCreatedAtBetween(any(), any())).thenReturn(List.of(earned, payout, simulated)); + var row = service.getPublicDailyRevenue(1).getFirst(); + assertEquals(1000L, row.get("grossCents")); + assertEquals(850L, row.get("creatorCents")); + assertEquals(100L, row.get("platformCents")); + } + + @Test void clicksNeverMintEarnings() { + var service = new AdCampaignService(); var campaigns = mock(AdCampaignRepository.class); + var ledger = mock(FinanceLedgerEntryRepository.class); var projects = mock(ProjectService.class); + var accounts = mock(EarningsAccountService.class); var core = new RevenueOpsSupport(); + var campaign = new AdCampaign(); campaign.setId("campaign"); campaign.setTargetUrl("https://sponsor.example/offer"); campaign.setBaseRevenuePerClickCents(50000); + var project = new Project(); project.setId("project"); project.setAuthorId("creator"); project.setAdsEnabled(true); + when(campaigns.findById("campaign")).thenReturn(Optional.of(campaign)); + when(projects.getProjectById("project")).thenReturn(project); + when(accounts.getSettings()).thenReturn(new PlatformFinanceSettings()); + ReflectionTestUtils.setField(service, "adCampaignRepository", campaigns); ReflectionTestUtils.setField(service, "ledgerRepository", ledger); + ReflectionTestUtils.setField(service, "projectService", projects); ReflectionTestUtils.setField(service, "financeAccountService", accounts); ReflectionTestUtils.setField(service, "core", core); + assertEquals("https://sponsor.example/offer", service.registerAdClickAndResolveUrl("campaign", "project", "127.0.0.1")); + service.registerAdClickAndResolveUrl("campaign", "project", "127.0.0.1"); + var captured = ArgumentCaptor.forClass(FinanceLedgerEntry.class); + verify(ledger, times(1)).save(captured.capture()); + assertEquals(0, captured.getValue().getCreatorCents()); assertEquals(0, captured.getValue().getGrossCents()); + campaign.setActive(false); + assertThrows(IllegalArgumentException.class, () -> service.registerAdClickAndResolveUrl("campaign", "project", "other")); + } + + @ParameterizedTest @ValueSource(strings = {"javascript:alert(1)", "//evil.example", "http://sponsor.example", "https://user:password@sponsor.example", "data:image/svg+xml,test", ""}) + void unsafeSponsoredUrlsAreRejected(String url) { + assertThrows(IllegalArgumentException.class, () -> RevenueOpsSupport.requireSafeExternalUrl(url)); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/FinanceWalletIntegrationTest.java b/backend/src/test/java/net/modtale/service/finance/FinanceWalletIntegrationTest.java new file mode 100644 index 000000000..26ec5d6c2 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/FinanceWalletIntegrationTest.java @@ -0,0 +1,134 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import com.mongodb.client.MongoClient; +import com.mongodb.client.MongoClients; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import net.modtale.model.finance.CreatorPayoutRequest; +import net.modtale.model.finance.CreatorWallet; +import net.modtale.model.finance.FinanceLedgerEntry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; + +/** Runs against a disposable replica set, with no application secrets or real payment provider. */ +@EnabledIfEnvironmentVariable(named = "FINANCE_TEST_MONGO_URI", matches = ".+") +class FinanceWalletIntegrationTest { + private MongoClient client; + private MongoTemplate mongo; + private FinanceWalletService wallets; + + @BeforeEach void setUp() { + client = MongoClients.create(System.getenv("FINANCE_TEST_MONGO_URI")); + var factory = new SimpleMongoClientDatabaseFactory(client, "finance_test_" + UUID.randomUUID().toString().replace("-", "")); + mongo = new MongoTemplate(factory); + mongo.createCollection(CreatorWallet.class); mongo.createCollection(CreatorPayoutRequest.class); mongo.createCollection(FinanceLedgerEntry.class); mongo.createCollection(net.modtale.model.finance.FinanceTransferReceipt.class); + wallets = new FinanceWalletService(mongo, factory); + } + @AfterEach void tearDown() { if (mongo != null) mongo.getDb().drop(); if (client != null) client.close(); } + + private FinanceLedgerEntry credit(String id, long amount) { + var credit = new FinanceLedgerEntry(); credit.setId(id); credit.setCreatorId("creator"); credit.setGrossCents(amount); + credit.setCreatorCents(amount); credit.setCurrency("usd"); credit.setType(FinanceLedgerEntry.LedgerType.DONATION); + credit.setStatus(FinanceLedgerEntry.EntryStatus.AVAILABLE); credit.getMetadata().put("settlement", "settled"); credit.getMetadata().put("providerAccountId", "acct_platform"); + return credit; + } + private List<CreatorPayoutRequest.Recipient> recipients(long amount) { + var recipient = new CreatorPayoutRequest.Recipient(); recipient.setUserId("creator"); recipient.setAccountId("acct_test"); recipient.setAmountCents(amount); + return List.of(recipient); + } + @Test void settlementReplayCannotCreditTwiceAndTestBalancesAreIsolated() { + wallets.postSettledCredit(credit("source-1", 2000), true); + wallets.postSettledCredit(credit("source-1", 2000), true); + assertEquals(2000, wallets.getWallet("creator", "usd", true).getAvailableCents()); + assertEquals(0, wallets.getWallet("creator", "usd", false).getAvailableCents()); + assertEquals(1, mongo.getCollection("finance_ledger_entries").countDocuments()); + assertThrows(IllegalStateException.class, () -> wallets.postSettledCredit(credit("source-1", 3000), true)); + } + @Test void concurrentWithdrawalsCannotSpendTheSameMoney() throws Exception { + wallets.postSettledCredit(credit("source-1", 1000), true); + try (var executor = Executors.newFixedThreadPool(2)) { + var one = executor.submit(() -> attemptReserve(UUID.randomUUID().toString())); + var two = executor.submit(() -> attemptReserve(UUID.randomUUID().toString())); + assertEquals(1, (one.get(20, TimeUnit.SECONDS) ? 1 : 0) + (two.get(20, TimeUnit.SECONDS) ? 1 : 0)); + } + var wallet = wallets.getWallet("creator", "usd", true); + assertEquals(0, wallet.getAvailableCents()); assertEquals(1000, wallet.getReservedCents()); + assertEquals(1, mongo.getCollection("creator_payout_requests").countDocuments()); + } + private boolean attemptReserve(String key) { + try { wallets.reserve("creator", "creator", "usd", true, key, 1000, 1000, recipients(1000), "acct_platform"); return true; } + catch (IllegalStateException unavailable) { return false; } + } + @Test void repeatedRequestAndTransferCompletionAreIdempotentAndDoNotRewriteEarnings() { + wallets.postSettledCredit(credit("source-1", 2500), true); + String key = UUID.randomUUID().toString(); + var request = wallets.reserve("creator", "creator", "usd", true, key, 1000, 1000, recipients(1000), "acct_platform"); + var replay = wallets.reserve("creator", "creator", "usd", true, key, 1000, 1000, recipients(1000), "acct_platform"); + assertEquals(request.getId(), replay.getId()); + assertEquals(1500, wallets.getWallet("creator", "usd", true).getAvailableCents()); + assertEquals(1500, wallets.getTotalAvailable("usd", true)); + assertEquals(0, wallets.getTotalAvailable("usd", false)); + wallets.markAttempted(request.getId()); + assertThrows(IllegalStateException.class, () -> wallets.completeTransfers(request.getId())); + assertTrue(wallets.authorizeRecipientTransfer(request.getId(), 0)); + wallets.recordTransfer(request.getId(), 0, "tr_test", "test-reviewer", "Verified fixture transfer"); + wallets.completeTransfers(request.getId()); wallets.completeTransfers(request.getId()); + assertEquals(0, wallets.getWallet("creator", "usd", true).getReservedCents()); + assertEquals(1500, wallets.getWallet("creator", "usd", true).getAvailableCents()); + assertEquals(2500, mongo.findById("source-1", FinanceLedgerEntry.class).getCreatorCents()); + assertEquals(2, mongo.getCollection("finance_ledger_entries").countDocuments()); + } + @Test void uncertainTransferKeepsItsReservationForReview() { + wallets.postSettledCredit(credit("source-1", 1000), true); + var request = wallets.reserve("creator", "creator", "usd", true, UUID.randomUUID().toString(), 1000, 1000, recipients(1000), "acct_platform"); + wallets.markAttempted(request.getId()); wallets.requireReview(request.getId(), "Provider outcome could not be reconciled."); + assertEquals(1000, wallets.getWallet("creator", "usd", true).getReservedCents()); + assertEquals(CreatorPayoutRequest.Status.REQUIRES_REVIEW, wallets.getRequest(request.getId()).getStatus()); + assertThrows(IllegalStateException.class, () -> wallets.completeTransfers(request.getId())); + } + @Test void riskArrivingAfterReservationBlocksDispatchAuthorization() { + wallets.postSettledCredit(credit("source-1", 1000), true); + var request = wallets.reserve("creator", "creator", "usd", true, UUID.randomUUID().toString(), 1000, 1000, recipients(1000), "acct_platform"); + wallets.markAttempted(request.getId()); + wallets.holdForRisk("creator", "usd", true, "dispute:dp_test"); + assertFalse(wallets.authorizeRecipientTransfer(request.getId(), 0)); + assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + assertEquals(1000, wallets.getWallet("creator", "usd", true).getReservedCents()); + } + @Test void conflictingReplayCannotCrossModesOrChangeFees() { + wallets.postSettledCredit(credit("source-1", 1000), true); + assertThrows(IllegalStateException.class, () -> wallets.postSettledCredit(credit("source-1", 1000), false)); + var changed = credit("source-1", 1000); changed.setProcessorFeeCents(5L); + assertThrows(IllegalStateException.class, () -> wallets.postSettledCredit(changed, true)); + assertEquals(0, wallets.getWallet("creator", "usd", false).getAvailableCents()); + } + @Test void fullRefundRetainsOriginalFeeOnlyOnceAndOffsetsFutureEarnings() { + var original = credit("source-1", 500); original.setCreatorCents(405); original.setPlatformCents(50); original.setProcessorFeeCents(45L); + wallets.postSettledCredit(original, true); + wallets.postRefund("source-1", "refund-1", 500, 0, "txn_refund", true); + wallets.postRefund("source-1", "refund-1", 500, 0, "txn_refund", true); + assertEquals(-45, wallets.getWallet("creator", "usd", true).getAvailableCents()); + wallets.postSettledCredit(credit("future-earning", 1000), true); + assertEquals(955, wallets.getWallet("creator", "usd", true).getAvailableCents()); + assertEquals(-50, mongo.findById("refund-1", FinanceLedgerEntry.class).getPlatformCents()); + } + @Test void partialRefundsConservePlatformRoundingAndNeverDoubleChargeOriginalFees() { + var original = credit("source-1", 101); original.setCreatorCents(87); original.setPlatformCents(10); original.setProcessorFeeCents(4L); + wallets.postSettledCredit(original, true); + wallets.postRefund("source-1", "refund-1", 34, 0, "txn_1", true); + wallets.postRefund("source-1", "refund-2", 33, 0, "txn_2", true); + wallets.postRefund("source-1", "refund-3", 34, 0, "txn_3", true); + assertEquals(-4, wallets.getWallet("creator", "usd", true).getAvailableCents()); + assertThrows(IllegalArgumentException.class, () -> wallets.postRefund("source-1", "refund-too-much", 1, 0, "txn_4", true)); + assertEquals(-4, wallets.getWallet("creator", "usd", true).getAvailableCents()); + } + +} diff --git a/backend/src/test/java/net/modtale/service/finance/PaymentAdjustmentPipelineIntegrationTest.java b/backend/src/test/java/net/modtale/service/finance/PaymentAdjustmentPipelineIntegrationTest.java new file mode 100644 index 000000000..f566e898b --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/PaymentAdjustmentPipelineIntegrationTest.java @@ -0,0 +1,98 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.*; +import net.modtale.model.finance.FinanceLedgerEntry; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "FINANCE_TEST_MONGO_URI", matches = ".+") +class PaymentAdjustmentPipelineIntegrationTest extends FinancePipelineFixture { + private Map<String, Object> charge(long refunded, boolean disputed) { + return Map.of("id", "ch_pi_first", "livemode", false, "currency", "usd", "amount_refunded", refunded, "disputed", disputed); + } + private Map<String, Object> refund(String id, long amount, String status, String balanceStatus) { + return Map.of("id", id, "charge", "ch_pi_first", "currency", "usd", "amount", amount, "status", status, + "balance_transaction", Map.of("id", "txn_" + id, "amount", -amount, "fee", 0, "net", -amount, "currency", "usd", "status", balanceStatus)); + } + private void refunds(List<Map<String, Object>> refunds) { + when(gateway.getChargeRefunds("ch_pi_first", null)).thenReturn(Map.of("has_more", false, "data", refunds)); + } + private int refundEvent(String id) throws Exception { return event(id, "charge.refunded", Map.of("id", "ch_pi_first")); } + @Test void signedRefundWebhookPaginatesAndReversesBothSharesOncePreservingOriginalFee() throws Exception { + settleOneTime(); when(gateway.getCharge("ch_pi_first")).thenReturn(charge(10000, false)); + when(gateway.getChargeRefunds("ch_pi_first", null)).thenReturn(Map.of("has_more", true, "data", List.of(refund("re_one", 3333, "succeeded", "available")))); + when(gateway.getChargeRefunds("ch_pi_first", "re_one")).thenReturn(Map.of("has_more", false, "data", List.of(refund("re_two", 6667, "succeeded", "available")))); + assertEquals(200, refundEvent("evt_refund")); assertEquals(-321, available()); + assertEquals(200, refundEvent("evt_refund")); assertEquals(200, refundEvent("evt_refund_later")); assertEquals(-321, available()); + assertFalse(wallets.getWallet("creator", "usd", true).isPayoutHold()); + var entries = ledger.findAll().stream().filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.REFUND_ADJUSTMENT).toList(); + assertEquals(2, entries.size()); assertEquals(-1234, entries.stream().mapToLong(FinanceLedgerEntry::getPlatformCents).sum()); + assertEquals(8445, credit("pi_first").getCreatorCents()); + } + @Test void pendingRefundAndMissingBalanceStayHeldThenScheduledReconciliationRepairsThem() throws Exception { + settleOneTime(); when(gateway.getCharge("ch_pi_first")).thenReturn(charge(10000, false)); + refunds(List.of(refund("re_pending", 10000, "pending", "pending"))); + assertEquals(200, refundEvent("evt_pending")); assertEquals(8445, available()); assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + refunds(List.of(refund("re_pending", 10000, "succeeded", "pending"))); adjustments.reconcileHeldCharges(); assertEquals(8445, available()); + refunds(List.of(refund("re_pending", 10000, "succeeded", "available"))); adjustments.reconcileHeldCharges(); + assertEquals(-321, available()); assertFalse(wallets.getWallet("creator", "usd", true).isPayoutHold()); + } + @Test void failedAndCanceledRefundsDoNotDebitAndOnlyTheirOwnHoldIsReleased() throws Exception { + settleOneTime(); wallets.holdForRisk("creator", "usd", true, "unrelated-review"); + when(gateway.getCharge("ch_pi_first")).thenReturn(charge(0, false)); + refunds(List.of(refund("re_failed", 10000, "failed", "available"), refund("re_canceled", 10000, "canceled", "available"))); + assertEquals(200, refundEvent("evt_failed")); assertEquals(8445, available()); + assertEquals(List.of("unrelated-review"), wallets.getWallet("creator", "usd", true).getOpenRiskIds()); + } + @Test void refundBeforeSettlementRetriesWithoutLosingTheEvent() throws Exception { + var intent = checkout(false); event("evt_paid", "checkout.session.completed", session(intent)); + assertEquals(503, refundEvent("evt_early_refund")); + when(gateway.getPaymentWithBalanceTransaction("pi_first")).thenReturn(payment("pi_first", "available")); settlement.reconcilePendingPayments(); + when(gateway.getCharge("ch_pi_first")).thenReturn(charge(10000, false)); refunds(List.of(refund("re_early", 10000, "succeeded", "available"))); + assertEquals(200, refundEvent("evt_early_refund")); assertEquals(-321, available()); + } + @Test void wrongChargeModeCurrencyAndRefundSourceCannotDebit() throws Exception { + settleOneTime(); + for (var changed : List.of(Map.<String, Object>of("livemode", true), Map.<String, Object>of("currency", "eur"), Map.<String, Object>of("id", "ch_other"))) { + var invalid = new HashMap<>(charge(10000, false)); invalid.putAll(changed); when(gateway.getCharge("ch_pi_first")).thenReturn(invalid); + assertEquals(503, refundEvent("evt_bad_charge")); assertEquals(8445, available()); + } + var missingMode = new HashMap<>(charge(10000, false)); missingMode.remove("livemode"); when(gateway.getCharge("ch_pi_first")).thenReturn(missingMode); + assertEquals(503, refundEvent("evt_missing_mode")); assertEquals(8445, available()); + when(gateway.getCharge("ch_pi_first")).thenReturn(charge(10000, false)); + for (var changed : List.of(Map.<String, Object>of("charge", "ch_other"), Map.<String, Object>of("currency", "eur"))) { + var invalid = new HashMap<>(refund("re_bad", 10000, "succeeded", "available")); invalid.putAll(changed); refunds(List.of(invalid)); + refundEvent("evt_bad_refund_" + changed.keySet().iterator().next()); assertEquals(8445, available()); + } + assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + } + @Test void accountAndModeSwitchCannotFindOrDebitAnUnrelatedOriginal() throws Exception { + settleOneTime(); when(gateway.getPlatformAccountId()).thenReturn("acct_other"); + assertEquals(503, refundEvent("evt_scope")); assertEquals(8445, available()); verify(gateway, never()).getCharge("ch_pi_first"); + when(gateway.getPlatformAccountId()).thenReturn("acct_platform"); when(gateway.isTestMode()).thenReturn(false); + assertThrows(IllegalArgumentException.class, () -> adjustments.synchronizeCharge("ch_pi_first")); assertEquals(8445, available()); + } + @Test void disputeLostReversesPrincipalOnceAndLeavesActualFeesForPolicyReview() throws Exception { + settleOneTime(); wallets.holdForRisk("creator", "usd", true, "unrelated-review"); + when(gateway.getDispute("dp_case")).thenReturn(dispute("lost")); + assertEquals(200, event("evt_lost", "charge.dispute.closed", Map.of("id", "dp_case", "charge", "ch_pi_first"))); + event("evt_lost_duplicate", "charge.dispute.updated", Map.of("id", "dp_case", "charge", "ch_pi_first")); + assertEquals(-321, available()); assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + var review = adjustments.getDisputeCases().getFirst(); assertEquals(1500, review.actualFeeCents()); + assertEquals("POLICY_REVIEW_REQUIRED", review.reviewStatus()); assertTrue(wallets.getWallet("creator", "usd", true).getOpenRiskIds().contains("unrelated-review")); + } + @Test void openWonAndWarningClosedDisputesDoNotInventPrincipalOrFeeDebits() throws Exception { + settleOneTime(); + for (String state : List.of("needs_response", "under_review", "won", "warning_closed", "prevented", "future_unknown")) { + when(gateway.getDispute("dp_case")).thenReturn(dispute(state)); + event("evt_" + state, "charge.dispute.updated", Map.of("id", "dp_case", "charge", "ch_pi_first")); assertEquals(8445, available()); + } + assertEquals(2, ledger.count()); assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + } + private Map<String, Object> dispute(String status) { + return Map.of("id", "dp_case", "charge", "ch_pi_first", "currency", "usd", "livemode", false, "amount", 10000, "status", status, + "balance_transactions", List.of(Map.of("id", "txn_dispute", "source", "dp_case", "type", "adjustment", "currency", "usd", "amount", -10000, "fee", 1500, "net", -11500, "status", "available"))); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/PaymentSettlementServiceTest.java b/backend/src/test/java/net/modtale/service/finance/PaymentSettlementServiceTest.java new file mode 100644 index 000000000..45a9fc668 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/PaymentSettlementServiceTest.java @@ -0,0 +1,37 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.HashMap; +import java.util.Map; +import net.modtale.model.finance.FinanceLedgerEntry; +import org.junit.jupiter.api.Test; + +class PaymentSettlementServiceTest { + private FinanceLedgerEntry pending() { + var pending = new FinanceLedgerEntry(); pending.setId("observation"); pending.setCreatorId("creator"); pending.setGrossCents(500); + pending.setPlatformCents(50); pending.getMetadata().put("providerAccountId", "acct_platform"); pending.getMetadata().put("paymentIntentId", "pi_test"); pending.getMetadata().put("testMode", "true"); + return pending; + } + private Map<String, Object> payment(long fee, String status) { + return Map.of("id", "pi_test", "status", "succeeded", "livemode", false, "currency", "usd", "amount_received", 500, + "latest_charge", Map.of("id", "ch_test", "paid", true, "captured", true, "disputed", false, "amount_refunded", 0, + "balance_transaction", Map.of("id", "txn_test", "status", status, "currency", "usd", "amount", 500, "fee", fee, "net", 500 - fee))); + } + @Test void actualProcessorFeeComesOutOfCreatorRemainder() { + var credit = PaymentSettlementService.settledCredit(pending(), payment(45, "available")); + assertNotNull(credit); assertEquals(405, credit.getCreatorCents()); assertEquals(50, credit.getPlatformCents()); + assertEquals(45, credit.getProcessorFeeCents()); assertEquals(450, credit.getCreatorGrossCents()); + assertEquals(credit.getGrossCents(), credit.getCreatorCents() + credit.getPlatformCents() + credit.getProcessorFeeCents()); + assertEquals("stripe:test:acct_platform:settlement:txn_test", credit.getId()); assertNull(credit.getExpiresAt()); + } + @Test void pendingProviderFundsAndFeeDominatedPaymentsAreNotCredited() { + assertNull(PaymentSettlementService.settledCredit(pending(), payment(45, "pending"))); + assertNull(PaymentSettlementService.settledCredit(pending(), payment(451, "available"))); + } + @Test void paymentCannotCrossCurrencyOrTestLiveBoundaries() { + var payment = new HashMap<>(payment(45, "available")); payment.put("livemode", true); + assertNull(PaymentSettlementService.settledCredit(pending(), payment)); + payment.put("livemode", false); payment.put("currency", "eur"); + assertNull(PaymentSettlementService.settledCredit(pending(), payment)); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/PayoutReconciliationIntegrationTest.java b/backend/src/test/java/net/modtale/service/finance/PayoutReconciliationIntegrationTest.java new file mode 100644 index 000000000..fc3ac5c57 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/PayoutReconciliationIntegrationTest.java @@ -0,0 +1,206 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import net.modtale.model.finance.*; +import net.modtale.repository.user.UserRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.query.*; + +@EnabledIfEnvironmentVariable(named = "FINANCE_TEST_MONGO_URI", matches = ".+") +class PayoutReconciliationIntegrationTest extends FinancePipelineFixture { + private CreatorPayoutService payouts() { + when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(true); + return new CreatorPayoutService(wallets, gateway, mock(UserRepository.class), mock(RevenueOpsSupport.class)); + } + private CreatorPayoutRequest reserve(long... allocations) { + var recipients = new ArrayList<CreatorPayoutRequest.Recipient>(); + for (int i = 0; i < allocations.length; i++) { + var recipient = new CreatorPayoutRequest.Recipient(); recipient.setUserId("recipient" + i); + recipient.setAccountId("acct_recipient" + i); recipient.setAmountCents(allocations[i]); recipients.add(recipient); + } + return wallets.reserve("creator", "creator", "usd", true, UUID.randomUUID().toString(), Arrays.stream(allocations).sum(), 1000, recipients, "acct_platform"); + } + private Map<String, Object> transfer(CreatorPayoutRequest request, int index, String id) { + var value = new HashMap<String, Object>(); var recipient = request.getRecipients().get(index); + value.put("id", id); value.put("object", "transfer"); value.put("livemode", false); value.put("destination", recipient.getAccountId()); + value.put("amount", recipient.getAmountCents()); value.put("currency", request.getCurrency()); value.put("transfer_group", request.getTransferGroup()); + value.put("metadata", CreatorPayoutService.transferMetadata(request, index)); value.put("created", Instant.now().getEpochSecond()); + value.put("reversed", false); value.put("amount_reversed", 0); return value; + } + private void authorize(CreatorPayoutRequest request, int index) { wallets.markAttempted(request.getId()); assertTrue(wallets.authorizeRecipientTransfer(request.getId(), index)); } + @Test void lostProviderResponseRetriesTheExactKeyAndDoesNotMoveSubmissionTime() throws Exception { + settleOneTime(); var request = reserve(1000); var service = payouts(); var result = new AtomicReference<Map<String, Object>>(); + when(gateway.createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), eq(false), anyString())).thenAnswer(call -> { + if (result.get() == null) { result.set(transfer(wallets.getRequest(request.getId()), 0, "tr_once")); return new StripeGatewayService.StripeResult(false, null, null, "lost response", Map.of()); } + return new StripeGatewayService.StripeResult(true, "tr_once", null, null, result.get()); + }); + assertNull(request.getFirstAttemptAt()); service.dispatch(request.getId()); var submitted = wallets.getRequest(request.getId()); + assertEquals(1000, wallets.getWallet("creator", "usd", true).getReservedCents()); assertNotNull(submitted.getFirstAttemptAt()); + service.dispatch(request.getId()); service.dispatch(request.getId()); var completed = wallets.getRequest(request.getId()); + assertEquals(CreatorPayoutRequest.Status.TRANSFERRED, completed.getStatus()); assertEquals(submitted.getFirstAttemptAt(), completed.getFirstAttemptAt()); + assertEquals(submitted.getRecipients().getFirst().getAuthorizedAt(), completed.getRecipients().getFirst().getAuthorizedAt()); + assertEquals(0, wallets.getWallet("creator", "usd", true).getReservedCents()); assertEquals(7445, available()); + verify(gateway, times(2)).createTransfer(anyString(), eq(1000L), eq("usd"), anyString(), anyMap(), eq(false), eq("modtale-payout:" + request.getId() + ":0")); + assertEquals(1, mongo.getCollection("finance_transfer_receipts").countDocuments()); + } + @Test void expiredAttemptCanOnlyAdoptAProvenExistingTransferAndPreservesUnrelatedHolds() throws Exception { + settleOneTime(); var request = reserve(1000); var service = payouts(); authorize(request, 0); + var evidence = transfer(wallets.getRequest(request.getId()), 0, "tr_found"); + mongo.updateFirst(Query.query(Criteria.where("_id").is(request.getId())), new Update().set("firstAttemptAt", Instant.now().minusSeconds(24 * 3600)), CreatorPayoutRequest.class); + service.dispatch(request.getId()); assertEquals(CreatorPayoutRequest.Status.REQUIRES_REVIEW, wallets.getRequest(request.getId()).getStatus()); + wallets.holdForRisk("creator", "usd", true, "dp_unrelated"); when(gateway.getTransfer("tr_found")).thenReturn(evidence); + service.reconcileKnownTransfer(request.getId(), 0, "tr_found", donor, "Confirmed provider request and original transfer group"); + service.reconcileKnownTransfer(request.getId(), 0, "tr_found", donor, "Duplicate review click"); + assertEquals(0, wallets.getWallet("creator", "usd", true).getReservedCents()); assertEquals(7445, available()); + assertEquals(List.of("dp_unrelated"), wallets.getWallet("creator", "usd", true).getOpenRiskIds()); + assertEquals("donor", wallets.getRequest(request.getId()).getRecipients().getFirst().getConfirmedBy()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + } + @Test void missingAmbiguousOrMismatchedEvidenceNeverReleasesReservedMoney() throws Exception { + settleOneTime(); var request = reserve(1000); var service = payouts(); authorize(request, 0); wallets.requireReview(request.getId(), "Unknown outcome"); + var valid = transfer(wallets.getRequest(request.getId()), 0, "tr_candidate"); + List<Map<String, Object>> changes = List.of(Map.of("destination", "acct_wrong"), Map.of("amount", 999), Map.of("amount", 1000.5), + Map.of("currency", "eur"), Map.of("livemode", true), Map.of("object", "charge"), Map.of("id", "tr_other"), + Map.of("transfer_group", "other"), Map.of("metadata", Map.of()), Map.of("reversed", true), Map.of("amount_reversed", 1), Map.of("created", 1)); + when(gateway.getTransfer("tr_candidate")).thenReturn(Map.of()); + assertThrows(IllegalArgumentException.class, () -> service.reconcileKnownTransfer(request.getId(), 0, "tr_candidate", donor, "Review")); + for (var change : changes) { + var invalid = new HashMap<>(valid); invalid.putAll(change); when(gateway.getTransfer("tr_candidate")).thenReturn(invalid); + assertThrows(IllegalArgumentException.class, () -> service.reconcileKnownTransfer(request.getId(), 0, "tr_candidate", donor, "Review"), change.toString()); + } + when(gateway.getTransfer("tr_candidate")).thenReturn(valid); when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(false); + assertThrows(IllegalArgumentException.class, () -> service.reconcileKnownTransfer(request.getId(), 0, "tr_candidate", donor, "Review")); + assertEquals(1000, wallets.getWallet("creator", "usd", true).getReservedCents()); assertEquals(0, mongo.getCollection("finance_transfer_receipts").countDocuments()); + } + @Test void partialOrganizationSuccessIsAccountedBeforeRemainingRecipientIsReconciled() throws Exception { + settleOneTime(); var request = reserve(500, 500); var service = payouts(); + when(gateway.createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), eq(false), anyString())).thenAnswer(call -> { + if ("acct_recipient0".equals(call.getArgument(0))) return new StripeGatewayService.StripeResult(true, "tr_first", null, null, transfer(wallets.getRequest(request.getId()), 0, "tr_first")); + return new StripeGatewayService.StripeResult(false, null, null, "lost second response", Map.of()); + }); + service.dispatch(request.getId()); assertEquals(500, wallets.getWallet("creator", "usd", true).getReservedCents()); + assertEquals(1, ledger.findAll().stream().filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.PAYOUT).count()); + wallets.holdForRisk("creator", "usd", true, "dp_after_send"); wallets.requireReview(request.getId(), "Second transfer uncertain"); + when(gateway.getTransfer("tr_second")).thenReturn(transfer(wallets.getRequest(request.getId()), 1, "tr_second")); + service.reconcileKnownTransfer(request.getId(), 1, "tr_second", donor, "Provider confirmed second original request"); + assertEquals(0, wallets.getWallet("creator", "usd", true).getReservedCents()); assertEquals(CreatorPayoutRequest.Status.TRANSFERRED, wallets.getRequest(request.getId()).getStatus()); + assertTrue(wallets.getWallet("creator", "usd", true).isPayoutHold()); + assertEquals(-1000, ledger.findAll().stream().filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.PAYOUT).mapToLong(FinanceLedgerEntry::getCreatorCents).sum()); + } + @Test void confirmationBetweenSnapshotAndAuthorizationDoesNotStrandRemainingOrganizationRecipient() throws Exception { + assertCompetingConfirmationDoesNotStrandRemainingRecipient(true); + } + @Test void confirmationBetweenAuthorizationAndProviderRequestDoesNotResendOrStrandRemainingOrganizationRecipient() throws Exception { + assertCompetingConfirmationDoesNotStrandRemainingRecipient(false); + } + private void assertCompetingConfirmationDoesNotStrandRemainingRecipient(boolean beforeAuthorization) throws Exception { + settleOneTime(); var request = reserve(500, 500); payouts(); + var dispatchWallets = spy(wallets); + var service = new CreatorPayoutService(dispatchWallets, gateway, mock(UserRepository.class), mock(RevenueOpsSupport.class)); + doAnswer(call -> { + // A competing dispatcher confirms the first recipient while this worker has a stale snapshot. + if (beforeAuthorization) assertTrue(wallets.authorizeRecipientTransfer(request.getId(), 0)); + else assertTrue((boolean) call.callRealMethod()); + wallets.recordTransfer(request.getId(), 0, "tr_competing", "provider-response", "Verified fixture transfer"); + return beforeAuthorization ? call.callRealMethod() : true; + }).when(dispatchWallets).authorizeRecipientTransfer(request.getId(), 0); + when(gateway.createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), eq(false), anyString())) + .thenAnswer(call -> new StripeGatewayService.StripeResult(true, "tr_remaining", null, null, + transfer(wallets.getRequest(request.getId()), 1, "tr_remaining"))); + service.dispatch(request.getId()); + var completed = wallets.getRequest(request.getId()); + assertEquals(CreatorPayoutRequest.Status.TRANSFERRED, completed.getStatus()); + assertNull(completed.getReviewReason()); + assertEquals("tr_competing", completed.getRecipients().getFirst().getTransferId()); + assertEquals("tr_remaining", completed.getRecipients().get(1).getTransferId()); + verify(gateway, times(1)).createTransfer(eq("acct_recipient1"), eq(500L), eq("usd"), anyString(), anyMap(), eq(false), + eq("modtale-payout:" + request.getId() + ":1")); + verify(gateway, never()).createTransfer(eq("acct_recipient0"), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + assertEquals(0, wallets.getWallet("creator", "usd", true).getReservedCents()); + assertEquals(7445, available()); + assertEquals(2, mongo.getCollection("finance_transfer_receipts").countDocuments()); + assertEquals(-1000, ledger.findAll().stream().filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.PAYOUT).mapToLong(FinanceLedgerEntry::getCreatorCents).sum()); + } + @Test void recipientReviewCannotPauseAConfirmedRecipientAndRemainingRiskStillFailsClosed() throws Exception { + settleOneTime(); var request = reserve(500, 500); payouts(); authorize(request, 0); + wallets.recordTransfer(request.getId(), 0, "tr_confirmed", "provider-response", "Verified fixture transfer"); + assertEquals("tr_confirmed", wallets.getRequest(request.getId()).getRecipients().getFirst().getTransferId()); + assertFalse(wallets.authorizeRecipientTransfer(request.getId(), 0)); + wallets.requireRecipientReview(request.getId(), 0, "Stale authorization failure"); + var partial = wallets.getRequest(request.getId()); + assertEquals(CreatorPayoutRequest.Status.PROCESSING, partial.getStatus()); assertNull(partial.getReviewReason()); + wallets.holdForRisk("creator", "usd", true, "dp_remaining"); + assertFalse(wallets.authorizeRecipientTransfer(request.getId(), 1)); + wallets.requireRecipientReview(request.getId(), 1, "Remaining recipient risk"); + var paused = wallets.getRequest(request.getId()); + assertEquals(CreatorPayoutRequest.Status.REQUIRES_REVIEW, paused.getStatus()); + assertEquals("Remaining recipient risk", paused.getReviewReason()); + assertEquals("tr_confirmed", paused.getRecipients().getFirst().getTransferId()); + assertNull(paused.getRecipients().get(1).getTransferId()); + assertEquals(500, wallets.getWallet("creator", "usd", true).getReservedCents()); + assertEquals(List.of("dp_remaining"), wallets.getWallet("creator", "usd", true).getOpenRiskIds()); + assertEquals(1, mongo.getCollection("finance_transfer_receipts").countDocuments()); + verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + } + @Test void confirmationDuringRecipientReviewRetriesWithoutPausingRemainingRecipients() throws Exception { + settleOneTime(); var request = reserve(500, 500); authorize(request, 0); + var reviewMongo = spy(mongo); + var reviewWallets = new FinanceWalletService(reviewMongo, mongo.getMongoDatabaseFactory()); + var firstRead = new java.util.concurrent.atomic.AtomicBoolean(true); + try (var executor = Executors.newSingleThreadExecutor()) { + doAnswer(call -> { + var snapshot = call.callRealMethod(); + if (firstRead.compareAndSet(true, false)) { + // Confirm in another transaction after review read its old request snapshot. + executor.submit(() -> wallets.recordTransfer(request.getId(), 0, "tr_competing", "provider-response", "Verified fixture transfer")) + .get(20, TimeUnit.SECONDS); + } + return snapshot; + }).when(reviewMongo).findById(request.getId(), CreatorPayoutRequest.class); + reviewWallets.requireRecipientReview(request.getId(), 0, "Stale review attempt"); + } + verify(reviewMongo, atLeast(2)).findById(request.getId(), CreatorPayoutRequest.class); + var partial = wallets.getRequest(request.getId()); + assertEquals(CreatorPayoutRequest.Status.PROCESSING, partial.getStatus()); assertNull(partial.getReviewReason()); + assertEquals("tr_competing", partial.getRecipients().getFirst().getTransferId()); + assertNull(partial.getRecipients().get(1).getTransferId()); + assertEquals(500, wallets.getWallet("creator", "usd", true).getReservedCents()); + assertEquals(1, mongo.getCollection("finance_transfer_receipts").countDocuments()); + } + @Test void simultaneousAttemptsCannotClaimOneProviderTransferForTwoReservations() throws Exception { + settleOneTime(); var one = reserve(1000); var two = reserve(1000); authorize(one, 0); authorize(two, 0); + try (var executor = Executors.newFixedThreadPool(2)) { + var first = executor.submit(() -> claim(one.getId())); var second = executor.submit(() -> claim(two.getId())); + assertEquals(1, (first.get(20, TimeUnit.SECONDS) ? 1 : 0) + (second.get(20, TimeUnit.SECONDS) ? 1 : 0)); + } + assertEquals(1000, wallets.getWallet("creator", "usd", true).getReservedCents()); assertEquals(1, mongo.getCollection("finance_transfer_receipts").countDocuments()); + assertEquals(1, ledger.findAll().stream().filter(e -> e.getType() == FinanceLedgerEntry.LedgerType.PAYOUT).count()); + } + private boolean claim(String id) { + try { wallets.recordTransfer(id, 0, "tr_single", "operator", "Verified fixture"); return true; } catch (RuntimeException conflict) { return false; } + } + @Test void riskBetweenReservationAndAuthorizationPreventsOutboundMoneyButKeepsReservation() throws Exception { + settleOneTime(); var request = reserve(1000); var service = payouts(); wallets.holdForRisk("creator", "usd", true, "dp_before_send"); + service.dispatch(request.getId()); assertEquals(1000, wallets.getWallet("creator", "usd", true).getReservedCents()); + assertNull(wallets.getRequest(request.getId()).getFirstAttemptAt()); verify(gateway, never()).createTransfer(anyString(), anyLong(), anyString(), anyString(), anyMap(), anyBoolean(), anyString()); + } + @Test void changedPlatformAndLegacyWalletScopeCannotSpendPreviouslyFundedBalances() throws Exception { + settleOneTime(); var request = reserve(1000); var service = payouts(); when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(false); + service.dispatch(request.getId()); assertEquals(CreatorPayoutRequest.Status.REQUIRES_REVIEW, wallets.getRequest(request.getId()).getStatus()); + assertEquals(1000, wallets.getWallet("creator", "usd", true).getReservedCents()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(request.getId())), new Update().unset("providerAccountId"), CreatorPayoutRequest.class); + assertEquals(request.getId(), wallets.getReviewRequests().getFirst().getId()); + mongo.updateFirst(Query.query(Criteria.where("_id").is(FinanceWalletService.walletId("creator", "usd", true))), new Update().unset("providerAccountId"), CreatorWallet.class); + assertThrows(IllegalStateException.class, () -> reserve(1000)); assertEquals(7445, available()); + var next = credit("pi_first"); next.setId("new-source"); + assertThrows(IllegalStateException.class, () -> wallets.postSettledCredit(next, true)); + assertFalse(ledger.existsById("new-source")); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/ProviderCostEvidenceIntegrationTest.java b/backend/src/test/java/net/modtale/service/finance/ProviderCostEvidenceIntegrationTest.java new file mode 100644 index 000000000..03291e9e7 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/ProviderCostEvidenceIntegrationTest.java @@ -0,0 +1,55 @@ +package net.modtale.service.finance; + +import com.mongodb.client.*; +import java.util.*; +import java.util.concurrent.*; +import net.modtale.model.finance.ProviderCostEvidence; +import net.modtale.model.user.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import org.springframework.data.mongodb.core.MongoTemplate; +import org.springframework.data.mongodb.core.SimpleMongoClientDatabaseFactory; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@EnabledIfEnvironmentVariable(named = "FINANCE_TEST_MONGO_URI", matches = ".+") +class ProviderCostEvidenceIntegrationTest { + MongoClient client; MongoTemplate mongo; StripeGatewayService gateway; ProviderCostEvidenceService service; User reviewer; + ProviderCostEvidenceService.ImportRequest request = new ProviderCostEvidenceService.ImportRequest("txn_fee", "acct_platform", true, "Actual fee evidence reviewed; no creator attribution."); + @BeforeEach void setup() { + client = MongoClients.create(System.getenv("FINANCE_TEST_MONGO_URI")); + mongo = new MongoTemplate(new SimpleMongoClientDatabaseFactory(client, "cost_test_" + UUID.randomUUID().toString().replace("-", ""))); + mongo.createCollection(ProviderCostEvidence.class); gateway = mock(StripeGatewayService.class); service = new ProviderCostEvidenceService(gateway, mongo); + reviewer = new User(); reviewer.setId("reviewer"); reviewer.setAdminPermissions(Set.of(AdminPermission.PLATFORM_FINANCE_MANAGE)); + when(gateway.isTestMode()).thenReturn(true); when(gateway.isReconciliationEnabled()).thenReturn(true); when(gateway.getExpectedPlatformAccountId()).thenReturn("acct_platform"); + when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(true); when(gateway.getBalance()).thenReturn(Map.of("object", "balance", "livemode", false)); + when(gateway.getBalanceTransaction("txn_fee")).thenReturn(StripeCostEvidenceTest.fee()); + } + @AfterEach void cleanup() { if (mongo != null) mongo.getDb().drop(); if (client != null) client.close(); } + @Test void replayPreservesFirstEvidenceAndConflictCannotOverwrite() { + var first = service.retrieveAndImport(reviewer, request); + var repeated = service.retrieveAndImport(reviewer, new ProviderCostEvidenceService.ImportRequest("txn_fee", "acct_platform", true, "A later observer sees the same evidence.")); + assertEquals(first, repeated); assertEquals(request.reason(), repeated.reason()); + var altered = StripeCostEvidenceTest.fee(); altered.put("amount", -201); altered.put("net", -201); when(gateway.getBalanceTransaction("txn_fee")).thenReturn(altered); + assertThrows(IllegalArgumentException.class, () -> service.retrieveAndImport(reviewer, request)); + assertEquals(first, mongo.findById(first.id(), ProviderCostEvidence.class)); assertEquals(1, service.list(reviewer).size()); + assertEquals(0, mongo.getCollection("creator_wallets").countDocuments()); assertEquals(0, mongo.getCollection("finance_ledger_entries").countDocuments()); + } + @Test void simultaneousImportsProduceOneImmutableUnallocatedCost() throws Exception { + var gate = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var futures = new ArrayList<Future<ProviderCostEvidence>>(); + for (int i = 0; i < 8; i++) futures.add(executor.submit(() -> { gate.await(); return service.retrieveAndImport(reviewer, request); })); + gate.countDown(); var first = futures.getFirst().get(10, TimeUnit.SECONDS); + for (var future : futures) assertEquals(first, future.get(10, TimeUnit.SECONDS)); + } + assertEquals(1, service.list(reviewer).size()); assertEquals("UNALLOCATED", service.list(reviewer).getFirst().allocationStatus()); + assertEquals(0, mongo.getCollection("creator_wallets").countDocuments()); assertEquals(0, mongo.getCollection("finance_ledger_entries").countDocuments()); + } + @Test void testAndLiveEvidenceCannotAlias() { + var test = service.retrieveAndImport(reviewer, request); + when(gateway.isTestMode()).thenReturn(false); when(gateway.getBalance()).thenReturn(Map.of("object", "balance", "livemode", true)); + var live = service.retrieveAndImport(reviewer, new ProviderCostEvidenceService.ImportRequest("txn_fee", "acct_platform", false, "Different live scope fixture.")); + assertNotEquals(test.id(), live.id()); assertEquals(2, service.list(reviewer).size()); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/ProviderCostEvidenceServiceTest.java b/backend/src/test/java/net/modtale/service/finance/ProviderCostEvidenceServiceTest.java new file mode 100644 index 000000000..8eb1f3946 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/ProviderCostEvidenceServiceTest.java @@ -0,0 +1,51 @@ +package net.modtale.service.finance; + +import java.util.*; +import net.modtale.model.finance.ProviderCostEvidence; +import net.modtale.model.user.*; +import org.junit.jupiter.api.*; +import org.springframework.data.mongodb.core.MongoTemplate; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ProviderCostEvidenceServiceTest { + StripeGatewayService gateway; MongoTemplate mongo; ProviderCostEvidenceService service; User reviewer; + ProviderCostEvidenceService.ImportRequest request = new ProviderCostEvidenceService.ImportRequest("txn_fee", "acct_platform", true, "Verify actual service fee; attribution remains unreviewed."); + @BeforeEach void setup() { + gateway = mock(StripeGatewayService.class); mongo = mock(MongoTemplate.class); service = new ProviderCostEvidenceService(gateway, mongo); + reviewer = new User(); reviewer.setId("reviewer"); reviewer.setAdminPermissions(Set.of(AdminPermission.PLATFORM_FINANCE_MANAGE)); + when(gateway.isTestMode()).thenReturn(true); when(gateway.isReconciliationEnabled()).thenReturn(true); + when(gateway.getExpectedPlatformAccountId()).thenReturn("acct_platform"); when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(true); + when(gateway.getBalance()).thenReturn(Map.of("object", "balance", "livemode", false)); + when(gateway.getBalanceTransaction("txn_fee")).thenReturn(StripeCostEvidenceTest.fee()); + when(mongo.insert(any(ProviderCostEvidence.class))).thenAnswer(invocation -> invocation.getArgument(0)); + } + @Test void recordsCanonicalEvidenceWithNoAllocationAndAuthenticatedReviewer() { + var evidence = service.retrieveAndImport(reviewer, request); + assertEquals("UNALLOCATED", evidence.allocationStatus()); assertEquals("reviewer", evidence.recordedBy()); assertEquals(200, evidence.costMinorUnits()); + assertEquals("stripe:test:acct_platform:provider-cost:txn_fee", evidence.id()); + verify(gateway).verifyPlatformAccountId("acct_platform"); verify(mongo).insert(evidence); verifyNoMoreInteractions(mongo); + } + @Test void nonReviewerCannotReadOrImportOrCallProvider() { + var user = new User(); user.setId("owner"); + assertThrows(SecurityException.class, () -> service.list(user)); assertThrows(SecurityException.class, () -> service.retrieveAndImport(user, request)); + verifyNoInteractions(gateway, mongo); + } + @Test void wrongAccountAndModeNeverRetrieveEvidence() { + when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(false); + assertThrows(IllegalStateException.class, () -> service.retrieveAndImport(reviewer, request)); + when(gateway.verifyPlatformAccountId("acct_platform")).thenReturn(true); when(gateway.isTestMode()).thenReturn(false); + assertThrows(IllegalStateException.class, () -> service.retrieveAndImport(reviewer, request)); + verify(gateway, never()).getBalanceTransaction(any()); verifyNoInteractions(mongo); + } + @Test void missingOrWrongBalanceModeNeverRetrievesEvidence() { + for (var balance : List.of(Map.<String,Object>of("object", "balance"), Map.<String,Object>of("object", "balance", "livemode", true), Map.<String,Object>of("object", "wrong", "livemode", false))) { + when(gateway.getBalance()).thenReturn(balance); assertThrows(IllegalStateException.class, () -> service.retrieveAndImport(reviewer, request)); + } + verify(gateway, never()).getBalanceTransaction(any()); verifyNoInteractions(mongo); + } + @Test void unsupportedProviderDataNeverReachesStorage() { + var data = StripeCostEvidenceTest.fee(); data.put("type", "charge"); when(gateway.getBalanceTransaction("txn_fee")).thenReturn(data); + assertThrows(IllegalArgumentException.class, () -> service.retrieveAndImport(reviewer, request)); verifyNoInteractions(mongo); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/RecurringStateConcurrencyIntegrationTest.java b/backend/src/test/java/net/modtale/service/finance/RecurringStateConcurrencyIntegrationTest.java new file mode 100644 index 000000000..edb03a364 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/RecurringStateConcurrencyIntegrationTest.java @@ -0,0 +1,48 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.time.Instant; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; + +@EnabledIfEnvironmentVariable(named = "FINANCE_TEST_MONGO_URI", matches = ".+") +class RecurringStateConcurrencyIntegrationTest extends FinancePipelineFixture { + @Test void slowOlderActiveResponseCannotUndoCompletedCancellation() throws Exception { assertLatestWins("canceled", false); } + @Test void slowOlderResponseCannotRemoveScheduledCancellation() throws Exception { assertLatestWins("active", true); } + private void assertLatestWins(String latestStatus, boolean cancelAtPeriodEnd) throws Exception { + var intent = checkout(true); recurring.registerCheckout(intent, session(intent)); + var firstInFlight = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1); var calls = new AtomicInteger(); + when(gateway.getSubscription("sub_synthetic")).thenAnswer(call -> { + if (calls.incrementAndGet() == 1) { + firstInFlight.countDown(); assertTrue(releaseFirst.await(10, TimeUnit.SECONDS)); + return providerState("active", false); + } + return providerState(latestStatus, cancelAtPeriodEnd); + }); + try (var executor = Executors.newSingleThreadExecutor()) { + var old = executor.submit(() -> { try { recurring.refreshSubscription("sub_synthetic"); return true; } catch (IllegalArgumentException stale) { return false; } }); + try { + assertTrue(firstInFlight.await(10, TimeUnit.SECONDS)); recurring.refreshSubscription("sub_synthetic"); + var latest = subscriptions.findById("sub_synthetic").orElseThrow(); assertEquals(latestStatus, latest.getStatus()); assertEquals(cancelAtPeriodEnd, latest.isCancelAtPeriodEnd()); + } finally { releaseFirst.countDown(); } + assertFalse(old.get(10, TimeUnit.SECONDS)); + } + var persisted = subscriptions.findById("sub_synthetic").orElseThrow(); assertEquals(latestStatus, persisted.getStatus()); assertEquals(cancelAtPeriodEnd, persisted.isCancelAtPeriodEnd()); + recurring.refreshSubscription("sub_synthetic"); assertEquals(0, ledger.count()); assertEquals(0, available()); + } + @Test void revisionAdvancesPastStoredTimestampEvenWhenWallClockHasNotCaughtUp() { + var intent = checkout(true); recurring.registerCheckout(intent, session(intent)); + var subscription = subscriptions.findById("sub_synthetic").orElseThrow(); + Instant ahead = Instant.now().plusSeconds(60).truncatedTo(java.time.temporal.ChronoUnit.MILLIS); subscription.setUpdatedAt(ahead); subscriptions.save(subscription); + when(gateway.getSubscription("sub_synthetic")).thenReturn(providerState("active", true)); + recurring.refreshSubscription("sub_synthetic"); recurring.refreshSubscription("sub_synthetic"); + assertEquals(ahead.plusMillis(2), subscriptions.findById("sub_synthetic").orElseThrow().getUpdatedAt()); + } + private Map<String, Object> providerState(String status, boolean cancel) { + return Map.of("id", "sub_synthetic", "customer", "cus_synthetic", "livemode", false, "status", status, "cancel_at_period_end", cancel); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/RecurringSupportServiceTest.java b/backend/src/test/java/net/modtale/service/finance/RecurringSupportServiceTest.java new file mode 100644 index 000000000..54adf841a --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/RecurringSupportServiceTest.java @@ -0,0 +1,97 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import net.modtale.model.finance.CreatorSupportSubscription; +import net.modtale.model.finance.FinanceLedgerEntry; +import net.modtale.model.user.User; +import net.modtale.repository.finance.CreatorSupportSubscriptionRepository; +import net.modtale.repository.finance.DonationIntentRepository; +import net.modtale.repository.finance.FinanceLedgerEntryRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.dao.DuplicateKeyException; + +class RecurringSupportServiceTest { + private CreatorSupportSubscriptionRepository subscriptions; + private FinanceLedgerEntryRepository ledger; + private StripeGatewayService gateway; + private RecurringSupportService service; + private Map<String, Object> invoice; + @BeforeEach void setup() { + subscriptions = mock(CreatorSupportSubscriptionRepository.class); ledger = mock(FinanceLedgerEntryRepository.class); gateway = mock(StripeGatewayService.class); + when(gateway.isTestMode()).thenReturn(true); when(gateway.getPlatformAccountId()).thenReturn("acct_platform"); + service = new RecurringSupportService(subscriptions, mock(DonationIntentRepository.class), ledger, gateway, new RevenueOpsSupport(), mock(net.modtale.service.project.query.ProjectService.class)); + var subscription = new CreatorSupportSubscription(); subscription.setId("sub_test"); subscription.setProviderAccountId("acct_platform"); subscription.setDonorUserId("donor"); subscription.setCreatorId("creator"); subscription.setProjectId("project"); subscription.setCustomerId("cus_test"); subscription.setAmountCents(500); subscription.setPlatformCutBps(1000); subscription.setCurrency("usd"); subscription.setTestMode(true); subscription.setStatus("active"); + when(subscriptions.findById("sub_test")).thenReturn(Optional.of(subscription)); + when(subscriptions.updateProviderState(anyString(), any(), anyString(), anyBoolean(), anyString(), anyString(), anyBoolean(), any())).thenReturn(1L); + when(gateway.getSubscription("sub_test")).thenReturn(Map.of("id", "sub_test", "customer", "cus_test", "status", "active", "livemode", false)); + invoice = Map.of("id", "in_test", "status", "paid", "parent", Map.of("type", "subscription_details", "subscription_details", Map.of("subscription", "sub_test")), + "currency", "usd", "customer", "cus_test", "livemode", false, "amount_paid", 500); + } + private Map<String, Object> payment() { return Map.of("status", "paid", "amount_paid", 500, "payment", Map.of("type", "payment_intent", "payment_intent", "pi_test")); } + @Test void repeatedPaidInvoiceCreatesOnlySourceIdentifiedPendingObservation() { + when(gateway.getInvoicePayments("in_test")).thenReturn(Map.of("has_more", false, "data", List.of(payment()))); + when(ledger.insert(any(FinanceLedgerEntry.class))).thenThrow(new DuplicateKeyException("duplicate")); + assertDoesNotThrow(() -> service.handlePaidInvoice(invoice)); + var captured = ArgumentCaptor.forClass(FinanceLedgerEntry.class); verify(ledger).insert(captured.capture()); + assertEquals("stripe:test:acct_platform:invoice:in_test", captured.getValue().getId()); assertEquals(450, captured.getValue().getCreatorCents()); + assertEquals("pi_test", captured.getValue().getMetadata().get("paymentIntentId")); + assertEquals(FinanceLedgerEntry.EntryStatus.PENDING, captured.getValue().getStatus()); + } + @Test void creditFundedOrMultiplePaymentsNeverBecomeFabricatedCashRevenue() { + when(gateway.getInvoicePayments("in_test")).thenReturn(Map.of("has_more", false, "data", List.of())); + assertThrows(IllegalArgumentException.class, () -> service.handlePaidInvoice(invoice)); + when(gateway.getInvoicePayments("in_test")).thenReturn(Map.of("has_more", false, "data", List.of(payment(), payment()))); + assertThrows(IllegalArgumentException.class, () -> service.handlePaidInvoice(invoice)); verifyNoInteractions(ledger); + } + @Test void billingPortalCannotBeOpenedByAnotherDonor() { + var user = new User(); user.setId("other"); + assertThrows(SecurityException.class, () -> service.openBillingPortal(user, "sub_test")); + verifyNoInteractions(gateway); + } + @Test void subscriptionUpdatesRetrieveCurrentProviderStateInsteadOfRegressingFromOldEvents() { + when(gateway.getSubscription("sub_test")).thenReturn(Map.of("id", "sub_test", "customer", "cus_test", "status", "canceled", "cancel_at_period_end", false, "livemode", false)); + service.refreshSubscription("sub_test"); + verify(subscriptions).updateProviderState(eq("sub_test"), any(), eq("acct_platform"), eq(true), eq("cus_test"), eq("canceled"), eq(false), any()); + verify(subscriptions, never()).save(any()); + } + @Test void anAlreadyCanceledSubscriptionCannotBeReactivatedByStaleProviderData() { + subscriptions.findById("sub_test").orElseThrow().setStatus("canceled"); + assertThrows(IllegalArgumentException.class, () -> service.refreshSubscription("sub_test")); + verify(subscriptions, never()).updateProviderState(anyString(), any(), anyString(), anyBoolean(), anyString(), anyString(), anyBoolean(), any()); + } + @Test void aConcurrentUpdateRejectsTheOlderResponseInsteadOfOverwritingIt() { + when(subscriptions.updateProviderState(anyString(), any(), anyString(), anyBoolean(), anyString(), anyString(), anyBoolean(), any())).thenReturn(0L); + assertThrows(IllegalArgumentException.class, () -> service.refreshSubscription("sub_test")); + verify(subscriptions, never()).save(any()); + } + @Test void invoicesNeedExplicitModeAndMatchingProviderScope() { + var missingMode = new java.util.HashMap<>(invoice); missingMode.remove("livemode"); + assertThrows(IllegalArgumentException.class, () -> service.handlePaidInvoice(missingMode)); + when(gateway.getPlatformAccountId()).thenReturn("acct_other"); + assertThrows(IllegalArgumentException.class, () -> service.handlePaidInvoice(invoice)); + verifyNoInteractions(ledger); + verify(gateway, never()).getInvoicePayments(anyString()); + } + @Test void absentInvoiceModeMustNotBeInterpretedAsLive() { + subscriptions.findById("sub_test").orElseThrow().setTestMode(false); + when(gateway.isTestMode()).thenReturn(false); + var missingMode = new java.util.HashMap<>(invoice); missingMode.remove("livemode"); + assertThrows(IllegalArgumentException.class, () -> service.handlePaidInvoice(missingMode)); + verify(gateway, never()).getInvoicePayments(anyString()); + verifyNoInteractions(ledger); + } + @Test void subscriptionRefreshCannotTreatAbsentModeAsLiveOrReadAnotherAccount() { + when(gateway.getSubscription("sub_test")).thenReturn(Map.of("id", "sub_test", "customer", "cus_test", "status", "active")); + assertThrows(IllegalArgumentException.class, () -> service.refreshSubscription("sub_test")); + when(gateway.getPlatformAccountId()).thenReturn("acct_other"); + assertThrows(IllegalArgumentException.class, () -> service.refreshSubscription("sub_test")); + verify(gateway, times(1)).getSubscription("sub_test"); + verify(subscriptions, never()).save(any()); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/RevenuePoolAllocatorTest.java b/backend/src/test/java/net/modtale/service/finance/RevenuePoolAllocatorTest.java new file mode 100644 index 000000000..d7c13f235 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/RevenuePoolAllocatorTest.java @@ -0,0 +1,31 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class RevenuePoolAllocatorTest { + @Test void distributesOnlyTheFundedCreatorShareAndPreservesRounding() { + var result = RevenuePoolAllocator.allocate(101, 7500, Map.of("a", 1L, "b", 1L, "c", 1L)); + assertEquals(76, result.creatorPoolCents()); + assertEquals(25, result.platformCents()); + assertEquals(Map.of("a", 26L, "b", 25L, "c", 25L), result.projectCents()); + assertEquals(101, result.platformCents() + result.projectCents().values().stream().mapToLong(Long::longValue).sum()); + } + @Test void allocationSnapshotDoesNotDependOnMapOrderOrFutureShareChanges() { + var first = RevenuePoolAllocator.allocate(10000, 7500, Map.of("b", 2L, "a", 1L)); + var second = RevenuePoolAllocator.allocate(10000, 7500, Map.of("a", 1L, "b", 2L)); + assertEquals(first, second); + assertEquals(7500, first.creatorShareBps()); + assertThrows(UnsupportedOperationException.class, () -> first.projectCents().put("extra", 100L)); + } + @Test void handlesLargeLegitimateActivityWithoutOverflow() { + var result = RevenuePoolAllocator.allocate(Long.MAX_VALUE, 7500, Map.of("a", Long.MAX_VALUE, "b", Long.MAX_VALUE)); + assertEquals(Long.MAX_VALUE, result.platformCents() + result.projectCents().values().stream().mapToLong(Long::longValue).sum()); + } + @Test void rejectsUnverifiedEmptyOrInvalidActivity() { + assertThrows(IllegalArgumentException.class, () -> RevenuePoolAllocator.allocate(100, 7500, Map.of())); + assertThrows(IllegalArgumentException.class, () -> RevenuePoolAllocator.allocate(100, 7500, Map.of("a", -1L))); + assertThrows(IllegalArgumentException.class, () -> RevenuePoolAllocator.allocate(-100, 7500, Map.of("a", 1L))); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/StripeCostEvidenceTest.java b/backend/src/test/java/net/modtale/service/finance/StripeCostEvidenceTest.java new file mode 100644 index 000000000..e02602a9d --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/StripeCostEvidenceTest.java @@ -0,0 +1,55 @@ +package net.modtale.service.finance; + +import java.time.Instant; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import static org.junit.jupiter.api.Assertions.*; + +class StripeCostEvidenceTest { + static Map<String, Object> fee() { + Map<String, Object> data = new HashMap<>(Map.of("id", "txn_fee", "object", "balance_transaction", "type", "stripe_fee", "reporting_category", "fee", "status", "available", "currency", "usd", "amount", -200, "fee", 0, "net", -200, "created", 1700000000)); + data.put("available_on", 1700000000); data.put("source", null); data.put("fee_details", List.of()); return data; + } + @Test void standaloneCostKeepsSignedAmountsAndDoesNotInventCreatorAttribution() { + var snapshot = StripeCostEvidence.parse("txn_fee", fee(), Instant.now()); + assertEquals(200, snapshot.cost()); assertEquals(-200, snapshot.net()); assertNull(snapshot.source()); + var refund = fee(); refund.put("amount", 200); refund.put("net", 200); + assertEquals(-200, StripeCostEvidence.parse("txn_fee", refund, Instant.now()).cost()); + } + @ParameterizedTest @ValueSource(strings = {"charge", "payment", "refund", "payment_refund", "adjustment", "transfer", "payout", "application_fee", "application_fee_refund", "stripe_fx_fee", "tax_fee", "unknown"}) + void principalAndUnsupportedCategoriesCannotBecomeExtraFees(String type) { + var value = fee(); value.put("type", type); + assertThrows(IllegalArgumentException.class, () -> StripeCostEvidence.parse("txn_fee", value, Instant.now())); + } + @ParameterizedTest @ValueSource(strings = {"ch_charge", "py_payment", "re_refund", "dp_dispute", "du_dispute", "tr_transfer", "po_payout", "pi_payment", "in_invoice"}) + void existingFinancialSourcesCannotBeImportedAgainAsServiceCosts(String source) { + var value = fee(); value.put("source", source); + assertThrows(IllegalArgumentException.class, () -> StripeCostEvidence.parse("txn_fee", value, Instant.now())); + } + @Test void malformedFractionalMissingOrInconsistentAmountsAreRejected() { + for (Object invalid : List.of(-1.5, "-200", 0, Long.MIN_VALUE, Long.MAX_VALUE)) { + var value = fee(); value.put("amount", invalid); assertThrows(IllegalArgumentException.class, () -> StripeCostEvidence.parse("txn_fee", value, Instant.now())); + } + for (String missing : List.of("amount", "fee", "net", "source", "fee_details", "created", "available_on")) { + var value = fee(); value.remove(missing); assertThrows(IllegalArgumentException.class, () -> StripeCostEvidence.parse("txn_fee", value, Instant.now()), missing); + } + for (var mismatch : Map.<String, Object>of("fee", 1, "net", -201, "currency", "xxx", "status", "pending", "reporting_category", "charge", "id", "txn_other", "object", "charge", "fee_details", List.of(Map.of("amount", 1))).entrySet()) { + var value = fee(); value.put(mismatch.getKey(), mismatch.getValue()); + assertThrows(IllegalArgumentException.class, () -> StripeCostEvidence.parse("txn_fee", value, Instant.now()), mismatch.getKey()); + } + } + @Test void futureAvailabilityCannotBeRecordedAsSettled() { + var value = fee(); value.put("available_on", Instant.now().plusSeconds(3600).getEpochSecond()); + assertThrows(IllegalArgumentException.class, () -> StripeCostEvidence.parse("txn_fee", value, Instant.now())); + } + @Test void digestBindsAccountModeAndExactFinancialEvidence() { + var snapshot = StripeCostEvidence.parse("txn_fee", fee(), Instant.now()); + assertEquals(snapshot.digest("acct_one", true), snapshot.digest("acct_one", true)); + assertNotEquals(snapshot.digest("acct_one", true), snapshot.digest("acct_two", true)); + assertNotEquals(snapshot.digest("acct_one", true), snapshot.digest("acct_one", false)); + var altered = fee(); altered.put("amount", -201); altered.put("net", -201); + assertNotEquals(snapshot.digest("acct_one", true), StripeCostEvidence.parse("txn_fee", altered, Instant.now()).digest("acct_one", true)); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/StripeGatewayHttpContractTest.java b/backend/src/test/java/net/modtale/service/finance/StripeGatewayHttpContractTest.java new file mode 100644 index 000000000..8c7d88b5a --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/StripeGatewayHttpContractTest.java @@ -0,0 +1,263 @@ +package net.modtale.service.finance; + +import com.sun.net.httpserver.HttpServer; +import java.net.InetSocketAddress; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.reactive.function.client.WebClient; +import static org.junit.jupiter.api.Assertions.*; + +/** Exercises real HTTP encoding against a loopback stub, never Stripe or real credentials. */ +class StripeGatewayHttpContractTest { + private static final String FIXTURE_KEY = "sk_test_local_http_fixture_not_a_real_key"; + private record Reply(int status, String body, boolean disconnect) {} + private record Request(String method, String path, Map<String, String> query, Map<String, String> form, + String authorization, String version, String idempotency, String contentType) {} + private HttpServer server; + private ExecutorService executor; + private StripeGatewayService gateway; + private final BlockingQueue<Request> requests = new LinkedBlockingQueue<>(); + private final AtomicReference<Reply> reply = new AtomicReference<>(); + + @BeforeEach void setup() throws Exception { + reply.set(new Reply(200, "{\"id\":\"cs_fixture\",\"url\":\"https://checkout.stripe.test/fixture\"}", false)); + server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + executor = Executors.newVirtualThreadPerTaskExecutor(); server.setExecutor(executor); + server.createContext("/", exchange -> { + try { + var headers = exchange.getRequestHeaders(); + requests.add(new Request(exchange.getRequestMethod(), exchange.getRequestURI().getPath(), + decode(exchange.getRequestURI().getRawQuery()), + decode(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8)), + headers.getFirst("Authorization"), headers.getFirst("Stripe-Version"), + headers.getFirst("Idempotency-Key"), headers.getFirst("Content-Type"))); + Reply response = reply.get(); + if (!response.disconnect()) { + byte[] bytes = response.body().getBytes(StandardCharsets.UTF_8); + exchange.getResponseHeaders().set("Content-Type", "application/json"); + exchange.sendResponseHeaders(response.status(), bytes.length); + exchange.getResponseBody().write(bytes); + } + } finally { exchange.close(); } + }); + server.start(); + // The test changes only the destination; the gateway applies its own production headers. + gateway = new StripeGatewayService(WebClient.builder() + .baseUrl("http://127.0.0.1:" + server.getAddress().getPort() + "/v1")); + ReflectionTestUtils.setField(gateway, "stripeSecretKey", FIXTURE_KEY); + } + @AfterEach void cleanup() { if (server != null) server.stop(0); if (executor != null) executor.close(); } + private Request take() throws Exception { var request = requests.poll(2, TimeUnit.SECONDS); assertNotNull(request, "Expected a loopback HTTP request"); return request; } + private static Map<String, String> decode(String input) { + Map<String, String> result = new LinkedHashMap<>(); + if (input == null || input.isEmpty()) return result; + for (String part : input.split("&")) { + String[] pair = part.split("=", 2); + result.put(URLDecoder.decode(pair[0], StandardCharsets.UTF_8), URLDecoder.decode(pair.length == 2 ? pair[1] : "", StandardCharsets.UTF_8)); + } + return result; + } + private static void assertProviderHeaders(Request request) { + assertEquals("2026-08-26.dahlia", request.version()); + assertEquals("Basic " + Base64.getEncoder().encodeToString((FIXTURE_KEY + ":").getBytes(StandardCharsets.UTF_8)), request.authorization()); + } + + @ParameterizedTest @ValueSource(booleans = {false, true}) + void checkoutEncodesExactAmountsMetadataAndSubscriptionFrequency(boolean recurring) throws Exception { + var result = gateway.createOrSimulateDonationCheckout("intent_fixture", "Café + Tools & Maps", 1234, recurring, + "https://modtale.test/success?x=1&y=2", "https://modtale.test/cancel", "usd", false); + assertTrue(result.success()); assertEquals("cs_fixture", result.id()); + assertEquals("https://checkout.stripe.test/fixture", result.url()); + Request request = take(); assertProviderHeaders(request); + assertEquals("POST", request.method()); assertEquals("/v1/checkout/sessions", request.path()); + assertTrue(request.contentType().startsWith("application/x-www-form-urlencoded")); + assertEquals("donation-checkout-intent_fixture", request.idempotency()); + assertEquals("1234", request.form().get("line_items[0][price_data][unit_amount]")); + assertEquals("usd", request.form().get("line_items[0][price_data][currency]")); + assertEquals("Support Café + Tools & Maps on Modtale", request.form().get("line_items[0][price_data][product_data][name]")); + assertEquals("https://modtale.test/success?x=1&y=2", request.form().get("success_url")); + assertEquals("intent_fixture", request.form().get("metadata[intentId]")); + assertEquals(recurring ? "subscription" : "payment", request.form().get("mode")); + assertEquals("intent_fixture", request.form().get((recurring ? "subscription_data" : "payment_intent_data") + "[metadata][intentId]")); + assertEquals(recurring ? "month" : null, request.form().get("line_items[0][price_data][recurring][interval]")); + } + @Test void transferSerializationPreservesDurableIdentityAndIntegerCents() throws Exception { + reply.set(new Reply(200, "{\"id\":\"tr_fixture\",\"amount\":1234,\"currency\":\"usd\",\"destination\":\"acct_creator\",\"livemode\":false}", false)); + var result = gateway.createTransfer("acct_creator", 1234, "usd", "Fixture payout", Map.of("payoutId", "payout_fixture", "recipientId", "creator_fixture"), false, "durable-payout-key"); + assertTrue(result.success()); assertEquals("tr_fixture", result.id()); + Request request = take(); assertProviderHeaders(request); + assertEquals("POST", request.method()); assertEquals("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/v1/transfers", request.path()); + assertEquals("durable-payout-key", request.idempotency()); + assertEquals("1234", request.form().get("amount")); assertEquals("usd", request.form().get("currency")); + assertEquals("acct_creator", request.form().get("destination")); + assertEquals("payout_fixture", request.form().get("metadata[payoutId]")); + assertEquals("creator_fixture", request.form().get("metadata[recipientId]")); + } + @Test void realTransferWithoutDurableKeyIsRejectedBeforeHttp() { + assertFalse(gateway.createTransfer("acct_creator", 1000, "usd", "Fixture", Map.of(), false, null).success()); + assertTrue(requests.isEmpty()); + } + @Test void simulationNeverSendsARequestAndRemainsExplicitlyUnpaid() { + assertTrue(gateway.createOrSimulateDonationCheckout("fixture", "Fixture", 500, false, "https://modtale.test", "https://modtale.test", "usd", true).success()); + var session = gateway.getCheckoutSession("sim_cs_fixture", true); + assertEquals(true, session.get("simulated")); assertEquals("unpaid", session.get("payment_status")); + assertEquals("open", session.get("status")); assertTrue(requests.isEmpty()); + } + @Test void actualFeeLookupRequestsExpandedBalanceTransaction() throws Exception { + reply.set(new Reply(200, "{\"id\":\"pi_fixture\",\"status\":\"succeeded\"}", false)); + assertEquals("pi_fixture", gateway.getPaymentWithBalanceTransaction("pi_fixture").get("id")); + Request request = take(); assertProviderHeaders(request); + assertEquals("GET", request.method()); assertEquals("/v1/payment_intents/pi_fixture", request.path()); + assertEquals("latest_charge.balance_transaction", request.query().get("expand[]")); + } + @Test void refundAndInvoiceQueriesUseCurrentDocumentedProviderCollections() throws Exception { + reply.set(new Reply(200, "{\"data\":[],\"has_more\":false}", false)); + gateway.getChargeRefunds("ch_fixture", "re_previous"); + Request refunds = take(); assertProviderHeaders(refunds); + assertEquals("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/v1/refunds", refunds.path()); assertEquals("ch_fixture", refunds.query().get("charge")); + assertEquals("100", refunds.query().get("limit")); assertEquals("re_previous", refunds.query().get("starting_after")); + assertEquals("data.balance_transaction", refunds.query().get("expand[]")); + gateway.getInvoicePayments("in_fixture"); Request invoices = take(); assertProviderHeaders(invoices); + assertEquals("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/v1/invoice_payments", invoices.path()); assertEquals("in_fixture", invoices.query().get("invoice")); + assertEquals("paid", invoices.query().get("status")); assertEquals("100", invoices.query().get("limit")); + } + @Test void billingPortalSendsOnlyTheServerSelectedCustomerAndReturnUrl() throws Exception { + gateway.createBillingPortalSession("cus_fixture", "https://modtale.test/dashboard/finance"); + Request request = take(); assertProviderHeaders(request); + assertEquals("/v1/billing_portal/sessions", request.path()); + assertEquals(Map.of("customer", "cus_fixture", "return_url", "https://modtale.test/dashboard/finance"), request.form()); + } + @ParameterizedTest @ValueSource(ints = {400, 401, 429, 500}) + void providerErrorsAreNotReportedAsSuccessfulTransfersOrLeakedToCallers(int status) throws Exception { + reply.set(new Reply(status, "{\"error\":{\"message\":\"provider-private-detail\"}}", false)); + var result = gateway.createTransfer("acct_creator", 1000, "usd", "Fixture", Map.of(), false, "stable-key"); + assertFalse(result.success()); assertNull(result.id()); assertFalse(result.error().contains("provider-private-detail")); + assertEquals("stable-key", take().idempotency()); + } + @Test void lostResponseCannotBecomeSuccessAndAnyNetworkRetryKeepsTheSameKey() throws Exception { + reply.set(new Reply(200, "", true)); + var result = gateway.createTransfer("acct_creator", 1000, "usd", "Fixture", Map.of(), false, "stable-uncertain-key"); + assertFalse(result.success()); assertNull(result.id()); + assertEquals("stable-uncertain-key", take().idempotency()); + for (Request retry : requests) assertEquals("stable-uncertain-key", retry.idempotency()); + } + @Test void liveKillSwitchStopsNewMoneyButAllowsExistingPaymentReconciliation() throws Exception { + ReflectionTestUtils.setField(gateway, "stripeSecretKey", "sk_live_local_http_fixture_not_a_real_key"); + ReflectionTestUtils.setField(gateway, "livePaymentsEnabled", false); + assertFalse(gateway.createTransfer("acct_creator", 1000, "usd", "Fixture", Map.of(), false, "stable-key").success()); + assertFalse(gateway.createOrSimulateDonationCheckout("intent", "Fixture", 500, false, "https://modtale.test", "https://modtale.test", "usd", false).success()); + assertTrue(requests.isEmpty()); + reply.set(new Reply(200, "{\"id\":\"ch_fixture\",\"livemode\":true}", false)); + assertEquals("ch_fixture", gateway.getCharge("ch_fixture").get("id")); + assertEquals("/v1/charges/ch_fixture", take().path()); + } + @Test void payoutAccountVerificationBypassesCachedScopeAndRejectsChangedAccount() throws Exception { + reply.set(new Reply(200, "{\"id\":\"acct_original\"}", false)); + assertEquals("acct_original", gateway.getPlatformAccountId()); + take(); + reply.set(new Reply(200, "{\"id\":\"acct_changed\"}", false)); + assertFalse(gateway.verifyPlatformAccountId("acct_original")); + Request changed = take(); assertProviderHeaders(changed); + assertEquals("GET", changed.method()); assertEquals("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/v1/account", changed.path()); + assertTrue(gateway.verifyPlatformAccountId("acct_changed")); + assertEquals("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/v1/account", take().path()); + } + @ParameterizedTest @ValueSource(ints = {401, 429, 500}) + void payoutAccountVerificationFailsClosedOnProviderErrors(int status) throws Exception { + reply.set(new Reply(status, "{\"error\":{\"message\":\"private fixture detail\"}}", false)); + assertFalse(gateway.verifyPlatformAccountId("acct_expected")); + assertEquals("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/v1/account", take().path()); + } + @Test void knownTransferReconciliationUsesReadOnlyProviderLookup() throws Exception { + reply.set(new Reply(200, "{\"id\":\"tr_fixture\",\"amount\":1234,\"reversed\":false}", false)); + var transfer = gateway.getTransfer("tr_fixture"); + assertEquals("tr_fixture", transfer.get("id")); assertEquals(1234, transfer.get("amount")); + Request request = take(); assertProviderHeaders(request); + assertEquals("GET", request.method()); assertEquals("/v1/transfers/tr_fixture", request.path()); + assertTrue(request.form().isEmpty()); assertNull(request.idempotency()); + } + @Test void invalidReconciliationIdentifiersNeverReachTheProvider() { + for (String invalid : Arrays.asList(null, "", "acct_bad/path", "acct_", "acct_bad?query")) + assertFalse(gateway.verifyPlatformAccountId(invalid)); + for (String invalid : Arrays.asList(null, "", "tr_bad/path", "tr_", "tr_bad?query")) + assertTrue(gateway.getTransfer(invalid).isEmpty()); + assertTrue(requests.isEmpty()); + } + @Test void transferGroupSurvivesFormEncodingAlongsideItsAuditMetadata() throws Exception { + gateway.createTransfer("acct_creator", 1000, "usd", "Fixture", Map.of("transferGroup", "payout_fixture_group"), false, "stable-group-key"); + Request request = take(); + assertEquals("payout_fixture_group", request.form().get("transfer_group")); + assertEquals("payout_fixture_group", request.form().get("metadata[transferGroup]")); + assertEquals("stable-group-key", request.idempotency()); + } + @Test void chargeDisputeEnumerationUsesFullCursorPaginationWithoutDateFiltering() throws Exception { + reply.set(new Reply(200, "{\"data\":[],\"has_more\":false}", false)); + gateway.getChargeDisputes("ch_fixture", null); + Request first = take(); assertProviderHeaders(first); + assertEquals("GET", first.method()); assertEquals("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/v1/disputes", first.path()); + assertEquals(Map.of("charge", "ch_fixture", "limit", "100"), first.query()); + gateway.getChargeDisputes("ch_fixture", "dp_previous"); + assertEquals(Map.of("charge", "ch_fixture", "limit", "100", "starting_after", "dp_previous"), take().query()); + } + @Test void invalidDisputePaginationIdentifiersNeverReachProvider() { + assertTrue(gateway.getChargeDisputes("ch_fixture/path", null).isEmpty()); + assertTrue(gateway.getChargeDisputes("ch_fixture", "dp_bad?query").isEmpty()); + assertTrue(gateway.getChargeDisputes(null, null).isEmpty()); + assertTrue(requests.isEmpty()); + } + @Test void readinessProviderChecksOnlyReadExactConfigurationEndpoints() throws Exception { + reply.set(new Reply(200, "{}", false)); + gateway.getCurrentAccount(); gateway.getBalance(); gateway.getWebhookEndpoint("we_fixture"); gateway.getPortalConfiguration("bpc_fixture"); + for (String path : List.of("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/v1/account", "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/v1/balance", "/v1/webhook_endpoints/we_fixture", "/v1/billing_portal/configurations/bpc_fixture")) { + Request request = take(); assertProviderHeaders(request); + assertEquals("GET", request.method()); assertEquals(path, request.path()); + assertTrue(request.query().isEmpty()); assertTrue(request.form().isEmpty()); + } + } + @Test void invalidReadinessIdentifiersNeverReachProvider() { + for (String invalid : Arrays.asList(null, "", "we_", "we_bad/path", "we_bad?query")) assertTrue(gateway.getWebhookEndpoint(invalid).isEmpty()); + for (String invalid : Arrays.asList(null, "", "bpc_", "bpc_bad/path", "bpc_bad?query")) assertTrue(gateway.getPortalConfiguration(invalid).isEmpty()); + assertTrue(requests.isEmpty()); + } + @Test void configuredPortalSessionUsesPinnedConfigurationAndRejectsInvalidConfig() throws Exception { + ReflectionTestUtils.setField(gateway, "portalConfigurationId", "bpc_fixture"); + gateway.createBillingPortalSession("cus_fixture", "https://modtale.test/finance"); + assertEquals(Map.of("customer", "cus_fixture", "return_url", "https://modtale.test/finance", "configuration", "bpc_fixture"), take().form()); + ReflectionTestUtils.setField(gateway, "portalConfigurationId", "bpc_bad/path"); + assertFalse(gateway.createBillingPortalSession("cus_fixture", "https://modtale.test/finance").success()); + assertTrue(requests.isEmpty()); + } + @Test void claimableSandboxCredentialsRemainTestOnlyEvenWhenLiveSwitchIsOff() throws Exception { + ReflectionTestUtils.setField(gateway, "stripeSecretKey", "rkcs_local_http_fixture_not_a_real_key"); + ReflectionTestUtils.setField(gateway, "livePaymentsEnabled", false); + assertTrue(gateway.isAnonymousSandbox()); assertTrue(gateway.isTestMode()); assertFalse(gateway.isLiveMode()); + assertFalse(gateway.isOperational()); assertFalse(gateway.isCheckoutAvailable()); + assertTrue(gateway.createOrSimulateDonationCheckout("intent_fixture", "Fixture", 500, false, "https://modtale.test", "https://modtale.test", "usd", false).success()); + assertEquals("/v1/checkout/sessions", take().path()); + } + @Test void providerCostLookupReadsOnlyTheExactBalanceTransaction() throws Exception { + reply.set(new Reply(200, "{\"id\":\"txn_fixture\",\"object\":\"balance_transaction\"}", false)); + assertEquals("txn_fixture", gateway.getBalanceTransaction("txn_fixture").get("id")); + Request request = take(); assertProviderHeaders(request); + assertEquals("GET", request.method()); assertEquals("/v1/balance_transactions/txn_fixture", request.path()); + assertTrue(request.query().isEmpty()); assertTrue(request.form().isEmpty()); + } + @Test void invalidCostTransactionIdsNeverReachProvider() { + for (String invalid : Arrays.asList(null, "", "txn_", "txn_bad/path", "txn_bad?query")) assertTrue(gateway.getBalanceTransaction(invalid).isEmpty()); + assertTrue(requests.isEmpty()); + } + @Test void accountScopeComesFromCurrentPlatformAccountEndpoint() throws Exception { + reply.set(new Reply(200, "{\"id\":\"acct_platform_fixture\"}", false)); + assertEquals("acct_platform_fixture", gateway.getPlatformAccountId()); + assertEquals("acct_platform_fixture", gateway.getPlatformAccountId()); + Request request = take(); assertProviderHeaders(request); assertEquals("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/v1/account", request.path()); + assertTrue(requests.isEmpty(), "Stable configured credential may cache its verified account scope"); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/StripeReadinessServiceTest.java b/backend/src/test/java/net/modtale/service/finance/StripeReadinessServiceTest.java new file mode 100644 index 000000000..33e83a4c0 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/StripeReadinessServiceTest.java @@ -0,0 +1,85 @@ +package net.modtale.service.finance; + +import java.util.*; +import org.junit.jupiter.api.*; +import org.springframework.test.util.ReflectionTestUtils; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class StripeReadinessServiceTest { + StripeGatewayService gateway; + StripeReadinessService service; + @BeforeEach void setup() { + gateway = mock(StripeGatewayService.class); + when(gateway.isTestMode()).thenReturn(true); when(gateway.isReconciliationEnabled()).thenReturn(true); + when(gateway.getExpectedPlatformAccountId()).thenReturn("acct_fixture"); when(gateway.getPortalConfigurationId()).thenReturn("bpc_fixture"); + when(gateway.getCurrentAccount()).thenReturn(Map.of("id", "acct_fixture", "object", "account")); + when(gateway.getBalance()).thenReturn(Map.of("object", "balance", "livemode", false)); + when(gateway.getWebhookEndpoint("we_fixture")).thenReturn(endpoint()); + when(gateway.getPortalConfiguration("bpc_fixture")).thenReturn(portal()); + service = new StripeReadinessService(gateway, "whsec_fixture_never_return", "we_fixture", "https://api.modtale.test", "https://modtale.test"); + } + Map<String, Object> endpoint() { var value = new HashMap<String, Object>(Map.of("id", "we_fixture", "object", "webhook_endpoint", "status", "enabled", "livemode", false, + "api_version", StripeGatewayService.API_VERSION, "url", "https://api.modtale.test/api/v1/finance/webhooks/stripe", "enabled_events", new ArrayList<>(StripeWebhookEvents.REQUIRED))); value.put("application", null); return value; } + Map<String, Object> portal() { return new HashMap<>(Map.of("id", "bpc_fixture", "object", "billing_portal.configuration", "livemode", false, "active", true, + "features", Map.of("subscription_cancel", Map.of("enabled", true, "mode", "at_period_end"), "payment_method_update", Map.of("enabled", true)))); } + @Test void configurationNeverCallsProviderOrClaimsVerified() { + var result = service.configuration(); assertFalse(result.providerConfigurationVerified()); assertNull(result.verifiedAt()); + verify(gateway, never()).getCurrentAccount(); verify(gateway, never()).getWebhookEndpoint(any()); + assertFalse(result.toString().contains("whsec_fixture_never_return")); + } + @Test void verifiedConfigurationStillListsDeliveryAndBusinessChecks() { + var result = service.verifyProviderConfiguration(); assertTrue(result.providerConfigurationVerified()); assertNotNull(result.verifiedAt()); + assertFalse(result.livePaymentsEnabled()); assertEquals("TEST", result.mode()); + assertTrue(result.remainingChecks().stream().anyMatch(x -> x.contains("authentic signed platform webhook"))); + assertFalse(result.toString().contains("whsec_fixture_never_return")); + } + @Test void unknownCredentialsNeverReachProvider() { + when(gateway.isTestMode()).thenReturn(false); when(gateway.isReconciliationEnabled()).thenReturn(false); when(gateway.isEnabled()).thenReturn(true); + assertEquals("UNKNOWN", service.verifyProviderConfiguration().mode()); assertFalse(service.verifyProviderConfiguration().providerConfigurationVerified()); + verify(gateway, never()).getCurrentAccount(); + } + @Test void anonymousRestrictionsRemainExplicitAndBlocked() { + when(gateway.isAnonymousSandbox()).thenReturn(true); when(gateway.getCurrentAccount()).thenReturn(Map.of()); when(gateway.getBalance()).thenReturn(Map.of()); + var result = service.verifyProviderConfiguration(); assertFalse(result.providerConfigurationVerified()); + assertTrue(result.remainingChecks().stream().anyMatch(x -> x.contains("expire unless claimed"))); + } + @Test void everyProviderWebhookBindingIsRequired() { + for (var mismatch : Map.<String,Object>of("id", "we_other", "object", "other", "status", "disabled", "livemode", true, + "api_version", "2025-03-31.basil", "url", "https://elsewhere.test/stripe", "enabled_events", List.of("invoice.paid")).entrySet()) { + var data = endpoint(); data.put(mismatch.getKey(), mismatch.getValue()); when(gateway.getWebhookEndpoint("we_fixture")).thenReturn(data); + assertFalse(service.verifyProviderConfiguration().providerConfigurationVerified(), mismatch.getKey()); + } + var data = endpoint(); data.remove("livemode"); when(gateway.getWebhookEndpoint("we_fixture")).thenReturn(data); + assertFalse(service.verifyProviderConfiguration().providerConfigurationVerified()); + } + @Test void connectApplicationAndAbsentScopeMetadataDoNotPassEndpointFields() { + var endpoint = endpoint(); endpoint.put("application", "ca_fixture"); when(gateway.getWebhookEndpoint("we_fixture")).thenReturn(endpoint); + assertFalse(service.verifyProviderConfiguration().providerConfigurationVerified()); + endpoint.remove("application"); assertFalse(service.verifyProviderConfiguration().providerConfigurationVerified()); + assertTrue(service.configuration().remainingChecks().stream().anyMatch(x -> x.contains("delivery scope is not proven"))); + } + + @Test void accountModeAndPortalAllFailClosed() { + when(gateway.getCurrentAccount()).thenReturn(Map.of("id", "acct_wrong", "object", "account")); assertFalse(service.verifyProviderConfiguration().providerConfigurationVerified()); + when(gateway.getCurrentAccount()).thenReturn(Map.of("id", "acct_fixture", "object", "account")); + when(gateway.getBalance()).thenReturn(Map.of("object", "balance")); assertFalse(service.verifyProviderConfiguration().providerConfigurationVerified()); + when(gateway.getBalance()).thenReturn(Map.of("object", "balance", "livemode", false)); + var p = portal(); p.put("features", Map.of("subscription_cancel", Map.of("enabled", true, "mode", "immediately"))); + when(gateway.getPortalConfiguration("bpc_fixture")).thenReturn(p); assertFalse(service.verifyProviderConfiguration().providerConfigurationVerified()); + } + @Test void baseUrlsRejectCredentialsQueriesFragmentsAndNonLoopbackHttp() { + for (String input : List.of("https://u:p@example.test", "https://example.test?x=1", "https://example.test#x", "https://example.test/path", "http://example.test", "javascript:foo", "//example.test")) assertFalse(StripeReadinessService.validBaseUrl(input, true), input); + assertTrue(StripeReadinessService.validBaseUrl("http://127.0.0.1:8080", true)); + assertFalse(StripeReadinessService.validBaseUrl("http://127.0.0.1:8080", false)); + assertTrue(StripeReadinessService.validBaseUrl("https://api.modtale.test/", false)); + } + @Test void anonymousCredentialIsTestOnlyAndDoesNotEnableUnknownKeys() { + var actual = new StripeGatewayService(); + ReflectionTestUtils.setField(actual, "stripeSecretKey", "rkcs_official_prefix_fixture"); + ReflectionTestUtils.setField(actual, "livePaymentsEnabled", true); + assertTrue(actual.isTestMode()); assertFalse(actual.isLiveMode()); assertTrue(actual.isAnonymousSandbox()); + ReflectionTestUtils.setField(actual, "stripeSecretKey", "unknown_fixture"); + assertFalse(actual.isOperational()); assertFalse(actual.isReconciliationEnabled()); + } +} diff --git a/backend/src/test/java/net/modtale/service/finance/StripeWebhookSignatureTest.java b/backend/src/test/java/net/modtale/service/finance/StripeWebhookSignatureTest.java new file mode 100644 index 000000000..bd72dd916 --- /dev/null +++ b/backend/src/test/java/net/modtale/service/finance/StripeWebhookSignatureTest.java @@ -0,0 +1,30 @@ +package net.modtale.service.finance; + +import static org.junit.jupiter.api.Assertions.*; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import org.junit.jupiter.api.Test; + +class StripeWebhookSignatureTest { + private String sign(String body, long timestamp) throws Exception { + var mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec("test-secret".getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return "t=" + timestamp + ",v1=" + HexFormat.of().formatHex(mac.doFinal((timestamp + "." + body).getBytes(StandardCharsets.UTF_8))); + } + private boolean verify(String body, String header, long now) { return StripeWebhookSignature.verify(body.getBytes(StandardCharsets.UTF_8), header, "test-secret", now); } + + @Test void verifiesOriginalBodyAndMultipleSignatures() throws Exception { + assertTrue(verify("{\"id\":\"evt_1\"}", sign("{\"id\":\"evt_1\"}", 1000) + ",v1=invalid", 1000)); + assertFalse(verify("{ \"id\":\"evt_1\"}", sign("{\"id\":\"evt_1\"}", 1000), 1000)); + } + @Test void rejectsExpiredFutureMalformedOrAbsentSignatures() throws Exception { + assertFalse(verify("{}", sign("{}", 1000), 1301)); + assertFalse(verify("{}", sign("{}", 1301), 1000)); + assertFalse(verify("{}", "t=invalid,v1=fff", 1000)); + assertFalse(verify("{}", sign("{}", 1000) + ",t=1000", 1000)); + assertFalse(verify("{}", null, 1000)); + assertFalse(StripeWebhookSignature.verify("{}".getBytes(StandardCharsets.UTF_8), sign("{}", 1000), "", 1000)); + } +} diff --git a/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java b/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java index 65cb8f173..61b6f3ee4 100644 --- a/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/metadata/MetadataServiceTest.java @@ -188,4 +188,17 @@ void metadataMayRetainTheExistingProjectImage() { assertEquals("owned-image.png", existing.getImageUrl()); } + @Test + void ordinaryMetadataSavePreservesOwnerMonetizationPolicy() { + Project existing = new Project(); existing.setId("project-1"); existing.setClassification(ProjectClassification.PLUGIN); + existing.setAdsEnabled(false); existing.setDonationsEnabled(true); existing.setSuggestedDonationCents(1500); existing.setDonationRecurringDefault(true); + Project updated = new Project(); updated.setTitle("Updated title"); + User user = new User(); user.setId("editor"); + when(projectService.getRawProjectById("project-1")).thenReturn(existing); + when(accessControlService.hasProjectPermission(existing, user, "PROJECT_EDIT_METADATA")).thenReturn(true); + service.updateMetadata("project-1", updated, user); + assertFalse(existing.isAdsEnabled()); assertTrue(existing.isDonationsEnabled()); + assertEquals(1500, existing.getSuggestedDonationCents()); assertTrue(existing.isDonationRecurringDefault()); + } + } diff --git a/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java b/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java index eaed70738..52a1119a7 100644 --- a/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java +++ b/backend/src/test/java/net/modtale/service/project/version/VersionDownloadOrchestrationServiceTest.java @@ -242,7 +242,7 @@ void downloadVersionConsumesTokenChecksReadAccessTracksAndReturnsStoredArtifact( assertEquals("sky-tools.jar", payload.filename()); assertArrayEquals(new byte[]{1, 2, 3}, payload.bytes()); - verify(trackingService).logDownload("project-1", "version-1", "author-name", false, "203.0.113.1", false); + verify(trackingService).logDownload("project-1", "version-1", "author-id", false, "203.0.113.1", false); } @ParameterizedTest @@ -268,8 +268,8 @@ void downloadVersionGeneratesModpackZipAndTracksDependencies(boolean launcher) t assertEquals("Sky_Pack_-1.0.0.zip", payload.filename()); assertArrayEquals(new byte[]{9, 8, 7}, payload.bytes()); - verify(trackingService).logDownload("pack-1", "version-1", "author-name", true, "198.51.100.9", launcher); - verify(trackingService).logDownload("dep-1", null, "author-name", true, "198.51.100.9", launcher); + verify(trackingService).logDownload("pack-1", "version-1", "author-id", true, "198.51.100.9", launcher); + verify(trackingService).logDownload("dep-1", null, "author-id", true, "198.51.100.9", launcher); } @ParameterizedTest @@ -299,8 +299,8 @@ void downloadBundleTracksOnlySelectedNonEmbeddedDependenciesAndReturnsZipName(bo assertEquals("Sky_Tools-UNZIP-ME.zip", payload.filename()); assertArrayEquals(new byte[]{4, 5}, payload.bytes()); - verify(trackingService).logDownload("project-1", "version-1", "author-name", true, "198.51.100.9", launcher); - verify(trackingService).logDownload("dep-1", null, "author-name", true, "198.51.100.9", launcher); + verify(trackingService).logDownload("project-1", "version-1", "author-id", true, "198.51.100.9", launcher); + verify(trackingService).logDownload("dep-1", null, "author-id", true, "198.51.100.9", launcher); verify(projectService, never()).getRawProjectById("dep-2"); verify(projectService, never()).getRawProjectById("embedded"); } @@ -358,6 +358,7 @@ private static Project project(String id, String title, ProjectClassification cl project.setId(id); project.setTitle(title); project.setAuthor("author-name"); + project.setAuthorId("author-id"); project.setClassification(classification); return project; } diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 075fd939a..fdadb9a6f 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -5,6 +5,9 @@ COPY scripts ./scripts RUN npm ci --legacy-peer-deps COPY . . +ARG PUBLIC_FINANCE_DEMO=false +ENV PUBLIC_FINANCE_DEMO=$PUBLIC_FINANCE_DEMO + ARG PUBLIC_API_URL ENV PUBLIC_API_URL=$PUBLIC_API_URL ARG SSR_API_URL diff --git a/frontend/integration/browser/financeBrowser.spec.ts b/frontend/integration/browser/financeBrowser.spec.ts new file mode 100644 index 000000000..06bd0656b --- /dev/null +++ b/frontend/integration/browser/financeBrowser.spec.ts @@ -0,0 +1,159 @@ +import { test, expect, type BrowserContext, type Page } from '@playwright/test'; + +const backend = process.env.MODTALE_FINANCE_TEST_ORIGIN!; +const frontend = 'http://localhost:3000'; +const checkout = `${backend}/api/v1/finance/projects/project/donations/checkout-url`; +const external: string[] = []; + +async function isolatedRoutes(context: BrowserContext) { + external.length = 0; + await context.route('**/*', route => { + const target = new URL(route.request().url()); + if (target.origin === backend || target.origin === frontend) return route.continue(); + if (target.href === 'https://checkout.stripe.com/c/pay/fixture' || target.href === 'https://billing.stripe.com/p/session/fixture') { + // Real browser navigation, but no network traffic or cookies ever reach Stripe. + return route.fulfill({ status: 200, contentType: 'text/html', body: '<title>Isolated provider destination

Provider navigation fixture. No payment.

' }); + } + external.push(target.origin); + return route.abort('blockedbyclient'); + }); +} + +async function signin(page: Page, username: 'owner' | 'other' = 'owner') { + await page.getByRole('button', { name: `Sign in ${username}`, exact: true }).click(); + await expect(page.getByTestId('identity')).toHaveText(username); +} + +async function status(page: Page, operation: 'monthly' | 'subscriptions' | 'invalid-amount' | 'other-portal' | 'other-settings' | 'wrong-password') { + return page.evaluate(async op => { + const { api, financeClient } = (window as any).financeFixture; + try { + if (op === 'monthly') await financeClient.createDonationCheckout('project', 500, true, false, 1234); + if (op === 'subscriptions') await financeClient.getSupportSubscriptions(); + if (op === 'invalid-amount') await financeClient.createDonationCheckout('project', 500.5, false, true, 1234); + if (op === 'other-portal') await financeClient.openSupportBillingPortal('sub_owner'); + if (op === 'other-settings') await financeClient.updateProjectMonetization('project', { donationPlatformCutBps: 2500 }); + if (op === 'wrong-password') await api.post('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/auth/signin', { username: 'owner', password: 'incorrect' }); + return 200; + } catch (error: any) { return error.response?.status ?? 0; } + }, operation); +} + +test.beforeEach(async ({ context, page }) => { + await isolatedRoutes(context); + await page.goto('/integration/browser/index.html'); + await expect(page.getByRole('button', { name: 'Open support', exact: true })).toBeEnabled(); +}); +test.afterEach(() => { expect(external).toEqual([]); }); + +test('guest tip uses real cookies, exact terms and one actual isolated popup', async ({ page }) => { + const requests: string[] = []; + page.on('request', request => { if (request.url() === checkout) requests.push(request.postData()!); }); + await page.getByRole('button', { name: 'Open support', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('12.34% supports Modtale'); + await expect(dialog).toContainText('Your download is free'); + await expect(dialog.getByRole('button', { name: 'Monthly', exact: true })).toHaveCount(0); + const opened = page.waitForEvent('popup'); + const response = page.waitForResponse(checkout); + await dialog.getByRole('button', { name: 'Tip $5.00', exact: true }).evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click(); }); + const popup = await opened; + await expect(popup).toHaveURL('https://checkout.stripe.com/c/pay/fixture'); + await expect(popup.getByText('Provider navigation fixture. No payment.')).toBeVisible(); + expect(await popup.evaluate(() => window.opener)).toBeNull(); + expect((await response).status()).toBe(200); + expect(requests).toHaveLength(1); + expect(JSON.parse(requests[0])).toEqual({ amountCents: 500, recurring: false, guestCheckout: true, expectedPlatformCutBps: 1234 }); + const cookies = await page.context().cookies(backend); + expect(cookies.some(cookie => cookie.name === 'XSRF-TOKEN')).toBe(true); + await popup.close(); +}); + +test('actual sign-in session permits monthly support only after an explicit choice', async ({ page }) => { + expect(await status(page, 'monthly')).toBe(400); + expect(await status(page, 'wrong-password')).toBe(401); + expect(await status(page, 'subscriptions')).toBe(401); + await signin(page); + expect((await page.context().cookies(backend)).some(cookie => cookie.name === 'JSESSIONID' && cookie.httpOnly)).toBe(true); + await page.getByRole('button', { name: 'Open support', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog.getByRole('button', { name: 'One-time', exact: true })).toHaveAttribute('aria-pressed', 'true'); + await dialog.getByRole('button', { name: 'Monthly', exact: true }).click(); + await expect(dialog).toContainText('Renews monthly until cancelled'); + const opened = page.waitForEvent('popup'); + const response = page.waitForResponse(checkout); + await dialog.getByRole('button', { name: 'Tip $5.00/mo', exact: true }).click(); + const popup = await opened; + await expect(popup).toHaveURL('https://checkout.stripe.com/c/pay/fixture'); + expect((await response).request().postDataJSON()).toEqual({ amountCents: 500, recurring: true, guestCheckout: false, expectedPlatformCutBps: 1234 }); + await popup.close(); +}); + +test('stale terms close the real blank tab and require an explicit retry; return URLs never prove payment', async ({ page }) => { + await page.getByRole('button', { name: 'Open stale quote', exact: true }).click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toContainText('10% supports Modtale'); + const oldTab = page.waitForEvent('popup'); + const rejected = page.waitForResponse(checkout); + await dialog.getByRole('button', { name: 'Tip $5.00', exact: true }).click(); + const old = await oldTab; + expect((await rejected).status()).toBe(409); + await expect.poll(() => old.isClosed()).toBe(true); + await expect(page.getByTestId('outcome')).toHaveText('TERMS_CHANGED'); + await expect(dialog).toContainText('12.34% supports Modtale'); + await expect(dialog.getByRole('alert')).toContainText('Review the new share before retrying'); + const opened = page.waitForEvent('popup'); + const accepted = page.waitForResponse(checkout); + await dialog.getByRole('button', { name: 'Tip $5.00', exact: true }).click(); + const popup = await opened; + await expect(popup).toHaveURL('https://checkout.stripe.com/c/pay/fixture'); + const intent = await (await accepted).json(); + expect(intent).toMatchObject({ platformCents: 62, creatorCents: 438 }); + const returns = await page.evaluate(async id => { + const { verifySupportReturn } = (window as any).financeFixture; + return [(await verifySupportReturn(id, false, () => true)).title, (await verifySupportReturn(id, true, () => true)).title]; + }, intent.intentId); + expect(returns).toEqual(['Support Pending', 'Checkout Closed']); + await popup.close(); +}); + +test('owned billing portal survives popup failure and blocks other-account access', async ({ page }) => { + await signin(page); + await page.getByRole('button', { name: 'Toggle monthly support', exact: true }).click(); + await expect(page.getByText('Isolated support fixture', { exact: true })).toBeVisible(); + await expect(page.getByText('$5.00 / month', { exact: false })).toBeVisible(); + await page.evaluate(() => { (window as any).restoreFinanceOpen = window.open; window.open = () => { throw new Error('Browser popup interrupted'); }; }); + const manage = page.getByRole('button', { name: 'Manage or cancel in a new tab', exact: true }); + await manage.click(); + await expect(page.getByRole('alert')).toContainText('Could not open billing management'); + await expect(manage).toBeEnabled(); + await page.evaluate(() => { window.open = (window as any).restoreFinanceOpen; }); + const opened = page.waitForEvent('popup'); + await manage.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click(); }); + const popup = await opened; + await expect(popup).toHaveURL('https://billing.stripe.com/p/session/fixture'); + expect(await popup.evaluate(() => window.opener)).toBeNull(); + await popup.close(); + await signin(page, 'other'); + expect(await status(page, 'other-portal')).toBe(403); + expect(await status(page, 'other-settings')).toBe(403); + await page.getByRole('button', { name: 'Refresh', exact: true }).click(); + await expect(page.getByText('Isolated support fixture', { exact: true })).toHaveCount(0); +}); + +test('browser enforces missing CSRF, rejects fractional cents and loses authenticated access on logout', async ({ page }) => { + await signin(page); + const rejected = await page.evaluate(async origin => { + const response = await fetch(`${origin}/api/v1/finance/projects/project/donations/checkout-url`, { + method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ amountCents: 500, recurring: true, guestCheckout: false, expectedPlatformCutBps: 1234 }) + }); + return response.status; + }, backend); + expect(rejected).toBe(403); + expect(await status(page, 'invalid-amount')).toBe(400); + await page.getByRole('button', { name: 'Sign out', exact: true }).click(); + await expect(page.getByTestId('identity')).toHaveText('guest'); + expect(await status(page, 'subscriptions')).toBe(401); + expect(await status(page, 'monthly')).toBe(400); +}); diff --git a/frontend/integration/browser/index.html b/frontend/integration/browser/index.html new file mode 100644 index 000000000..182e7ba61 --- /dev/null +++ b/frontend/integration/browser/index.html @@ -0,0 +1,4 @@ + +Isolated finance browser test + +
diff --git a/frontend/integration/browser/main.tsx b/frontend/integration/browser/main.tsx new file mode 100644 index 000000000..e5529564a --- /dev/null +++ b/frontend/integration/browser/main.tsx @@ -0,0 +1,55 @@ +import { useEffect, useRef, useState } from 'react'; +import { createRoot } from 'react-dom/client'; +import { api } from '@/utils/api'; +import { financeClient } from '@/modules/finance/api/financeClient'; +import type { DonationConfig } from '@/modules/finance/api/financeTypes'; +import { openSupportCheckout, verifySupportReturn } from '@/modules/finance/api/supportCheckout'; +import { DonationPromptModal } from '@/modules/project/components/dialogs/DonationPromptModal'; +import { SupportSubscriptions } from '@/modules/finance/components/SupportSubscriptions'; + +// Browser-side production client, actual cookies and popup APIs. Provider requests are intercepted by Playwright. +api.defaults.adapter = 'xhr'; +api.defaults.timeout = 5000; +Object.assign(window, { financeFixture: { api, financeClient, verifySupportReturn } }); + +function Fixture() { + const [configuration, setConfiguration] = useState(); + const [show, setShow] = useState(false); + const [subscriptions, setSubscriptions] = useState(false); + const [processing, setProcessing] = useState(false); + const [outcome, setOutcome] = useState(''); + const [user, setUser] = useState('guest'); + const generation = useRef(0); + useEffect(() => { financeClient.getDonationConfig('project').then(setConfiguration); }, []); + const signin = async (username: string) => { + await api.post('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/auth/logout'); + await api.post('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/auth/signin', { username, password: 'isolated-fixture-password' }); + setConfiguration(await financeClient.getDonationConfig('project')); setUser(username); + }; + const dismiss = () => { generation.current++; setShow(false); }; + return
+

Isolated finance integration

{user}

+ + + + + + +

{outcome}

+ {subscriptions && } + {configuration && { + const current = generation.current; setProcessing(true); + const result = await openSupportCheckout({ projectId: 'project', amountCents, recurring, guestCheckout, + expectedPlatformCutBps: configuration.donationPlatformCutBps, isCurrent: () => current === generation.current }); + if (current !== generation.current) return; + setProcessing(false); setOutcome(result.status); + if (result.status === 'TERMS_CHANGED') setConfiguration(result.configuration); + if (result.status === 'OPENED') dismiss(); + }} />} +
; +} +createRoot(document.getElementById('fixture')!).render(); diff --git a/frontend/integration/financeSession.test.tsx b/frontend/integration/financeSession.test.tsx new file mode 100644 index 000000000..7d8d2c333 --- /dev/null +++ b/frontend/integration/financeSession.test.tsx @@ -0,0 +1,124 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { api, BACKEND_URL, getCookie } from '@/utils/api'; +import { financeClient } from '@/modules/finance/api/financeClient'; +import { openSupportCheckout, verifySupportReturn } from '@/modules/finance/api/supportCheckout'; +import { DonationPromptModal } from '@/modules/project/components/dialogs/DonationPromptModal'; +import { SupportSubscriptions } from '@/modules/finance/components/SupportSubscriptions'; + +// Real XMLHttpRequest, browser cookie jar, CORS and the production Axios interceptors. +// Only popup navigation is stubbed: no request can leave the disposable loopback server. +api.defaults.adapter = 'xhr'; +api.defaults.timeout = 5000; +api.interceptors.request.use(config => { + const target = new URL(config.url || '', `${config.baseURL}/`); + if (target.origin !== process.env.MODTALE_FINANCE_TEST_ORIGIN) throw new Error('External test request rejected'); + return config; +}); + +let host: HTMLDivElement; +let root: Root; +const login = (username = 'owner') => api.post('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/auth/signin', { username, password: 'isolated-fixture-password' }); +const button = (text: string) => [...host.querySelectorAll('button')].find(node => node.textContent?.trim() === text)!; +const popup = () => ({ opener: {}, close: vi.fn(), location: { replace: vi.fn() } }); + +beforeEach(async () => { + await api.post('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/auth/logout'); + host = document.createElement('div'); document.body.append(host); root = createRoot(host); +}); +afterEach(async () => { await act(async () => root.unmount()); host.remove(); }); + +describe('authenticated frontend ↔ servlet finance integration', { concurrent: false }, () => { + it('renders exact server terms and sends a guest one-time tip once through real CSRF', async () => { + const configuration = await financeClient.getDonationConfig('project'); + expect(configuration).toMatchObject({ donationPlatformCutBps: 1234, donationPlatformCutPercent: 12.34, currency: 'usd', checkoutEnabled: true }); + expect(getCookie('XSRF-TOKEN')).toBeNull(); // API-host cookie cannot be read from the frontend host. + const tab = popup(); + let pending: ReturnType | undefined; + const donate = vi.fn((amountCents, recurring, guestCheckout) => { + pending = openSupportCheckout({ projectId: configuration.projectId, amountCents, recurring, guestCheckout, + expectedPlatformCutBps: configuration.donationPlatformCutBps, isCurrent: () => true, openWindow: () => tab as unknown as Window }); + }); + await act(async () => root.render( {}} onSkip={() => {}} onDonate={donate} />)); + expect(host.textContent).toContain('12.34% supports Modtale'); + expect(host.textContent).toContain('Your download is free'); + await act(async () => { button('Tip $5.00').click(); button('Tip $5.00').click(); await pending; }); + expect(donate).toHaveBeenCalledExactlyOnceWith(500, false, true); + expect(await pending).toEqual({ status: 'OPENED' }); + expect(tab.opener).toBeNull(); expect(tab.location.replace).toHaveBeenCalledExactlyOnceWith('https://checkout.stripe.com/c/pay/fixture'); + }); + + it('signs in through the real session endpoint and permits an explicit monthly choice', async () => { + await expect(financeClient.createDonationCheckout('project', 500, true, false, 1234)).rejects.toMatchObject({ response: { status: 400 } }); + await expect(api.post('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/auth/signin', { username: 'owner', password: 'incorrect' })).rejects.toMatchObject({ response: { status: 401 } }); + await expect(financeClient.getSupportSubscriptions()).rejects.toMatchObject({ response: { status: 401 } }); + expect((await login()).data.status).toBe('success'); + const configuration = await financeClient.getDonationConfig('project'); + const tab = popup(); let pending: ReturnType | undefined; + await act(async () => root.render( {}} onSkip={() => {}} onDonate={(amountCents, recurring, guestCheckout) => { + pending = openSupportCheckout({ projectId: 'project', amountCents, recurring, guestCheckout, + expectedPlatformCutBps: configuration.donationPlatformCutBps, isCurrent: () => true, openWindow: () => tab as unknown as Window }); + }} />)); + await act(async () => button('Monthly').click()); + expect(host.textContent).toContain('Renews monthly until cancelled'); + await act(async () => { button('Tip $5.00/mo').click(); await pending; }); + expect(await pending).toEqual({ status: 'OPENED' }); + expect(tab.location.replace).toHaveBeenCalledOnce(); + }); + + it('refreshes a stale quote from HTTP 409 without paying until an explicit retry', async () => { + const oldTab = popup(); + const result = await openSupportCheckout({ projectId: 'project', amountCents: 500, recurring: false, guestCheckout: true, + expectedPlatformCutBps: 1000, isCurrent: () => true, openWindow: () => oldTab as unknown as Window }); + expect(result.status).toBe('TERMS_CHANGED'); + expect(oldTab.close).toHaveBeenCalledOnce(); expect(oldTab.location.replace).not.toHaveBeenCalled(); + if (result.status !== 'TERMS_CHANGED') throw new Error('Expected fresh server terms'); + expect(result.configuration.donationPlatformCutBps).toBe(1234); + const checkout = await financeClient.createDonationCheckout('project', 500, false, true, result.configuration.donationPlatformCutBps!); + expect(checkout).toMatchObject({ creatorCents: 438, platformCents: 62, simulated: false }); + expect((await verifySupportReturn(checkout.intentId, false, () => true))?.title).toBe('Support Pending'); + expect((await verifySupportReturn(checkout.intentId, true, () => true))?.title).toBe('Checkout Closed'); + }); + + it('renders the signed-in support list and opens only its owned billing portal', async () => { + await login(); + await act(async () => { root.render(); }); + await vi.waitFor(async () => { + await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); }); + expect(host.textContent).toContain('Isolated support fixture'); + }); + expect(host.textContent).toContain('$5.00 / month'); + const tab = popup(); vi.spyOn(window, 'open').mockReturnValue(tab as unknown as Window); + await act(async () => { + const manage = [...host.querySelectorAll('button')].find(node => node.textContent?.startsWith('Manage or cancel'))!; + manage.click(); manage.click(); + await vi.waitFor(() => expect(tab.location.replace).toHaveBeenCalledOnce()); + }); + expect(tab.location.replace).toHaveBeenCalledWith('https://billing.stripe.com/p/session/fixture'); + await api.post('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/auth/logout'); + await login('other'); + expect(await financeClient.getSupportSubscriptions()).toEqual([]); + await expect(financeClient.openSupportBillingPortal('sub_owner')).rejects.toMatchObject({ response: { status: 403 } }); + await expect(financeClient.updateProjectMonetization('project', { donationPlatformCutBps: 2500 })).rejects.toMatchObject({ response: { status: 403 } }); + }); + + it('rejects missing CSRF, preserves integer validation, and removes monthly access on logout', async () => { + await login(); + const status = await new Promise((resolve, reject) => { + const request = new XMLHttpRequest(); request.open('POST', `${BACKEND_URL}/api/v1/finance/projects/project/donations/checkout-url`); + request.withCredentials = true; request.setRequestHeader('Content-Type', 'application/json'); + request.onload = () => resolve(request.status); request.onerror = () => reject(new Error('Loopback XHR failed')); + request.send(JSON.stringify({ amountCents: 500, recurring: true, guestCheckout: false, expectedPlatformCutBps: 1234 })); + }); + expect(status).toBe(403); + await expect(financeClient.createDonationCheckout('project', 500.5, false, false, 1234)).rejects.toMatchObject({ response: { status: 400 } }); + await api.post('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/auth/logout'); + await expect(financeClient.getSupportSubscriptions()).rejects.toMatchObject({ response: { status: 401 } }); + await expect(financeClient.createDonationCheckout('project', 500, true, false, 1234)).rejects.toMatchObject({ response: { status: 400 } }); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f06cf9fe..a0ad96be8 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -48,6 +48,7 @@ }, "devDependencies": { "@astrojs/check": "^0.9.10", + "@playwright/test": "1.63.0", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", @@ -2369,6 +2370,22 @@ "url": "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/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", @@ -8898,6 +8915,35 @@ "pathe": "^2.0.1" } }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/points-on-curve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3d129a871..2f8afb7dd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,7 @@ }, "devDependencies": { "@astrojs/check": "^0.9.10", + "@playwright/test": "1.63.0", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/typography": "^0.5.20", "@tailwindcss/vite": "^4.3.3", diff --git a/frontend/playwright.finance.config.ts b/frontend/playwright.finance.config.ts new file mode 100644 index 000000000..c1e9ba57a --- /dev/null +++ b/frontend/playwright.finance.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from '@playwright/test'; + +const raw = process.env.MODTALE_FINANCE_TEST_ORIGIN; +if (!raw) throw new Error('Run backend financeFrontendIntegrationTest'); +const origin = new URL(raw); +if (origin.protocol !== 'http:' || origin.hostname !== 'localhost' || !origin.port || origin.pathname !== '/' + || origin.username || origin.password || origin.search || origin.hash) { + throw new Error('Finance browser tests require an exact loopback HTTP origin'); +} + +export default defineConfig({ + testDir: './integration/browser', + testMatch: 'financeBrowser.spec.ts', + workers: 1, + retries: 0, + timeout: 20_000, + reporter: [['list'], ['junit']], + outputDir: '../backend/build/finance-browser-artifacts', + use: { + baseURL: 'http://localhost:3000', + browserName: 'chromium', + launchOptions: { executablePath: process.env.MODTALE_FINANCE_CHROMIUM_PATH }, + serviceWorkers: 'block', + screenshot: 'only-on-failure', + trace: 'retain-on-failure' + } +}); diff --git a/frontend/scripts/run-finance-browser-tests.mjs b/frontend/scripts/run-finance-browser-tests.mjs new file mode 100644 index 000000000..b78642471 --- /dev/null +++ b/frontend/scripts/run-finance-browser-tests.mjs @@ -0,0 +1,35 @@ +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { createServer } from 'vite'; + +const raw = process.env.MODTALE_FINANCE_TEST_ORIGIN; +if (!raw) throw new Error('Run backend financeFrontendIntegrationTest to start the isolated servlet'); +const origin = new URL(raw); +if (origin.protocol !== 'http:' || origin.hostname !== 'localhost' || !origin.port || origin.pathname !== '/' + || origin.username || origin.password || origin.search || origin.hash) { + throw new Error('Finance browser tests require an exact loopback HTTP origin'); +} + +// This Vite entry is a test file, never an Astro route or a deployed fixture account. +const server = await createServer({ + configFile: false, + root: process.cwd(), + resolve: { alias: { '@': path.resolve('src') } }, + define: { 'import.meta.env.PUBLIC_API_URL': JSON.stringify(`${origin.origin}/api/v1`) }, + esbuild: { jsx: 'automatic' }, + server: { host: '127.0.0.1', port: 3000, strictPort: true, open: false } +}); + +try { + await server.listen(); + const child = spawn(process.execPath, ['node_modules/@playwright/test/cli.js', 'test', '--config', 'playwright.finance.config.ts'], { + stdio: 'inherit', env: process.env + }); + const result = await new Promise((resolve, reject) => { + child.once('error', reject); + child.once('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0))); + }); + process.exitCode = result; +} finally { + await server.close(); +} diff --git a/frontend/src/components/ui/charts/LineChart.tsx b/frontend/src/components/ui/charts/LineChart.tsx index a905e93f6..2ed4c377e 100644 --- a/frontend/src/components/ui/charts/LineChart.tsx +++ b/frontend/src/components/ui/charts/LineChart.tsx @@ -17,9 +17,10 @@ interface LineChartProps { datasets: Dataset[]; onToggle?: (id: string, hidden: boolean) => void; yAxisFormatter?: (val: number) => string; + emptyMessage?: string; } -export const LineChart: React.FC = ({ datasets, onToggle, yAxisFormatter }) => { +export const LineChart: React.FC = ({ datasets, onToggle, yAxisFormatter, emptyMessage = 'No data yet.' }) => { const [hoverIndex, setHoverIndex] = useState(null); const chartId = useId(); @@ -162,11 +163,12 @@ export const LineChart: React.FC = ({ datasets, onToggle, yAxisF {!hasData ? (
- No data selected. Toggle items above. + {normalizedDatasets.length > 0 && activeDatasets.length === 0 + ? 'No data selected. Toggle items above.' : emptyMessage}
) : (
-
+
{ticks.map(val => { const topPerc = (getY(val) / height) * 100; return ( diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 31884fbe9..d908de81d 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -20,6 +20,12 @@ const SECURITY_CSP = [ "trusted-types default", ].join('; '); +// The synthetic preview needs a same-origin iframe to exercise real mobile CSS. +// Real application pages and production builds retain the framing prohibition. +const FINANCE_PREVIEW_CSP = SECURITY_CSP + .replace("frame-ancestors 'none'", "frame-ancestors 'self'") + .replace('frame-src https://www.youtube-nocookie.com', "frame-src 'self' https://www.youtube-nocookie.com"); + const isLocalHostname = (hostname: string) => { return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'; }; @@ -31,6 +37,8 @@ export const onRequest: MiddlewareHandler = async ({ url, request }, next) => { const contentType = response.headers.get('content-type') || ''; const isLocal = isLocalHostname(url.hostname); const isDevModtale = isDevModtaleHostname(url.hostname); + const isFinancePreview = import.meta.env.PUBLIC_FINANCE_DEMO === 'true' + && url.pathname === '/finance-preview'; if (isDevModtale) { response.headers.set('X-Robots-Tag', 'noindex, nofollow, noarchive, nosnippet, noimageindex'); @@ -55,10 +63,10 @@ export const onRequest: MiddlewareHandler = async ({ url, request }, next) => { } if (contentType.includes('text/html') && !isLocal) { - response.headers.set('Content-Security-Policy', SECURITY_CSP); + response.headers.set('Content-Security-Policy', isFinancePreview ? FINANCE_PREVIEW_CSP : SECURITY_CSP); response.headers.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); response.headers.set('Cross-Origin-Opener-Policy', 'same-origin'); - response.headers.set('X-Frame-Options', 'DENY'); + response.headers.set('X-Frame-Options', isFinancePreview ? 'SAMEORIGIN' : 'DENY'); response.headers.set('X-Content-Type-Options', 'nosniff'); response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); } diff --git a/frontend/src/modules/admin/components/FinanceAdmin.tsx b/frontend/src/modules/admin/components/FinanceAdmin.tsx new file mode 100644 index 000000000..bdcf9076e --- /dev/null +++ b/frontend/src/modules/admin/components/FinanceAdmin.tsx @@ -0,0 +1,454 @@ +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'; +import { AdSettlementReview } from '@/modules/finance/components/AdSettlementReview'; +import { PayoutReconciliationReview } from '@/modules/finance/components/PayoutReconciliationReview'; +import { ProviderCostReview } from '@/modules/finance/components/ProviderCostReview'; +import { StripeReadiness } from '@/modules/finance/components/StripeReadiness'; +import { DisputeReconciliationReview } from '@/modules/finance/components/DisputeReconciliationReview'; + +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 { + canManageFinance: boolean; +} + +export function FinanceAdmin({ canManageFinance }: 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({ + 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 (!canManageFinance) 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({ + 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 = { + 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

+

Creators share {data?.adCreatorSplitPercent ?? 75}% of collected ad revenue. Clicks and impressions are engagement metrics, not money. Provider settlement and eligible-activity reconciliation determine earnings.

+
+ + +
+ + +
+ + {canManageFinance && <>} + {canManageFinance && } + {canManageFinance && } + + {canManageFinance ? ( + <> +
+

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} /> +
+ +
+
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.

+
+ + + +