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://.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 payload, HttpServletRequest request) {
+ String campaignId = payload.get("campaignId");
+ String projectId = payload.get("projectId");
+ if (campaignId == null || projectId == null) return ResponseEntity.badRequest().build();
+
+ String ip = getClientIp(request);
+ financeAdsService.trackAdImpression(campaignId, projectId, ip);
+ return ResponseEntity.ok(Map.of("ok", true));
+ }
+
+ @GetMapping("/ads/click/{campaignId}")
+ public ResponseEntity clickAd(
+ @PathVariable String campaignId,
+ @RequestParam String projectId,
+ HttpServletRequest request
+ ) {
+ String ip = getClientIp(request);
+ String url = financeAdsService.registerAdClickAndResolveUrl(campaignId, projectId, ip);
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setLocation(URI.create(url));
+ return new ResponseEntity<>(headers, HttpStatus.FOUND);
+ }
+
+ private String getClientIp(HttpServletRequest request) {
+ // 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 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 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 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 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