Compare commits
No commits in common. "main" and "v0.2.5" have entirely different histories.
18
.env.example
18
.env.example
|
|
@ -8,18 +8,6 @@ APP_LOCALE=en
|
|||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
# Passport OAuth signing keys for the MCP OAuth server. Leave blank to use the
|
||||
# keys generated by `php artisan passport:keys` under storage/. Set both (PEM
|
||||
# contents) to share one key pair across multiple app instances.
|
||||
PASSPORT_PRIVATE_KEY=
|
||||
PASSPORT_PUBLIC_KEY=
|
||||
|
||||
# OAuth authorization server host for the MCP OAuth server (RFC 8414 issuer).
|
||||
# Set to a dedicated host OUTSIDE the PWA's manifest scope so mobile OAuth links
|
||||
# (e.g. ChatGPT on Android) open in the browser instead of being captured by the
|
||||
# installed PWA. Leave blank to use the app URL. Production: https://oauth.whisper.money
|
||||
MCP_AUTHORIZATION_SERVER=
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
|
|
@ -89,12 +77,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||
|
||||
HIDE_AUTH_BUTTONS=false
|
||||
|
||||
# Optional. Defaults to true when unset, so existing installs keep public sign-ups.
|
||||
# Set to false to close public sign-ups while keeping login open (invite/admin-created
|
||||
# accounts only): the /register routes are not registered and every registration CTA
|
||||
# is hidden, while /login stays fully available.
|
||||
# REGISTRATION_ENABLED=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
|
||||
# PostHog Analytics
|
||||
|
|
|
|||
|
|
@ -14,15 +14,6 @@ APP_LOCALE=en
|
|||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=en_US
|
||||
|
||||
# Trusted Proxies
|
||||
# Comma-separated proxy IPs/CIDRs Laravel trusts for X-Forwarded-* headers (no spaces).
|
||||
# When unset the app trusts all proxies (*), which keeps small private self-hosted
|
||||
# setups working behind any proxy out of the box. On a publicly exposed deployment set
|
||||
# this to the actual proxy range: otherwise clients can spoof X-Forwarded-For to forge
|
||||
# their IP (defeating per-IP rate limiting and poisoning logs). The private ranges below
|
||||
# cover the Docker network Coolify/Traefik connects from.
|
||||
TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
|
||||
# Logging
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single,sentry_logs
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
name: automerge
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ['CI']
|
||||
types: [completed]
|
||||
|
||||
jobs:
|
||||
automerge:
|
||||
if: >
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.event == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Merge PR with automerge label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.MERGE_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
HEAD_BRANCH="${{ github.event.workflow_run.head_branch }}"
|
||||
|
||||
PR_NUMBER=$(gh pr list --repo "$REPO" --head "$HEAD_BRANCH" --state open --json number --jq '.[0].number')
|
||||
|
||||
if [ -z "$PR_NUMBER" ] || [ "$PR_NUMBER" = "null" ]; then
|
||||
echo "No open PR found for branch $HEAD_BRANCH"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
HAS_LABEL=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json labels --jq '[.labels[].name] | map(select(. == "automerge")) | length')
|
||||
|
||||
if [ "$HAS_LABEL" -eq 0 ]; then
|
||||
echo "PR #$PR_NUMBER does not have automerge label, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure the CI run matches the PR's latest commit to avoid merging stale results
|
||||
CI_SHA="${{ github.event.workflow_run.head_sha }}"
|
||||
PR_SHA=$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')
|
||||
|
||||
if [ "$CI_SHA" != "$PR_SHA" ]; then
|
||||
echo "CI ran on $CI_SHA but PR head is $PR_SHA, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Ensure no checks have failed on the PR
|
||||
FAILED=$(gh pr checks "$PR_NUMBER" --repo "$REPO" --json state --jq '[.[] | select(.state == "FAILURE")] | length')
|
||||
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "PR #$PR_NUMBER has $FAILED failed check(s), skipping merge"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "PR #$PR_NUMBER has automerge label and all checks passed. Merging..."
|
||||
gh pr merge "$PR_NUMBER" --squash --repo "$REPO" --delete-branch
|
||||
|
|
@ -64,9 +64,6 @@ jobs:
|
|||
- name: Generate Application Key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Generate Passport Keys
|
||||
run: php artisan passport:keys --force
|
||||
|
||||
- name: Tests
|
||||
# --parallel splits the suite across workers via paratest. Each
|
||||
# worker creates its own "testing_test_<N>" database; the mysql
|
||||
|
|
@ -147,9 +144,6 @@ jobs:
|
|||
- name: Generate Application Key
|
||||
run: php artisan key:generate
|
||||
|
||||
- name: Generate Passport Keys
|
||||
run: php artisan passport:keys --force
|
||||
|
||||
- name: Browser Tests
|
||||
run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/6
|
||||
env:
|
||||
|
|
@ -241,9 +235,6 @@ jobs:
|
|||
|
||||
linter:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -271,44 +262,9 @@ jobs:
|
|||
- name: Lint Frontend
|
||||
run: bun run lint
|
||||
|
||||
# Advisory for now: the codebase carries a backlog of pre-existing
|
||||
# `tsc --noEmit` errors that predate this step, so it must not gate
|
||||
# merges yet. It still surfaces type regressions in the CI log. Flip
|
||||
# `continue-on-error` to false once the existing backlog is cleared.
|
||||
- name: Type Check Frontend
|
||||
run: bun run types
|
||||
continue-on-error: true
|
||||
|
||||
- name: Frontend Tests
|
||||
run: bun run test
|
||||
|
||||
# Gates merges on a Conventional-Commit PR title. Lives in the linter
|
||||
# job (a required check) so a bad title blocks the merge, unlike the old
|
||||
# standalone workflow whose check wasn't required. Skipped on push since
|
||||
# there's no PR title to validate then.
|
||||
- name: Conventional PR title
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
chore
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
revert
|
||||
requireScope: false
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
subjectPatternError: |
|
||||
Subject must not start with uppercase. Use: feat: add checkout, fix(billing): handle retry.
|
||||
|
||||
performance-tests:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
|
|
@ -440,8 +396,8 @@ jobs:
|
|||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=sha,prefix=,suffix=-development
|
||||
type=raw,value=development
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Extract package version
|
||||
id: package-version
|
||||
|
|
@ -453,8 +409,7 @@ jobs:
|
|||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=,suffix=-production
|
||||
type=raw,value=production
|
||||
type=raw,value=v${{ steps.package-version.outputs.version }}-production
|
||||
|
||||
|
|
@ -519,8 +474,8 @@ jobs:
|
|||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=sha,prefix=,suffix=-development
|
||||
type=raw,value=development
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Extract production metadata
|
||||
id: production-meta
|
||||
|
|
@ -528,8 +483,7 @@ jobs:
|
|||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=sha,prefix=
|
||||
type=raw,value=latest
|
||||
type=sha,prefix=,suffix=-production
|
||||
type=raw,value=production
|
||||
type=raw,value=v${{ needs.build-image.outputs.package-version }}-production
|
||||
|
||||
|
|
@ -595,3 +549,132 @@ jobs:
|
|||
"$IMAGE@$AMD64_DIGEST" \
|
||||
"$IMAGE@$ARM64_DIGEST"
|
||||
done <<< "$TAGS"
|
||||
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [tests, linter, static-analysis, performance-tests, changes]
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push' && needs.changes.outputs.code == 'true'
|
||||
env:
|
||||
SENTRY_RELEASE: whisper-money@${{ github.sha }}
|
||||
concurrency:
|
||||
group: production-deploy-main
|
||||
cancel-in-progress: true
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if this is the latest main commit
|
||||
id: deploy_guard
|
||||
run: |
|
||||
latest_main_sha=$(git ls-remote origin refs/heads/main | cut -f1)
|
||||
|
||||
echo "Current workflow SHA: ${{ github.sha }}"
|
||||
echo "Latest main SHA: $latest_main_sha"
|
||||
|
||||
if [ -z "$latest_main_sha" ]; then
|
||||
echo "Unable to determine the latest main SHA."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$latest_main_sha" != "${{ github.sha }}" ]; then
|
||||
echo "This workflow is not for the latest commit on main. Skipping deploy."
|
||||
echo "should_deploy=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "This workflow is for the latest commit on main. Proceeding with deploy."
|
||||
echo "should_deploy=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Trigger deployment
|
||||
if: steps.deploy_guard.outputs.should_deploy == 'true'
|
||||
run: |
|
||||
# The Coolify box rebuilds the image from git on deploy, so its API
|
||||
# (port 8000) can be unreachable for several minutes while a prior
|
||||
# build runs. We just need the webhook to land once: retry on
|
||||
# connection-level failures, waiting 10 min before the second
|
||||
# attempt and 20 min before the third so a long rebuild can finish.
|
||||
max_attempts=3
|
||||
attempt=1
|
||||
|
||||
# Separator we control, so curl's --write-out fields can never be
|
||||
# confused with the response body.
|
||||
marker="===CURL_META==="
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "Attempt $attempt of $max_attempts..."
|
||||
|
||||
curl_exit=0
|
||||
response=$(curl -sS \
|
||||
-w "${marker}\nhttp_code=%{http_code}\ncurl_exit=%{exitcode}\nerrormsg=%{errormsg}\ntiming=DNS:%{time_namelookup}s Connect:%{time_connect}s Total:%{time_total}s Remote:%{remote_ip}:%{remote_port}" \
|
||||
--connect-timeout 30 \
|
||||
--max-time 300 \
|
||||
-H "Authorization: Bearer ${{ secrets.DEPLOYMENT_TOKEN }}" \
|
||||
"http://147.93.126.54:8000/api/v1/deploy?uuid=ww00sswosco8w80k08c0occ8&force=false") || curl_exit=$?
|
||||
|
||||
body="${response%%${marker}*}"
|
||||
meta="${response#*${marker}}"
|
||||
http_code=$(echo "$meta" | sed -n 's/^http_code=//p')
|
||||
curl_code=$(echo "$meta" | sed -n 's/^curl_exit=//p')
|
||||
errormsg=$(echo "$meta" | sed -n 's/^errormsg=//p')
|
||||
timing=$(echo "$meta" | sed -n 's/^timing=//p')
|
||||
|
||||
echo "Response body: $body"
|
||||
echo "HTTP Status: ${http_code:-none}"
|
||||
echo "curl exit: ${curl_code:-$curl_exit}"
|
||||
echo "curl error: ${errormsg:-none}"
|
||||
echo "Timing: $timing"
|
||||
|
||||
if [ "${http_code:-0}" -ge 200 ] && [ "${http_code:-0}" -lt 300 ]; then
|
||||
echo "Deployment triggered successfully!"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4xx are caller errors (bad token / uuid) — retrying won't help.
|
||||
if [ "${http_code:-0}" -ge 400 ] && [ "${http_code:-0}" -lt 500 ]; then
|
||||
echo "Got client error $http_code — not retryable. Failing fast."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deployment request failed (http=${http_code:-none} curl_exit=${curl_code:-$curl_exit}: ${errormsg:-none})"
|
||||
|
||||
if [ $attempt -lt $max_attempts ]; then
|
||||
delay=$((600 * attempt))
|
||||
echo "Retrying in ${delay}s..."
|
||||
sleep $delay
|
||||
fi
|
||||
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "All $max_attempts attempts failed. Giving up."
|
||||
exit 1
|
||||
|
||||
- name: Install Sentry CLI
|
||||
if: steps.deploy_guard.outputs.should_deploy == 'true'
|
||||
run: curl -sL https://sentry.io/get-cli/ | bash
|
||||
|
||||
- name: Create Sentry release
|
||||
if: steps.deploy_guard.outputs.should_deploy == 'true'
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: |
|
||||
if ! sentry-cli releases info "$SENTRY_RELEASE" >/dev/null 2>&1; then
|
||||
sentry-cli releases new "$SENTRY_RELEASE"
|
||||
fi
|
||||
|
||||
sentry-cli releases set-commits "$SENTRY_RELEASE" --auto --ignore-missing
|
||||
sentry-cli releases finalize "$SENTRY_RELEASE"
|
||||
|
||||
- name: Mark Sentry release deployed
|
||||
if: steps.deploy_guard.outputs.should_deploy == 'true'
|
||||
env:
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
run: sentry-cli releases deploys "$SENTRY_RELEASE" new -e production
|
||||
|
||||
- name: Deployment skipped
|
||||
if: steps.deploy_guard.outputs.should_deploy == 'false'
|
||||
run: echo "Skipped deployment because a newer commit is already on main."
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
name: PR title
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize, reopened, ready_for_review]
|
||||
|
||||
jobs:
|
||||
semantic-pr-title:
|
||||
name: Conventional PR title
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Validate PR title
|
||||
uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
chore
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
revert
|
||||
requireScope: false
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
subjectPatternError: |
|
||||
Subject must not start with uppercase. Use: feat: add checkout, fix(billing): handle retry.
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
name: Release
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * 1" # Mondays 08:00 UTC = 10:00 Madrid (CEST) / 09:00 (CET)
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
increment:
|
||||
|
|
@ -44,38 +42,23 @@ jobs:
|
|||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Check for releasable commits
|
||||
id: guard
|
||||
run: |
|
||||
LAST=$(git describe --tags --abbrev=0)
|
||||
if [ -z "$(git log "$LAST"..HEAD --oneline)" ]; then
|
||||
echo "No commits since $LAST; skipping release."
|
||||
echo "skip=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "skip=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Create release branch
|
||||
id: branch
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
run: |
|
||||
BRANCH="release/run-${{ github.run_id }}"
|
||||
git checkout -b "$BRANCH"
|
||||
echo "name=$BRANCH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Run release-it
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: npx release-it "${{ inputs.increment || 'patch' }}" --ci
|
||||
run: npx release-it ${{ inputs.increment }} --ci
|
||||
|
||||
- name: Read new version
|
||||
id: version
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
run: echo "value=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Open pull request
|
||||
if: steps.guard.outputs.skip != 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
|
|
|
|||
103
CHANGELOG.md
103
CHANGELOG.md
|
|
@ -1,108 +1,5 @@
|
|||
# Changelog
|
||||
|
||||
## [0.2.6](https://github.com/whisper-money/whisper-money/compare/v0.2.5...v0.2.6) (2026-07-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **accounts:** fire drag haptic on first tap, not after dragging ([#576](https://github.com/whisper-money/whisper-money/issues/576)) ([c75e834](https://github.com/whisper-money/whisper-money/commit/c75e834b89b58f19fba0021e423ef5d7fe8ae827))
|
||||
* **accounts:** remove double-skeleton flash on account show ([#634](https://github.com/whisper-money/whisper-money/issues/634)) ([afa80b6](https://github.com/whisper-money/whisper-money/commit/afa80b60fe97ecc504847ef9c4a1e8c9a9c2c9f1)), closes [#632](https://github.com/whisper-money/whisper-money/issues/632)
|
||||
* **accounts:** stop second long-press haptic on drag handle ([#578](https://github.com/whisper-money/whisper-money/issues/578)) ([5db6cdc](https://github.com/whisper-money/whisper-money/commit/5db6cdc6d2fcc03d3d89d16d058834cb89107999)), closes [#576](https://github.com/whisper-money/whisper-money/issues/576)
|
||||
* address remaining security audit findings (round 2) ([#628](https://github.com/whisper-money/whisper-money/issues/628)) ([4fdb7ee](https://github.com/whisper-money/whisper-money/commit/4fdb7eebd916f81bfde7dc4c6adbc8387d6699ca)), closes [#623](https://github.com/whisper-money/whisper-money/issues/623)
|
||||
* **ai:** handle transient AI provider overloads — stop the Sentry noise and retry the dropped work ([#595](https://github.com/whisper-money/whisper-money/issues/595)) ([4038e60](https://github.com/whisper-money/whisper-money/commit/4038e60fbcd9891ceecabe5f66c34dde1abcc6cd))
|
||||
* **ai:** surface learned-rule toast in edit modal and guard weak description keys ([#635](https://github.com/whisper-money/whisper-money/issues/635)) ([c159e87](https://github.com/whisper-money/whisper-money/commit/c159e8782efa5808e1f707eb7309966de3144f9b))
|
||||
* **analysis:** respect category types like the cashflow screen ([#612](https://github.com/whisper-money/whisper-money/issues/612)) ([986f437](https://github.com/whisper-money/whisper-money/commit/986f43705a35fb0e3cd49c2056c2a46e769a91aa))
|
||||
* **appearance:** support MediaQueryList change events on legacy Safari (PHP-LARAVEL-41) ([#646](https://github.com/whisper-money/whisper-money/issues/646)) ([465eb38](https://github.com/whisper-money/whisper-money/commit/465eb38dae1e856f0017f7f0e33bbad31b1d2c71))
|
||||
* **banking:** handle EnableBanking expired sessions as reconnect, not error ([#557](https://github.com/whisper-money/whisper-money/issues/557)) ([c36df98](https://github.com/whisper-money/whisper-money/commit/c36df98d326336c27cf63039eb28518dae3af43e))
|
||||
* **banking:** keep the native green Wise logo, not the aggregator's ([#590](https://github.com/whisper-money/whisper-money/issues/590)) ([578a9b4](https://github.com/whisper-money/whisper-money/commit/578a9b44d8f01d4d22558397af2f31a9e5bf80b8)), closes [#589](https://github.com/whisper-money/whisper-money/issues/589) [#525](https://github.com/whisper-money/whisper-money/issues/525) [#589](https://github.com/whisper-money/whisper-money/issues/589)
|
||||
* **banking:** only log sync failures once the connection gives up ([#603](https://github.com/whisper-money/whisper-money/issues/603)) ([8bbff05](https://github.com/whisper-money/whisper-money/commit/8bbff05b2693cef3edbc8fc4c9350f6ba7ab99d6))
|
||||
* **banking:** skip inaccessible EnableBanking accounts instead of failing the connection ([#559](https://github.com/whisper-money/whisper-money/issues/559)) ([4656870](https://github.com/whisper-money/whisper-money/commit/46568700b2c0610566a7a35efe54810ea51e3925))
|
||||
* **banking:** stop Wise appearing multiple times in the connect list ([#589](https://github.com/whisper-money/whisper-money/issues/589)) ([ed5aac0](https://github.com/whisper-money/whisper-money/commit/ed5aac0c4a0713e5bf2b0f2a82b9258abdcdb986))
|
||||
* **charts:** improve contrast for chart colors 9-10 ([#614](https://github.com/whisper-money/whisper-money/issues/614)) ([a37481f](https://github.com/whisper-money/whisper-money/commit/a37481fb71b935f0f42b282fabe6db3c664be5c6)), closes [hi#index](https://github.com/hi/issues/index)
|
||||
* **discord:** show old → new plan on plan change notification ([#637](https://github.com/whisper-money/whisper-money/issues/637)) ([3972007](https://github.com/whisper-money/whisper-money/commit/39720078447f9b17dacbfd15e9986f978b87b56a))
|
||||
* **discord:** skip zero-amount payment stats messages ([#540](https://github.com/whisper-money/whisper-money/issues/540)) ([7693e48](https://github.com/whisper-money/whisper-money/commit/7693e4813f810c21836b292770590ec511224109))
|
||||
* **i18n:** keep Discord brand name untranslated in French ([#541](https://github.com/whisper-money/whisper-money/issues/541)) ([06effb5](https://github.com/whisper-money/whisper-money/commit/06effb5e6ef2311657c5beaf9ec57918739eb38f))
|
||||
* **integration-requests:** freeze votes on not-doable requests ([#555](https://github.com/whisper-money/whisper-money/issues/555)) ([89c1ab1](https://github.com/whisper-money/whisper-money/commit/89c1ab1ca8edf4afcab183e11d0b6fc7ab095952))
|
||||
* **open-banking:** only block re-adding a bank when a live connection exists ([#569](https://github.com/whisper-money/whisper-money/issues/569)) ([14c4598](https://github.com/whisper-money/whisper-money/commit/14c4598cda6989017af9dd8551791d5a2c456a9d))
|
||||
* **open-banking:** stop storing the XXX no-currency placeholder on accounts ([#602](https://github.com/whisper-money/whisper-money/issues/602)) ([bc57eae](https://github.com/whisper-money/whisper-money/commit/bc57eae5c34cdf37beb7a82b5569fb50d12e3ab7))
|
||||
* **queue:** add supervisor worker for the ai queue ([#546](https://github.com/whisper-money/whisper-money/issues/546)) ([f191d74](https://github.com/whisper-money/whisper-money/commit/f191d740314836ae12b897b6e9f5151ecc39e15f))
|
||||
* **queue:** raise retry_after above the longest job timeout (PHP-LARAVEL-2D) ([#645](https://github.com/whisper-money/whisper-money/issues/645)) ([05d4bae](https://github.com/whisper-money/whisper-money/commit/05d4bae0af1fece9196fabde61a624cf19472da9))
|
||||
* **security:** scope job-status endpoints to owner + feature-area fixes ([#627](https://github.com/whisper-money/whisper-money/issues/627)) ([d55c3f4](https://github.com/whisper-money/whisper-money/commit/d55c3f41df9705fe533d13f3fa9da0cf7790cb4a)), closes [#4](https://github.com/whisper-money/whisper-money/issues/4) [#1](https://github.com/whisper-money/whisper-money/issues/1)
|
||||
* **sentry:** drop browser-extension noise before sending events ([#568](https://github.com/whisper-money/whisper-money/issues/568)) ([52708f9](https://github.com/whisper-money/whisper-money/commit/52708f940df2a86bc1bf47afe72af08abd4e345f))
|
||||
* **settings:** vertically center rows in automation rules and labels tables ([#615](https://github.com/whisper-money/whisper-money/issues/615)) ([e631cbb](https://github.com/whisper-money/whisper-money/commit/e631cbba69d8184f5efbb4c65d198a836a9ab883))
|
||||
* skip demo reset when demo account is disabled ([#626](https://github.com/whisper-money/whisper-money/issues/626)) ([eb31455](https://github.com/whisper-money/whisper-money/commit/eb31455e606f8e0b965b6b3cd83d4e2c9de34df5))
|
||||
* stop double-dispatching transaction listeners (N+1 insert into jobs) ([#620](https://github.com/whisper-money/whisper-money/issues/620)) ([0f8eca5](https://github.com/whisper-money/whisper-money/commit/0f8eca50d036aca21142fac39ca5ed385d2dbada))
|
||||
* **transactions:** let date column size to its content ([#610](https://github.com/whisper-money/whisper-money/issues/610)) ([5ef3e01](https://github.com/whisper-money/whisper-money/commit/5ef3e01c8905795ca29600bab3f62578ffe0673d))
|
||||
* **transactions:** only auto-select account on account pages ([#549](https://github.com/whisper-money/whisper-money/issues/549)) ([9a20335](https://github.com/whisper-money/whisper-money/commit/9a20335c6a4a7fa814f1460afbf8384888ee2e88))
|
||||
* **transactions:** pad Category column when Date column is hidden ([#584](https://github.com/whisper-money/whisper-money/issues/584)) ([d2806b5](https://github.com/whisper-money/whisper-money/commit/d2806b5887753aca2d7ccce8b2360107541890bc)), closes [#583](https://github.com/whisper-money/whisper-money/issues/583) [#582](https://github.com/whisper-money/whisper-money/issues/582)
|
||||
* **transactions:** pad Category column when Date column is hidden ([#585](https://github.com/whisper-money/whisper-money/issues/585)) ([8e38713](https://github.com/whisper-money/whisper-money/commit/8e3871370ae17e2b582effdb72218ba2ed65b40a)), closes [582/#583](https://github.com/whisper-money/whisper-money/issues/583)
|
||||
* **transactions:** restore left padding when category is first column ([#582](https://github.com/whisper-money/whisper-money/issues/582)) ([d11aa2d](https://github.com/whisper-money/whisper-money/commit/d11aa2dfe579e0a9af27909d51ac776f975b3073))
|
||||
* **ui:** make input borders visible in dark mode ([#571](https://github.com/whisper-money/whisper-money/issues/571)) ([83a5e96](https://github.com/whisper-money/whisper-money/commit/83a5e9657e679d0e849c9f5f532a021dce9807ff))
|
||||
* **worktree:** remove double slash in storage/keys copy path ([#629](https://github.com/whisper-money/whisper-money/issues/629)) ([4615d7a](https://github.com/whisper-money/whisper-money/commit/4615d7a88002f2b3c7df847a27f783a710a69cc6))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **accounts:** reorder accounts with drag-and-drop ([#575](https://github.com/whisper-money/whisper-money/issues/575)) ([cd3080e](https://github.com/whisper-money/whisper-money/commit/cd3080ec52fd2586f9920794559c7b500cbef6bf))
|
||||
* add Wise open banking integration with balance sync ([#525](https://github.com/whisper-money/whisper-money/issues/525)) ([1c5a76a](https://github.com/whisper-money/whisper-money/commit/1c5a76a3a4cc9173d7ba2a7f8e3f67fc303e30d4))
|
||||
* **ai:** dismissable AI consent banner that stops after the first decision ([#617](https://github.com/whisper-money/whisper-money/issues/617)) ([7e36bba](https://github.com/whisper-money/whisper-money/commit/7e36bbafef8faa076b25dc70451b7165b000349a))
|
||||
* **ai:** learn from category corrections so the AI stops repeating the same mistake ([#608](https://github.com/whisper-money/whisper-money/issues/608)) ([6727a9c](https://github.com/whisper-money/whisper-money/commit/6727a9c64a7083ac962e4d489a36c61f4c4e590f))
|
||||
* **ai:** manage AI consent outside onboarding with live backfill ([#591](https://github.com/whisper-money/whisper-money/issues/591)) ([9a458b1](https://github.com/whisper-money/whisper-money/commit/9a458b103131a0b848bdc17b5d015c923a30d71e))
|
||||
* **ai:** persist AI categorization suggestions below the label bar ([#547](https://github.com/whisper-money/whisper-money/issues/547)) ([9328cd3](https://github.com/whisper-money/whisper-money/commit/9328cd3e1ba53218b521d6501d8750bb483a2ea6))
|
||||
* **ai:** record the model behind each AI categorization ([#594](https://github.com/whisper-money/whisper-money/issues/594)) ([291cfbe](https://github.com/whisper-money/whisper-money/commit/291cfbe2612a3682f913216a64ebc76c699e55a2))
|
||||
* **banking:** add Interactive Brokers sync via Flex Web Service ([#581](https://github.com/whisper-money/whisper-money/issues/581)) ([f60e6d7](https://github.com/whisper-money/whisper-money/commit/f60e6d70355d77b05ff4cad690cea65c1eb5792d))
|
||||
* **banking:** enable Interactive Brokers for all users ([#593](https://github.com/whisper-money/whisper-money/issues/593)) ([094ff4d](https://github.com/whisper-money/whisper-money/commit/094ff4d5ac1e4ebab787056b8794654ac49f0cd8))
|
||||
* **banking:** let Wise credentials be updated ([#588](https://github.com/whisper-money/whisper-money/issues/588)) ([619ed0f](https://github.com/whisper-money/whisper-money/commit/619ed0f1db44f004b8e4704e2a4e1bbef0e56a74)), closes [#581](https://github.com/whisper-money/whisper-money/issues/581)
|
||||
* **connections:** create a new account from the manage-accounts selector ([#560](https://github.com/whisper-money/whisper-money/issues/560)) ([6cb8d11](https://github.com/whisper-money/whisper-money/commit/6cb8d115631d41d01f0dee025092decc4c31e01c))
|
||||
* **connections:** manage which accounts a bank connection syncs ([#558](https://github.com/whisper-money/whisper-money/issues/558)) ([a9b90a2](https://github.com/whisper-money/whisper-money/commit/a9b90a200efc66d85e20405872a857db829255cf))
|
||||
* **currencies:** add Nigerian Naira (NGN) ([#642](https://github.com/whisper-money/whisper-money/issues/642)) ([6ff7edf](https://github.com/whisper-money/whisper-money/commit/6ff7edf193bfb9baf51b4907a9fe0da49a95f533))
|
||||
* **currency:** add GHS (Ghanaian Cedi) ([#644](https://github.com/whisper-money/whisper-money/issues/644)) ([2aebe45](https://github.com/whisper-money/whisper-money/commit/2aebe45d1f5a481760fd2c36ec98436b29d1c510)), closes [#567](https://github.com/whisper-money/whisper-money/issues/567)
|
||||
* **currency:** add RSD (Serbian Dinar) ([#567](https://github.com/whisper-money/whisper-money/issues/567)) ([934e16c](https://github.com/whisper-money/whisper-money/commit/934e16c0fa2659a7e701d6cfcc23cbaad67d0c13))
|
||||
* **dashboard:** add accounts manager dialog with visibility toggle and reorder ([#604](https://github.com/whisper-money/whisper-money/issues/604)) ([777dfc0](https://github.com/whisper-money/whisper-money/commit/777dfc07b281a5df522fc93154b73e6d7c72d2ef))
|
||||
* **demo:** gate demo account access behind a config flag ([#580](https://github.com/whisper-money/whisper-money/issues/580)) ([a346566](https://github.com/whisper-money/whisper-money/commit/a346566fd0a6398bb7e9b146617f88d0d218a35f))
|
||||
* **drip:** email users stuck on the paywall a day after onboarding ([#562](https://github.com/whisper-money/whisper-money/issues/562)) ([ce6bfc9](https://github.com/whisper-money/whisper-money/commit/ce6bfc9c562195c7dc89028fe3a920a814ff1b7a))
|
||||
* **email:** follow up after post-onboarding AI consent ([#596](https://github.com/whisper-money/whisper-money/issues/596)) ([934d834](https://github.com/whisper-money/whisper-money/commit/934d834ab3dd19d66525fbd883d30de1b3d77982))
|
||||
* **encryption:** commands to warn and remove inactive encrypted-data accounts ([#633](https://github.com/whisper-money/whisper-money/issues/633)) ([477e4d5](https://github.com/whisper-money/whisper-money/commit/477e4d50e26760d387dee1784db7093c05ce593e))
|
||||
* **features:** support percentage rollouts in feature:enable ([#592](https://github.com/whisper-money/whisper-money/issues/592)) ([f72e2a6](https://github.com/whisper-money/whisper-money/commit/f72e2a64ca1101a04ddc3c3384f5f7c75aed8e5d))
|
||||
* **i18n:** add French translation support ([#532](https://github.com/whisper-money/whisper-money/issues/532)) ([a38ed69](https://github.com/whisper-money/whisper-money/commit/a38ed69d2e134651ceddae5082fd2d70493b83e0))
|
||||
* **integration-requests:** add done status and fix review command crash on orphaned author ([#601](https://github.com/whisper-money/whisper-money/issues/601)) ([e4be39b](https://github.com/whisper-money/whisper-money/commit/e4be39be1201b54894097e9580958d7cd23c4740))
|
||||
* **integration-requests:** add not-doable status with a public comment ([#552](https://github.com/whisper-money/whisper-money/issues/552)) ([801f6c7](https://github.com/whisper-money/whisper-money/commit/801f6c7cd4834eeecbe77a078994c89845003a70))
|
||||
* **integration-requests:** community board to request & vote bank integrations ([#550](https://github.com/whisper-money/whisper-money/issues/550)) ([5e8f227](https://github.com/whisper-money/whisper-money/commit/5e8f227fbdabbd15fd752645e7a1405803f032d9))
|
||||
* **integration-requests:** let the admin bypass limits and auto-approve ([#551](https://github.com/whisper-money/whisper-money/issues/551)) ([7e9122e](https://github.com/whisper-money/whisper-money/commit/7e9122eaf442f0ea4cd2f2eecf1dfea1c8e7f7fd))
|
||||
* **integration-requests:** markdown comments and in-progress status ([#553](https://github.com/whisper-money/whisper-money/issues/553)) ([da88adb](https://github.com/whisper-money/whisper-money/commit/da88adbee36c899fa24342d0b62c0cf1063df689))
|
||||
* **integration-requests:** multi-vote, per-plan quota and undo ([#554](https://github.com/whisper-money/whisper-money/issues/554)) ([0ea54fa](https://github.com/whisper-money/whisper-money/commit/0ea54fa0d75dd268c7cb5e59f1da95a9a22976ee))
|
||||
* **landing:** clarify AI framing and add testimonials ([#613](https://github.com/whisper-money/whisper-money/issues/613)) ([d55e15b](https://github.com/whisper-money/whisper-money/commit/d55e15bb4faabf34e4f1f92022db4a17fe085dec))
|
||||
* **onboarding:** auto-enable AI for connected banks, ask the rest ([#618](https://github.com/whisper-money/whisper-money/issues/618)) ([10442c1](https://github.com/whisper-money/whisper-money/commit/10442c1e3293270dcc75a299414b793c5db14610))
|
||||
* **onboarding:** clarify the "categorize at least 5" goal in the categorizer ([#616](https://github.com/whisper-money/whisper-money/issues/616)) ([af64f56](https://github.com/whisper-money/whisper-money/commit/af64f563991897723c69749ac69bf724b162f44c))
|
||||
* **open-banking:** allow re-connecting a bank behind a replace warning ([#570](https://github.com/whisper-money/whisper-money/issues/570)) ([64827fa](https://github.com/whisper-money/whisper-money/commit/64827fabae82cadd98febdd457dcf0106934cc18)), closes [#569](https://github.com/whisper-money/whisper-money/issues/569)
|
||||
* **open-banking:** disable already-connected banks in the connect picker ([#556](https://github.com/whisper-money/whisper-money/issues/556)) ([6e6433c](https://github.com/whisper-money/whisper-money/commit/6e6433c6ad8c6673edc5f263a76096bc4377da96))
|
||||
* **open-banking:** enable manage bank accounts for everyone ([#572](https://github.com/whisper-money/whisper-money/issues/572)) ([0f3cdd4](https://github.com/whisper-money/whisper-money/commit/0f3cdd41aaa72a9544202a3d5add2515cf5ac6ab))
|
||||
* **paywall:** require a plan when the user has accepted AI ([#564](https://github.com/whisper-money/whisper-money/issues/564)) ([29d13ce](https://github.com/whisper-money/whisper-money/commit/29d13ceed103860399b271ffed3cb17810112001))
|
||||
* **stats:** add --no-discord flag to stats:experiment-funnel ([#606](https://github.com/whisper-money/whisper-money/issues/606)) ([1db2871](https://github.com/whisper-money/whisper-money/commit/1db2871398bd86deb0a3c48ba1f1881822e016cc))
|
||||
* **stats:** add --no-discord to the remaining report commands ([#607](https://github.com/whisper-money/whisper-money/issues/607)) ([300756e](https://github.com/whisper-money/whisper-money/commit/300756e55341b84245efbbd37a038ae7edb7217b))
|
||||
* **stats:** add weekly subscription funnel report ([#599](https://github.com/whisper-money/whisper-money/issues/599)) ([756b481](https://github.com/whisper-money/whisper-money/commit/756b4816a6d67922a15cf6b69e9933f8f1bea2c8))
|
||||
* **stats:** weekly paywall stuck-cohort report to Discord ([#563](https://github.com/whisper-money/whisper-money/issues/563)) ([57f8c93](https://github.com/whisper-money/whisper-money/commit/57f8c93e2818ad53abcd8d1ad656aeb1f1edda4d))
|
||||
* **subscriptions:** reframe pay_now paywall copy around try-and-refund ([#605](https://github.com/whisper-money/whisper-money/issues/605)) ([09d6e8e](https://github.com/whisper-money/whisper-money/commit/09d6e8ee6cfe6247439bfb3bfd475ab1a092e722))
|
||||
* **subscriptions:** trial/pricing A/B/C experiment ([#600](https://github.com/whisper-money/whisper-money/issues/600)) ([e5350ff](https://github.com/whisper-money/whisper-money/commit/e5350ff1a6061ee3bdb9596e3b6e925618e39e3e))
|
||||
* **support:** add support link with community-first help modal ([#542](https://github.com/whisper-money/whisper-money/issues/542)) ([4a891a5](https://github.com/whisper-money/whisper-money/commit/4a891a5906d57f17bbf0aeeec957656e7e67f937))
|
||||
* **transactions:** default balance toggle on and apply it server-side ([#566](https://github.com/whisper-money/whisper-money/issues/566)) ([b76a0de](https://github.com/whisper-money/whisper-money/commit/b76a0de0746e334835c84e7aef96140e8ef7d00f))
|
||||
* **transactions:** highlight new transactions since last visit ([#609](https://github.com/whisper-money/whisper-money/issues/609)) ([884038c](https://github.com/whisper-money/whisper-money/commit/884038c13bd093ca5ec1f6a656af40453f085efc))
|
||||
* **transactions:** make new-transaction marker cross-device ([#611](https://github.com/whisper-money/whisper-money/issues/611)) ([ee69c51](https://github.com/whisper-money/whisper-money/commit/ee69c51a846a944632e6c23e098014f768229310)), closes [#609](https://github.com/whisper-money/whisper-money/issues/609)
|
||||
* **transactions:** refine new transaction form layout and balance toggle ([#597](https://github.com/whisper-money/whisper-money/issues/597)) ([d6ec983](https://github.com/whisper-money/whisper-money/commit/d6ec9830dff95b1fa869b57604ccc892e97113bb))
|
||||
* **transactions:** release transaction analysis to all users ([#579](https://github.com/whisper-money/whisper-money/issues/579)) ([ae6f869](https://github.com/whisper-money/whisper-money/commit/ae6f8696118cc9d4808a8ee9270de2b25850dd70))
|
||||
* **transactions:** reorder filters and switch accounts to a logo dropdown ([#598](https://github.com/whisper-money/whisper-money/issues/598)) ([d7bc4e6](https://github.com/whisper-money/whisper-money/commit/d7bc4e6707a802044b5f931c54cefca23dcbeebc))
|
||||
* **transactions:** serve import dedup and account ledger from the backend ([#631](https://github.com/whisper-money/whisper-money/issues/631)) ([021cb66](https://github.com/whisper-money/whisper-money/commit/021cb6664311ec475e87b628ca7a88baf8de4907))
|
||||
* **welcome:** add Francisco Montes testimonial ([#636](https://github.com/whisper-money/whisper-money/issues/636)) ([02087ab](https://github.com/whisper-money/whisper-money/commit/02087abcc7c7e9f7c210ae922c7813206db6093e))
|
||||
* **welcome:** add Haru testimonial with Discord avatar ([#577](https://github.com/whisper-money/whisper-money/issues/577)) ([b0e74fa](https://github.com/whisper-money/whisper-money/commit/b0e74fac2c629078d4d88b9a2365d343571cc0e6))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **accounts:** defer the account ledger prop on show ([#632](https://github.com/whisper-money/whisper-money/issues/632)) ([9326d8f](https://github.com/whisper-money/whisper-money/commit/9326d8fd2fd3c9739cffa7d0f2f384fa49a623c1)), closes [#631](https://github.com/whisper-money/whisper-money/issues/631) [#631](https://github.com/whisper-money/whisper-money/issues/631) [#631](https://github.com/whisper-money/whisper-money/issues/631)
|
||||
* **ai:** reduce N+1 in bulk category updates (PHP-LARAVEL-40, partial) ([#624](https://github.com/whisper-money/whisper-money/issues/624)) ([3d3f6da](https://github.com/whisper-money/whisper-money/commit/3d3f6daa77c130e5a91547d9426189a1e820cad5)), closes [#2](https://github.com/whisper-money/whisper-money/issues/2)
|
||||
* **banking:** kill per-transaction dedup N+1 in bank sync (PHP-LARAVEL-3Y) ([#621](https://github.com/whisper-money/whisper-money/issues/621)) ([84bad76](https://github.com/whisper-money/whisper-money/commit/84bad76316425616899f03157fa8246144635288))
|
||||
* **db:** index transactions for the daily synced-email slow query (PHP-LARAVEL-3X) ([#622](https://github.com/whisper-money/whisper-money/issues/622)) ([ad46e46](https://github.com/whisper-money/whisper-money/commit/ad46e465be3cb2d3a29726a7c4cc2f822ecf67a3))
|
||||
|
||||
## [0.2.5](https://github.com/whisper-money/whisper-money/compare/v0.2.4...v0.2.5) (2026-06-15)
|
||||
|
||||
|
||||
|
|
|
|||
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -15,14 +15,6 @@ composer run dev # Start full dev environment (PHP server, queue, Vite,
|
|||
bun run dev # Vite dev server only
|
||||
```
|
||||
|
||||
### Running the server for QA
|
||||
|
||||
When a command or skill needs the user to open the app in Chrome to review a change:
|
||||
|
||||
1. **First run in a worktree?** Prepare it: `./worktree.sh /path/to/main/repo` (copies `.env` + `storage/keys`, installs `bun` and `composer` deps). Skip if already set up.
|
||||
2. **Start the server** if it isn't running: `composer run dev`.
|
||||
3. **Get the URL** — it's dynamic per worktree, so don't guess it. On startup `composer run dev` prints and copies it to the clipboard (`✓ <URL> copied to clipboard`); read it from the `server` pane output. Ask the user to open that URL in Chrome to QA.
|
||||
|
||||
### Build & Quality
|
||||
|
||||
```bash
|
||||
|
|
@ -115,7 +107,6 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||
|
||||
- php - 8.4
|
||||
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v2
|
||||
- laravel/ai (AI) - v0
|
||||
- laravel/cashier (CASHIER) - v16
|
||||
- laravel/fortify (FORTIFY) - v1
|
||||
- laravel/framework (LARAVEL) - v13
|
||||
|
|
@ -147,7 +138,6 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
|
||||
- `inertia-react-development` — Develops Inertia.js v2 React client-side applications. Activates when creating React pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions React with Inertia, React pages, React forms, or React navigation.
|
||||
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
|
||||
- `ai-sdk-development` — TRIGGER when working with ai-sdk which is Laravel official first-party AI SDK. Activate when building, editing AI agents, chatbots, text generation, image generation, audio/TTS, transcription/STT, embeddings, RAG, vector stores, reranking, structured output, streaming, conversation memory, tools, queueing, broadcasting, and provider failover across OpenAI, Anthropic, Gemini, Azure, Groq, xAI, DeepSeek, Mistral, Ollama, ElevenLabs, Cohere, Jina, and VoyageAI. Invoke when the user references ai-sdk, the `Laravel\Ai\` namespace, or this project's AI features — not for other AI packages used directly.
|
||||
- `fortify-development` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
|
||||
|
||||
## Conventions
|
||||
|
|
|
|||
|
|
@ -164,7 +164,6 @@ The template includes:
|
|||
| ----------------------- | ------- | -------------------------------------------------- |
|
||||
| `DRIP_EMAILS_ENABLED` | `true` | Enable drip emails (welcome, onboarding, feedback) |
|
||||
| `HIDE_AUTH_BUTTONS` | `false` | Hide login/register buttons on landing page |
|
||||
| `REGISTRATION_ENABLED` | `true` | Set to `false` to close public sign-ups (the `/register` routes are disabled and every registration CTA is hidden) while keeping `/login` open |
|
||||
| `SUBSCRIPTIONS_ENABLED` | `false` | Enable Stripe subscriptions |
|
||||
| `STRIPE_KEY` | - | Stripe publishable key |
|
||||
| `STRIPE_SECRET` | - | Stripe secret key |
|
||||
|
|
@ -172,7 +171,7 @@ The template includes:
|
|||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/?type=date&legend=top-left&repos=whisper-money%2Fwhisper-money)
|
||||
[](https://www.star-history.com/#whisper-money/whisper-money&type=date&legend=top-left)
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
|
|
@ -1,49 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Ai;
|
||||
|
||||
use App\Jobs\CategorizeUncategorizedTransactionsJob;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use App\Services\Ai\AiCategorizationGate;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class StartCategorizationBackfill
|
||||
{
|
||||
public function __construct(private readonly AiCategorizationGate $gate) {}
|
||||
|
||||
/**
|
||||
* Dispatch a categorization backfill when the user is eligible and has
|
||||
* something to categorize, seeding the progress cache the client polls.
|
||||
*
|
||||
* @return array{job_id: string, total: int}|null
|
||||
*/
|
||||
public function handle(User $user): ?array
|
||||
{
|
||||
if (! $this->gate->allows($user)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$total = Transaction::query()
|
||||
->where('user_id', $user->id)
|
||||
->pendingAiCategorization()
|
||||
->count();
|
||||
|
||||
if ($total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$jobId = (string) Str::uuid();
|
||||
|
||||
Cache::put(
|
||||
CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($user->id, $jobId),
|
||||
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
CategorizeUncategorizedTransactionsJob::dispatch($user, $jobId);
|
||||
|
||||
return ['job_id' => $jobId, 'total' => $total];
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,6 @@ namespace App\Actions;
|
|||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Models\Category;
|
||||
use App\Models\Space;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Str;
|
||||
|
|
@ -14,16 +13,14 @@ class CreateDefaultCategories
|
|||
{
|
||||
/**
|
||||
* Create default categories for a newly registered user, nesting child
|
||||
* categories under their configured parent. Categories live in a space, so
|
||||
* seeding targets the given space (defaulting to the user's active one).
|
||||
* categories under their configured parent.
|
||||
*/
|
||||
public function handle(User $user, ?Space $space = null): void
|
||||
public function handle(User $user): void
|
||||
{
|
||||
$space ??= $user->activeSpace();
|
||||
$locale = $user->locale ?? app()->getLocale();
|
||||
$defaultCategories = self::getDefaultCategories($locale);
|
||||
|
||||
$existingCategories = $space->categories()
|
||||
$existingCategories = $user->categories()
|
||||
->whereIn('name', array_column($defaultCategories, 'name'))
|
||||
->pluck('id', 'name');
|
||||
|
||||
|
|
@ -35,7 +32,6 @@ class CreateDefaultCategories
|
|||
'cashflow_direction' => $category['cashflow_direction'] ?? CategoryCashflowDirection::Hidden->value,
|
||||
'id' => (string) Str::uuid(),
|
||||
'user_id' => $user->id,
|
||||
'space_id' => $space->id,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Enums\Locale;
|
||||
use App\Models\User;
|
||||
use App\Services\LandingAuthOverrideService;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
|
@ -43,7 +42,7 @@ class CreateNewUser implements CreatesNewUsers
|
|||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
'locale' => Locale::detectFromHeader(request()->header('Accept-Language'))->value,
|
||||
'locale' => $this->detectLocaleFromRequest(),
|
||||
'timezone' => $this->normalizeTimezone($input['timezone'] ?? null),
|
||||
]);
|
||||
|
||||
|
|
@ -70,4 +69,19 @@ class CreateNewUser implements CreatesNewUsers
|
|||
|
||||
return $timezone;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect locale from Accept-Language header.
|
||||
*/
|
||||
protected function detectLocaleFromRequest(): string
|
||||
{
|
||||
$acceptLanguage = request()->header('Accept-Language', '');
|
||||
|
||||
// Check if Spanish is preferred
|
||||
if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') {
|
||||
return 'es';
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,54 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Actions\Subscription;
|
||||
|
||||
use App\Actions\OpenBanking\DisconnectBankingConnection;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Self-service "money-back guarantee" for the pay_now experiment variant:
|
||||
* refund the upfront charge, cancel the subscription immediately, and revoke
|
||||
* the user's bank connections (keeping the data they already imported).
|
||||
*
|
||||
* Eligibility is enforced by the caller via ExperimentOffer::canSelfRefund().
|
||||
* The refund is stamped before the cancel/disconnect steps run so that a
|
||||
* failure in those steps can never leave a refunded-but-active subscription
|
||||
* that could be refunded a second time; the cleanup is best-effort and logged.
|
||||
*/
|
||||
class RefundSelfServe
|
||||
{
|
||||
public function __construct(private DisconnectBankingConnection $disconnect) {}
|
||||
|
||||
public function handle(User $user): void
|
||||
{
|
||||
$subscription = $user->subscription('default');
|
||||
|
||||
if ($subscription === null || $subscription->refunded_at !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payment = $subscription->latestPayment();
|
||||
|
||||
if ($payment !== null) {
|
||||
$user->refund($payment->asStripePaymentIntent()->id);
|
||||
}
|
||||
|
||||
$subscription->forceFill(['refunded_at' => now()])->save();
|
||||
|
||||
try {
|
||||
$subscription->cancelNow();
|
||||
|
||||
$user->bankingConnections()->get()->each(function (BankingConnection $connection): void {
|
||||
$this->disconnect->handle($connection, deleteAccounts: false);
|
||||
});
|
||||
} catch (\Throwable $exception) {
|
||||
Log::error('Self-serve refund issued but post-refund cleanup failed', [
|
||||
'user_id' => $user->getKey(),
|
||||
'subscription_id' => $subscription->getKey(),
|
||||
'error' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
|
@ -33,7 +32,7 @@ class BackfillAccountIbans extends Command
|
|||
->whereNull('iban')
|
||||
->whereNotNull('external_account_id')
|
||||
->whereNotNull('banking_connection_id')
|
||||
->whereHas('bankingConnection', fn ($q) => $q->where('provider', BankingProvider::EnableBanking));
|
||||
->whereHas('bankingConnection', fn ($q) => $q->where('provider', 'enablebanking'));
|
||||
|
||||
if ($connectionId) {
|
||||
$query->where('banking_connection_id', $connectionId);
|
||||
|
|
|
|||
|
|
@ -1,82 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Account;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\Budget;
|
||||
use App\Models\Category;
|
||||
use App\Models\Label;
|
||||
use App\Models\SavedFilter;
|
||||
use App\Models\Space;
|
||||
use App\Models\Transaction;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class BackfillSpaces extends Command
|
||||
{
|
||||
protected $signature = 'spaces:backfill {--chunk=500 : Users processed per batch}';
|
||||
|
||||
protected $description = 'Provision a personal space for every user and stamp their existing rows with it';
|
||||
|
||||
/**
|
||||
* Owned tables to backfill. Each row inherits the personal space of its
|
||||
* user_id. Idempotent: only rows with a null space_id are touched.
|
||||
*
|
||||
* @var list<class-string<Model>>
|
||||
*/
|
||||
private array $models = [
|
||||
Account::class,
|
||||
BankingConnection::class,
|
||||
Transaction::class,
|
||||
Category::class,
|
||||
Label::class,
|
||||
Budget::class,
|
||||
AutomationRule::class,
|
||||
SavedFilter::class,
|
||||
];
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$chunk = (int) $this->option('chunk');
|
||||
|
||||
// Soft-deleted users are included: their accounts/transactions still
|
||||
// exist and must be stamped too, so a restored account keeps its data
|
||||
// and the column can eventually go NOT NULL.
|
||||
$this->info('Provisioning personal spaces…');
|
||||
User::withTrashed()->whereNull('current_space_id')->chunkById($chunk, function ($users): void {
|
||||
foreach ($users as $user) {
|
||||
$user->provisionPersonalSpace();
|
||||
}
|
||||
});
|
||||
|
||||
$this->info('Backfilling space_id on owned rows…');
|
||||
Space::query()->where('personal', true)->chunkById($chunk, function ($spaces): void {
|
||||
foreach ($spaces as $space) {
|
||||
foreach ($this->models as $model) {
|
||||
$this->stamp($model, $space->owner_id, $space->id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$this->info('Done.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param class-string<Model> $model
|
||||
*/
|
||||
private function stamp(string $model, string $ownerId, string $spaceId): void
|
||||
{
|
||||
// Go through the query builder rather than Eloquent so soft-deleted rows
|
||||
// are stamped too (no soft-delete global scope to work around).
|
||||
DB::table((new $model)->getTable())
|
||||
->whereNull('space_id')
|
||||
->where('user_id', $ownerId)
|
||||
->update(['space_id' => $spaceId]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ namespace App\Console\Commands;
|
|||
|
||||
use App\Actions\OpenBanking\DisconnectBankingConnection;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Mail\EnableBankingConnectionsCancelledEmail;
|
||||
use App\Models\BankingConnection;
|
||||
use Illuminate\Console\Command;
|
||||
|
|
@ -23,7 +22,7 @@ class CancelFreeEnableBankingConnectionsCommand extends Command
|
|||
$connections = BankingConnection::query()
|
||||
->with(['user', 'accounts'])
|
||||
->whereHas('user')
|
||||
->where('provider', BankingProvider::EnableBanking)
|
||||
->where('provider', 'enablebanking')
|
||||
->where('status', '!=', BankingConnectionStatus::Revoked)
|
||||
->where('created_at', '<=', $cutoff)
|
||||
->get();
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ class CategorizeBackfillCommand extends Command
|
|||
return self::FAILURE;
|
||||
}
|
||||
|
||||
if (! $gate->allows($user)) {
|
||||
if (! $gate->allowsBackfill($user)) {
|
||||
$this->warn('User is not eligible (needs the feature enabled, a pro plan and active AI consent).');
|
||||
|
||||
return self::FAILURE;
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
trait FindsUsersWithLegacyEncryption
|
||||
{
|
||||
/**
|
||||
* Users who still have at least one client-side encrypted transaction or account
|
||||
* (a non-null `*_iv` column is the source of truth, not the stale `accounts.encrypted` flag).
|
||||
*
|
||||
* Subscriptions are eager-loaded so callers can filter with {@see excludeBilledUsers()}
|
||||
* without an N+1 query.
|
||||
*
|
||||
* @return Builder<User>
|
||||
*/
|
||||
protected function usersWithLegacyEncryption(): Builder
|
||||
{
|
||||
return User::query()->with('subscriptions')->where(function (Builder $query): void {
|
||||
$query->whereHas('transactions', function (Builder $transactions): void {
|
||||
$transactions->whereNotNull('description_iv')
|
||||
->orWhereNotNull('notes_iv');
|
||||
})->orWhereHas('accounts', function (Builder $accounts): void {
|
||||
$accounts->whereNotNull('name_iv');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop users who are still being billed. These accounts must never be emailed a
|
||||
* deletion warning nor deleted while a subscription or trial is active.
|
||||
*
|
||||
* @param Collection<int, User> $users
|
||||
* @return Collection<int, User>
|
||||
*/
|
||||
protected function excludeBilledUsers(Collection $users): Collection
|
||||
{
|
||||
return $users->reject(fn (User $user): bool => $user->hasActiveSubscriptionOrTrial())->values();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands\Concerns;
|
||||
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
/**
|
||||
* Render Discord report embeds as plain console output, for the --no-discord
|
||||
* flag on the stats:* report commands.
|
||||
*
|
||||
* @mixin Command
|
||||
*/
|
||||
trait RendersReportToConsole
|
||||
{
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $embeds
|
||||
*/
|
||||
protected function printEmbeds(array $embeds): void
|
||||
{
|
||||
foreach ($embeds as $embed) {
|
||||
if (! empty($embed['title'])) {
|
||||
$this->newLine();
|
||||
$this->line('<options=bold>'.$this->plainText($embed['title']).'</>');
|
||||
}
|
||||
|
||||
if (! empty($embed['description'])) {
|
||||
$this->line($this->plainText($embed['description']));
|
||||
}
|
||||
|
||||
foreach ($embed['fields'] ?? [] as $field) {
|
||||
$this->newLine();
|
||||
$this->line('<options=bold>'.$this->plainText($field['name']).'</>');
|
||||
$this->line($this->plainText($field['value']));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip Discord markdown (code fences, bold) for readable console output.
|
||||
*/
|
||||
private function plainText(string $text): string
|
||||
{
|
||||
return trim(str_replace(['```', '**'], '', $text));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
class DeleteEncryptedDataAccountsCommand extends Command
|
||||
{
|
||||
use FindsUsersWithLegacyEncryption;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'encryption:delete-accounts
|
||||
{--days=7 : Spare users active within the last N days}
|
||||
{--dry-run : List the accounts that would be deleted without touching them}
|
||||
{--force : Skip the confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Soft-delete and anonymize users who still have legacy encrypted data and did not sign in within the grace window';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$days = (int) $this->option('days');
|
||||
$cutoff = now()->subDays($days);
|
||||
|
||||
$users = $this->excludeBilledUsers(
|
||||
$this->usersWithLegacyEncryption()
|
||||
->where('email', '!=', config('app.demo.email'))
|
||||
->where(function (Builder $query) use ($cutoff): void {
|
||||
$query->whereNull('last_active_at')
|
||||
->orWhere('last_active_at', '<', $cutoff);
|
||||
})
|
||||
->get()
|
||||
);
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No accounts to delete.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("Found {$users->count()} account(s) with encrypted data and no activity in the last {$days} day(s).");
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->table(['Email', 'Last active'], $users->map(fn (User $user) => [
|
||||
$user->email,
|
||||
$user->last_active_at?->toDateTimeString() ?? 'never',
|
||||
])->all());
|
||||
$this->info('[dry-run] No accounts deleted.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm("Soft-delete and anonymize {$users->count()} account(s)?", false)) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$progressBar = $this->output->createProgressBar($users->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($users as $user) {
|
||||
$user->markAsDeleted();
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Deleted {$users->count()} account(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\OpenBanking\DisconnectBankingConnection;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
|
@ -54,7 +53,7 @@ class DeleteUserCommand extends Command
|
|||
$subscription = $this->activeSubscription($user);
|
||||
$enableBankingConnections = $user->bankingConnections()
|
||||
->with('accounts')
|
||||
->where('provider', BankingProvider::EnableBanking)
|
||||
->where('provider', 'enablebanking')
|
||||
->get();
|
||||
|
||||
if ($subscription && ! $this->confirm("User '{$user->email}' has an active Stripe subscription. Cancel it before deleting the user?")) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ class FeatureEnableCommand extends Command
|
|||
{
|
||||
use ResolvesFeatures;
|
||||
|
||||
protected $signature = 'feature:enable {feature : The feature name (class name or string-based feature)} {target : User email, "all" for everyone, or a percentage like "25%" for a random rollout}';
|
||||
protected $signature = 'feature:enable {feature : The feature name (class name or string-based feature)} {target : User email or "all" for everyone}';
|
||||
|
||||
protected $description = 'Enable a feature for a specific user or all users';
|
||||
|
||||
|
|
@ -36,10 +36,6 @@ class FeatureEnableCommand extends Command
|
|||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (preg_match('/^(\d{1,3})%$/', $target, $matches)) {
|
||||
return $this->enableForPercentage($featureClass, $featureName, (int) $matches[1]);
|
||||
}
|
||||
|
||||
$user = User::where('email', $target)->first();
|
||||
|
||||
if (! $user) {
|
||||
|
|
@ -53,23 +49,4 @@ class FeatureEnableCommand extends Command
|
|||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function enableForPercentage(string $featureClass, string $featureName, int $percentage): int
|
||||
{
|
||||
if ($percentage < 1 || $percentage > 100) {
|
||||
$this->error('Percentage must be between 1 and 100.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$count = (int) ceil(User::count() * $percentage / 100);
|
||||
|
||||
User::inRandomOrder()
|
||||
->take($count)
|
||||
->each(fn (User $user) => Feature::for($user)->activate($featureClass));
|
||||
|
||||
$this->info("Feature '{$featureName}' enabled for {$count} users (~{$percentage}% of current users).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,99 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
|
||||
use App\Jobs\SendUpdateEmailJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class NotifyEncryptedDataRemovalCommand extends Command
|
||||
{
|
||||
use FindsUsersWithLegacyEncryption;
|
||||
|
||||
/**
|
||||
* The name and signature of the console command.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'encryption:notify-removal
|
||||
{--dry-run : List affected users without sending anything}
|
||||
{--force : Skip the confirmation prompt}';
|
||||
|
||||
/**
|
||||
* The console command description.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Warn users with legacy encrypted data that their account will be deleted unless they sign in within 7 days';
|
||||
|
||||
private const VIEW = 'encrypted-data-removal';
|
||||
|
||||
private const IDENTIFIER = 'encrypted-data-removal';
|
||||
|
||||
private const SUBJECT = 'Action required: sign in to keep your Whisper Money account';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
{
|
||||
$encryptedUsers = $this->usersWithLegacyEncryption()->get();
|
||||
|
||||
// Always report the total scope of legacy encrypted data (soft-deleted users are
|
||||
// excluded by the model's default scope). This is the signal for deciding whether
|
||||
// the browser-side encryption code can finally be removed, independent of who we
|
||||
// actually email below.
|
||||
$this->info("{$encryptedUsers->count()} non-deleted user(s) still have encrypted data.");
|
||||
|
||||
$users = $this->excludeBilledUsers($encryptedUsers);
|
||||
|
||||
if ($users->isEmpty()) {
|
||||
$this->info('No users to warn.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if ($this->option('dry-run')) {
|
||||
$this->renderTable($users);
|
||||
$this->info('[dry-run] No emails sent.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->option('force') && ! $this->confirm("Send the deletion warning to {$users->count()} user(s)?", true)) {
|
||||
$this->info('Cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$progressBar = $this->output->createProgressBar($users->count());
|
||||
$progressBar->start();
|
||||
|
||||
foreach ($users->values() as $index => $user) {
|
||||
// Spread over the 'emails' queue at 50/day to match the existing bulk-email convention.
|
||||
SendUpdateEmailJob::dispatch($user, self::VIEW, self::IDENTIFIER, self::SUBJECT)
|
||||
->delay(now()->addDays((int) floor($index / 50)));
|
||||
|
||||
$progressBar->advance();
|
||||
}
|
||||
|
||||
$progressBar->finish();
|
||||
$this->newLine();
|
||||
$this->info("Queued {$users->count()} warning email(s) to the 'emails' queue (50/day).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, User> $users
|
||||
*/
|
||||
private function renderTable($users): void
|
||||
{
|
||||
$this->table(['Email', 'Last active'], $users->map(fn (User $user) => [
|
||||
$user->email,
|
||||
$user->last_active_at?->toDateTimeString() ?? 'never',
|
||||
])->all());
|
||||
}
|
||||
}
|
||||
|
|
@ -40,14 +40,6 @@ class ResetDemoAccountCommand extends Command
|
|||
|
||||
public function handle(): int
|
||||
{
|
||||
$demoEnabled = config('app.demo.enabled');
|
||||
|
||||
if (! $demoEnabled) {
|
||||
$this->info('Demo account is not enabled');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$demoEmail = config('app.demo.email');
|
||||
$demoPassword = config('app.demo.password');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\IntegrationRequestStatus;
|
||||
use App\Models\IntegrationRequest;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class ReviewIntegrationRequestsCommand extends Command
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $signature = 'integration-requests:review {--all : Pick any request from the full list and change its status}';
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Review integration requests and change their status';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
return $this->option('all') ? $this->reviewAny() : $this->reviewPending();
|
||||
}
|
||||
|
||||
private function reviewPending(): int
|
||||
{
|
||||
$pending = $this->load(IntegrationRequest::query()->where('status', IntegrationRequestStatus::Pending));
|
||||
|
||||
if ($pending->isEmpty()) {
|
||||
$this->info('No pending integration requests.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->printTable($pending);
|
||||
|
||||
$changed = 0;
|
||||
|
||||
foreach ($pending as $request) {
|
||||
$decision = $this->choice(
|
||||
"Review \"{$request->name}\" ({$request->url})",
|
||||
['approve', 'in progress', 'reject', 'not doable', 'done', 'skip'],
|
||||
'skip',
|
||||
);
|
||||
|
||||
if ($decision !== 'skip' && $this->apply($request, $decision)) {
|
||||
$changed++;
|
||||
}
|
||||
}
|
||||
|
||||
$this->info("Done. Updated {$changed} of {$pending->count()} requests.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function reviewAny(): int
|
||||
{
|
||||
$requests = $this->load(IntegrationRequest::query());
|
||||
|
||||
if ($requests->isEmpty()) {
|
||||
$this->info('No integration requests.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->printTable($requests, withIndex: true);
|
||||
|
||||
$request = $requests->get((int) $this->ask('Which request do you want to update? (number)') - 1);
|
||||
|
||||
if ($request === null) {
|
||||
$this->error('That number is not on the list.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$this->apply($request, $this->choice(
|
||||
"New status for \"{$request->name}\"",
|
||||
['approve', 'in progress', 'reject', 'not doable', 'done'],
|
||||
));
|
||||
|
||||
$this->info("\"{$request->name}\" is now {$request->status->label()}.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function apply(IntegrationRequest $request, string $decision): bool
|
||||
{
|
||||
$status = match ($decision) {
|
||||
'approve' => IntegrationRequestStatus::Approved,
|
||||
'in progress' => IntegrationRequestStatus::InProgress,
|
||||
'reject' => IntegrationRequestStatus::Rejected,
|
||||
'not doable' => IntegrationRequestStatus::NotDoable,
|
||||
'done' => IntegrationRequestStatus::Done,
|
||||
default => null,
|
||||
};
|
||||
|
||||
if ($status === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$request->update([
|
||||
'status' => $status,
|
||||
'comment' => $this->commentFor($status),
|
||||
]);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function commentFor(IntegrationRequestStatus $status): ?string
|
||||
{
|
||||
if ($status === IntegrationRequestStatus::NotDoable) {
|
||||
$comment = $this->ask('Why is this integration not doable? (shown to users)');
|
||||
|
||||
while (blank($comment)) {
|
||||
$comment = $this->ask('A comment is required to mark an integration as not doable');
|
||||
}
|
||||
|
||||
return $comment;
|
||||
}
|
||||
|
||||
if ($status === IntegrationRequestStatus::InProgress) {
|
||||
return $this->ask('Add a comment for this request (optional, shown to users)') ?: null;
|
||||
}
|
||||
|
||||
// Approving, rejecting, re-queuing or marking done drops any stale public comment.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<IntegrationRequest> $query
|
||||
* @return Collection<int, IntegrationRequest>
|
||||
*/
|
||||
private function load(Builder $query): Collection
|
||||
{
|
||||
return $query
|
||||
->withCount('votes')
|
||||
// Authors who deleted their account are soft-deleted, but their requests
|
||||
// linger; load them trashed so the table still shows who submitted each one.
|
||||
->with(['user' => fn ($user) => $user->withTrashed()->select('id', 'email')])
|
||||
->orderBy('created_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, IntegrationRequest> $requests
|
||||
*/
|
||||
private function printTable(Collection $requests, bool $withIndex = false): void
|
||||
{
|
||||
$headers = ['Name', 'URL', 'Status', 'Submitted by', 'Votes', 'Created'];
|
||||
|
||||
if ($withIndex) {
|
||||
array_unshift($headers, '#');
|
||||
}
|
||||
|
||||
$rows = $requests->values()->map(function (IntegrationRequest $request, int $index) use ($withIndex): array {
|
||||
$row = [
|
||||
$request->name,
|
||||
$request->url,
|
||||
$request->status->label(),
|
||||
$request->user->email,
|
||||
$request->votes_count,
|
||||
$request->created_at?->format('Y-m-d') ?? '—',
|
||||
];
|
||||
|
||||
if ($withIndex) {
|
||||
array_unshift($row, $index + 1);
|
||||
}
|
||||
|
||||
return $row;
|
||||
})->all();
|
||||
|
||||
$this->table($headers, $rows);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\RendersReportToConsole;
|
||||
use App\Services\Ai\AiCohortReportCollector;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
|
@ -10,9 +9,7 @@ use Illuminate\Console\Command;
|
|||
|
||||
class SendAiCohortReportCommand extends Command
|
||||
{
|
||||
use RendersReportToConsole;
|
||||
|
||||
protected $signature = 'stats:ai-cohort-report {--weeks= : Number of weekly cohorts to include} {--no-discord : Print the report to the console only, without posting to Discord}';
|
||||
protected $signature = 'stats:ai-cohort-report {--weeks= : Number of weekly cohorts to include}';
|
||||
|
||||
protected $description = 'Post the weekly AI-suggestions cohort retention/conversion report to Discord';
|
||||
|
||||
|
|
@ -26,19 +23,11 @@ class SendAiCohortReportCommand extends Command
|
|||
$weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null;
|
||||
|
||||
$report = $this->collector->collect($weeks);
|
||||
$embed = $this->buildEmbed($report);
|
||||
|
||||
if ($this->option('no-discord')) {
|
||||
$this->printEmbeds([$embed]);
|
||||
$this->info('Skipped Discord (--no-discord).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
|
||||
?: config('services.discord.webhook_url');
|
||||
|
||||
(new DiscordWebhook($webhookUrl))->send('', [$embed]);
|
||||
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
|
||||
|
||||
$this->info('AI cohort report sent to Discord.');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,57 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Jobs\Drip\SendAiConsentFollowUpEmailJob;
|
||||
use App\Models\AiConsent;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class SendAiConsentFollowUpEmailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'email:ai-consent-follow-up';
|
||||
|
||||
protected $description = 'Queue the AI consent follow-up email for users who opted into AI two days ago, outside of onboarding';
|
||||
|
||||
/**
|
||||
* Consent given more than this many days after sign-up is treated as a
|
||||
* deliberate, post-onboarding opt-in (the audience for this email).
|
||||
*/
|
||||
private const ONBOARDING_GRACE_DAYS = 3;
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! config('mail.drip_emails_enabled')) {
|
||||
$this->info('Drip emails are disabled. Nothing to do.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$queued = 0;
|
||||
|
||||
AiConsent::query()
|
||||
->active()
|
||||
->whereDate('accepted_at', today()->subDays(2))
|
||||
->with('user')
|
||||
->chunkById(100, function (Collection $consents) use (&$queued): void {
|
||||
foreach ($consents as $consent) {
|
||||
$user = $consent->user;
|
||||
|
||||
if ($user === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($consent->accepted_at->lessThanOrEqualTo($user->created_at->copy()->addDays(self::ONBOARDING_GRACE_DAYS))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SendAiConsentFollowUpEmailJob::dispatch($user);
|
||||
$queued++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Queued {$queued} AI consent follow-up email(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -2,20 +2,16 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\RendersReportToConsole;
|
||||
use App\Models\User;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use App\Services\Stripe\SubscriptionStatsCollector;
|
||||
use App\Support\Money;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
|
||||
class SendDailyStatsReportCommand extends Command
|
||||
{
|
||||
use RendersReportToConsole;
|
||||
|
||||
protected $signature = 'stats:daily-report {--no-discord : Print the report to the console only, without posting to Discord}';
|
||||
protected $signature = 'stats:daily-report';
|
||||
|
||||
protected $description = 'Post yesterday\'s user and Stripe subscription stats to the Discord admin channel';
|
||||
|
||||
|
|
@ -49,16 +45,9 @@ class SendDailyStatsReportCommand extends Command
|
|||
->where('created_at', '<', $todayStart->copy()->utc())
|
||||
->count();
|
||||
|
||||
$embed = $this->buildEmbed($stats, $newUsers, $totalUsers, $yesterdayStart);
|
||||
|
||||
if ($this->option('no-discord')) {
|
||||
$this->printEmbeds([$embed]);
|
||||
$this->info('Skipped Discord (--no-discord).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->discord->send('', [$embed]);
|
||||
$this->discord->send('', [
|
||||
$this->buildEmbed($stats, $newUsers, $totalUsers, $yesterdayStart),
|
||||
]);
|
||||
|
||||
$this->info('Daily stats report sent to Discord.');
|
||||
|
||||
|
|
@ -116,12 +105,17 @@ class SendDailyStatsReportCommand extends Command
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* MRR figures are held in major units (e.g. euros), so convert to cents for
|
||||
* the shared formatter.
|
||||
*/
|
||||
private function money(float $amount, string $currency): string
|
||||
{
|
||||
return Money::format((int) round($amount * 100), $currency);
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'usd' => '$',
|
||||
'jpy' => '¥',
|
||||
'brl' => 'R$',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amount, 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,212 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Features\SubscriptionExperiment;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use App\Services\Stats\BinomialProportion;
|
||||
use App\Services\Stats\ExperimentFunnelCollector;
|
||||
use App\Services\Stats\ProportionSignificance;
|
||||
use App\Support\Money;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendExperimentFunnelReportCommand extends Command
|
||||
{
|
||||
protected $signature = 'stats:experiment-funnel
|
||||
{--no-discord : Print the report to the console only, without posting to Discord}
|
||||
{--cost-per-connection=0.4 : Estimated cost (in the Cashier currency) per bank connection, used for the Cost/Burn/CM columns}';
|
||||
|
||||
protected $description = 'Post the trial/pricing experiment funnel (per variant) to Discord';
|
||||
|
||||
private const LABELS = [
|
||||
SubscriptionExperiment::CONTROL => 'control',
|
||||
SubscriptionExperiment::REDUCED_TRIAL => 'reduced',
|
||||
SubscriptionExperiment::PAY_NOW => 'pay_now',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private ExperimentFunnelCollector $collector,
|
||||
private ProportionSignificance $significance,
|
||||
) {
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$costPerConnectionCents = (int) round(((float) $this->option('cost-per-connection')) * 100);
|
||||
$report = $this->collector->collect($costPerConnectionCents);
|
||||
|
||||
if ($report['startedAt'] === null) {
|
||||
$this->warn('Experiment not started — set SUBSCRIPTION_EXPERIMENT_STARTED_AT to begin.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
foreach ($this->tableLines($report) as $line) {
|
||||
$this->line($line);
|
||||
}
|
||||
|
||||
foreach ($this->significanceLines($report) as $line) {
|
||||
$this->line($line);
|
||||
}
|
||||
|
||||
if ($this->option('no-discord')) {
|
||||
$this->info('Skipped Discord (--no-discord).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
|
||||
?: config('services.discord.webhook_url');
|
||||
|
||||
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
|
||||
|
||||
$this->info('Experiment funnel report sent to Discord.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
|
||||
* @return list<string>
|
||||
*/
|
||||
private function tableLines(array $report): array
|
||||
{
|
||||
$revenue = $report['revenueAvailable'];
|
||||
$currency = $report['currency'];
|
||||
$lines = [sprintf(
|
||||
'%-8s %5s %5s %5s %5s %5s %6s %7s %7s %7s %7s %7s',
|
||||
'Variant', 'Assg', 'Actd', 'Card', 'MatU', 'Conv', 'Conv%', 'ARPU', 'MRR', 'Cost', 'Burn', 'CM',
|
||||
)];
|
||||
|
||||
foreach (self::LABELS as $key => $label) {
|
||||
$row = $report['variants'][$key];
|
||||
$mature = $row['assignedMature'] > 0;
|
||||
$showMoney = $revenue && $mature;
|
||||
|
||||
$lines[] = sprintf(
|
||||
'%-8s %5d %5d %5d %5d %5d %6s %7s %7s %7s %7s %7s',
|
||||
$label,
|
||||
$row['assigned'],
|
||||
$row['activated'],
|
||||
$row['subscribed'],
|
||||
$row['assignedMature'],
|
||||
$row['convertedMature'],
|
||||
$mature ? ((int) round($row['conversionRate'] * 100)).'%' : 'pend',
|
||||
$showMoney && $row['arpuCents'] !== null ? Money::format($row['arpuCents'], $currency) : '—',
|
||||
$showMoney ? Money::format($row['mrrCents'], $currency) : '—',
|
||||
$mature ? Money::format($row['costCents'], $currency) : '—',
|
||||
$mature ? Money::format($row['wastedCostCents'], $currency) : '—',
|
||||
$showMoney ? Money::format($row['contributionMarginCents'], $currency) : '—',
|
||||
);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-variant conversion-rate uncertainty (95% Wilson interval) plus the
|
||||
* leader-vs-runner-up verdict from {@see ProportionSignificance} — a Fisher
|
||||
* exact test and a Newcombe difference interval, Bonferroni-corrected — so
|
||||
* "check significance before calling a winner" has the numbers behind it.
|
||||
*
|
||||
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
|
||||
* @return list<string>
|
||||
*/
|
||||
private function significanceLines(array $report): array
|
||||
{
|
||||
$lines = ['', 'Significance (95% Wilson CI on Conv%, n = MatU):'];
|
||||
$arms = [];
|
||||
|
||||
foreach (self::LABELS as $key => $label) {
|
||||
$row = $report['variants'][$key];
|
||||
$n = (int) $row['assignedMature'];
|
||||
$k = (int) $row['convertedMature'];
|
||||
|
||||
if ($n <= 0) {
|
||||
$lines[] = sprintf(' %-8s pend (n=0)', $label);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
[$low, $high] = $this->significance->wilsonInterval($k, $n);
|
||||
$lines[] = sprintf(' %-8s %6s [%6s – %6s] (n=%d)', $label, $this->percent($k / $n), $this->percent($low), $this->percent($high), $n);
|
||||
$arms[] = new BinomialProportion($label, $k, $n);
|
||||
}
|
||||
|
||||
if (count($arms) < 2) {
|
||||
$lines[] = 'Not enough matured variants to compare yet.';
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
usort($arms, fn (BinomialProportion $a, BinomialProportion $b): int => $b->rate() <=> $a->rate());
|
||||
[$leader, $runnerUp] = [$arms[0], $arms[1]];
|
||||
$result = $this->significance->compare($leader, $runnerUp);
|
||||
|
||||
$lines[] = sprintf(
|
||||
'Leader %s vs %s: Δ %+.1f pts (95%% CI %+.1f … %+.1f pts, Newcombe).',
|
||||
$leader->label, $runnerUp->label,
|
||||
($leader->rate() - $runnerUp->rate()) * 100, $result['diffLow'] * 100, $result['diffHigh'] * 100,
|
||||
);
|
||||
$lines[] = sprintf(
|
||||
'Fisher exact p=%.3f %s α=%.3f (Bonferroni×3) -> %s.%s',
|
||||
$result['fisherP'], $result['significant'] ? '<' : '≥', $result['alpha'],
|
||||
$result['significant'] ? 'significant' : 'not significant',
|
||||
$result['significant'] ? '' : ' Keep running.',
|
||||
);
|
||||
|
||||
if ($result['minExpectedCount'] < 5.0) {
|
||||
$lines[] = sprintf(
|
||||
'(Small sample: min expected conversions %.1f < 5, so the normal-approx z=%.2f overstates — exact test used.)',
|
||||
$result['minExpectedCount'], $result['z'],
|
||||
);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
private function percent(float $rate): string
|
||||
{
|
||||
return number_format($rate * 100, 1).'%';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{startedAt: ?CarbonImmutable, currency: string, revenueAvailable: bool, costPerConnectionCents: int, variants: array<string, array<string, mixed>>} $report
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildEmbed(array $report): array
|
||||
{
|
||||
return [
|
||||
'title' => '🧪 Trial/Pricing Experiment — Funnel by Variant',
|
||||
'description' => "```\n".implode("\n", $this->tableLines($report))."\n```",
|
||||
'color' => 0xFEE75C,
|
||||
'fields' => [
|
||||
[
|
||||
'name' => 'Started',
|
||||
'value' => $report['startedAt']->format('D, d M Y').' · new signups split evenly into the three variants.',
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => '📊 Significance',
|
||||
'value' => "```\n".implode("\n", $this->significanceLines($report))."\n```",
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'Legend',
|
||||
'value' => sprintf(
|
||||
'Assg = signups · Actd = activated (connected a bank or enabled AI = cost triggered) · Card = completed checkout (card on file) · MatU = matured assigned (cohort old enough to score for this variant) · Conv = matured users who ever converted (were charged, net of refund) — time-invariant, so it does not shrink as an older cohort has longer to churn · Conv%% = Conv ÷ MatU (always ≤100%%, comparable across variants) · ARPU = MRR ÷ MatU (revenue per matured user) · MRR = monthly run-rate of *currently* paying subs (yearly ÷ 12); Conv above MRR is churn · Cost = est. connection cost of MatU (%s/connection) · Burn = connection cost of matured users who never earned net revenue (connected a bank but never paid, or paid then refunded) · CM = MRR − Cost · `pend`/`—` = no matured data yet.',
|
||||
Money::format($report['costPerConnectionCents'], $report['currency']),
|
||||
),
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => '⚠️ How to read it',
|
||||
'value' => 'Each variant matures on its own decision window (control 15d, reduced 7d, pay_now 3d, +3d settle), so at any moment MatU differs a lot between variants (pay_now matures first). **Compare variants on Conv% and ARPU — normalized per matured user — not on the absolute MRR/Cost/Burn/CM totals, which scale with MatU and so mechanically favour whichever variant has matured more.** Assg/Actd/Card are lifetime counts; everything from MatU rightward covers the matured cohort only, so the raw Actd→Card→Conv funnel mixes cohorts (immature carded users can\'t have matured yet) — read it for volume. Conv counts anyone ever charged (net of refund), so it is not depressed for older cohorts the way a live-active snapshot would be. Per-user CM is sub-cent at current volume, so treat CM as directional context, not the decision. Check significance (sample size = MatU) before calling a winner. Cost is a flat per-connection estimate across all providers, not per-provider billing.',
|
||||
'inline' => false,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\DripEmailType;
|
||||
use App\Jobs\Drip\SendPaywallFollowUpEmailJob;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendPaywallFollowUpEmailsCommand extends Command
|
||||
{
|
||||
protected $signature = 'email:paywall-follow-up';
|
||||
|
||||
protected $description = 'Queue the paywall follow-up email for users who completed onboarding yesterday but are stuck on the paywall';
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (! config('mail.drip_emails_enabled')) {
|
||||
$this->info('Drip emails are disabled. Nothing to do.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$query = User::query()
|
||||
->whereDate('onboarded_at', today()->subDay())
|
||||
->whereHas('bankingConnections')
|
||||
->whereDoesntHave('mailLogs', function ($query): void {
|
||||
$query->where('email_type', DripEmailType::PaywallFollowUp);
|
||||
});
|
||||
|
||||
$queued = 0;
|
||||
|
||||
$query->chunkById(100, function ($users) use (&$queued): void {
|
||||
foreach ($users as $user) {
|
||||
if ($user->hasProPlan()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
SendPaywallFollowUpEmailJob::dispatch($user);
|
||||
$queued++;
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Queued {$queued} paywall follow-up email(s).");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,108 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Console\Commands\Concerns\RendersReportToConsole;
|
||||
use App\Models\StuckCohortSnapshot;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use App\Services\Stats\StuckCohortReportCollector;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendStuckCohortReportCommand extends Command
|
||||
{
|
||||
use RendersReportToConsole;
|
||||
|
||||
protected $signature = 'stats:stuck-cohort-report {--no-discord : Print the report to the console only, without posting to Discord}';
|
||||
|
||||
protected $description = 'Post the weekly paywall stuck-cohort report (banked users without a valid subscription) to Discord';
|
||||
|
||||
public function __construct(private StuckCohortReportCollector $collector)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$report = $this->collector->collect();
|
||||
$embed = $this->buildEmbed($report);
|
||||
|
||||
if ($this->option('no-discord')) {
|
||||
$this->printEmbeds([$embed]);
|
||||
$this->info('Skipped Discord (--no-discord).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
|
||||
?: config('services.discord.webhook_url');
|
||||
|
||||
(new DiscordWebhook($webhookUrl))->send('', [$embed]);
|
||||
|
||||
$this->info('Stuck cohort report sent to Discord.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{snapshot: StuckCohortSnapshot, previous: ?StuckCohortSnapshot, pctDelta: ?float, stuckDelta: ?int} $report
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildEmbed(array $report): array
|
||||
{
|
||||
$snapshot = $report['snapshot'];
|
||||
|
||||
$lines = [
|
||||
sprintf('Stuck %d', $snapshot->stuck_count),
|
||||
sprintf('Onboarded %d', $snapshot->onboarded_count),
|
||||
sprintf('Stuck rate %s%%', $this->formatPct((float) $snapshot->stuck_pct)),
|
||||
];
|
||||
|
||||
if ($report['previous'] !== null) {
|
||||
$lines[] = '';
|
||||
$lines[] = sprintf(
|
||||
'vs %s: %s pp · %s stuck',
|
||||
$report['previous']->date->format('d M'),
|
||||
$this->formatSignedPct((float) $report['pctDelta']),
|
||||
$this->formatSignedInt((int) $report['stuckDelta']),
|
||||
);
|
||||
} else {
|
||||
$lines[] = '';
|
||||
$lines[] = 'First snapshot — no previous week to compare.';
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => '🪤 Paywall — Weekly Stuck Cohort',
|
||||
'description' => "```\n".implode("\n", $lines)."\n```",
|
||||
'color' => 0xED4245,
|
||||
'fields' => [
|
||||
[
|
||||
'name' => 'Definition',
|
||||
'value' => 'Stuck = onboarded users with a non-deleted banking connection but no valid subscription (active/trialing/past_due, or canceled but still within the grace period).',
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'Denominator',
|
||||
'value' => 'Stuck rate = stuck / onboarded users (`onboarded_at` not null) — the population that has reached the paywall.',
|
||||
'inline' => false,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function formatPct(float $value): string
|
||||
{
|
||||
return rtrim(rtrim(number_format($value, 2, '.', ''), '0'), '.');
|
||||
}
|
||||
|
||||
private function formatSignedPct(float $value): string
|
||||
{
|
||||
$sign = $value > 0 ? '+' : '';
|
||||
|
||||
return $sign.$this->formatPct($value);
|
||||
}
|
||||
|
||||
private function formatSignedInt(int $value): string
|
||||
{
|
||||
return ($value > 0 ? '+' : '').$value;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,136 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use App\Services\Stats\SubscriptionFunnelCollector;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class SendSubscriptionFunnelReportCommand extends Command
|
||||
{
|
||||
protected $signature = 'stats:subscription-funnel {--weeks= : Number of weekly cohorts to include} {--no-discord : Print the report to the console only, without posting to Discord}';
|
||||
|
||||
protected $description = 'Post the weekly registration -> subscription -> paid funnel to Discord';
|
||||
|
||||
public function __construct(private SubscriptionFunnelCollector $collector)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$weeks = $this->option('weeks') !== null ? (int) $this->option('weeks') : null;
|
||||
|
||||
$report = $this->collector->collect($weeks);
|
||||
|
||||
foreach ($this->tableLines($report) as $line) {
|
||||
$this->line($line);
|
||||
}
|
||||
|
||||
if ($this->option('no-discord')) {
|
||||
$this->info('Skipped Discord (--no-discord).');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$webhookUrl = config('services.discord.ai_cohort_webhook_url')
|
||||
?: config('services.discord.webhook_url');
|
||||
|
||||
(new DiscordWebhook($webhookUrl))->send('', [$this->buildEmbed($report)]);
|
||||
|
||||
$this->info('Subscription funnel report sent to Discord.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{trialDays: int, weeks: list<array<string, mixed>>} $report
|
||||
* @return list<string>
|
||||
*/
|
||||
private function tableLines(array $report): array
|
||||
{
|
||||
$lines = [sprintf('%-9s %5s %5s %5s %5s %5s %5s', 'Week', 'Reg', 'Sub', 'Sub%', 'Paid', 'Pd%', 'T2P')];
|
||||
|
||||
foreach ($report['weeks'] as $row) {
|
||||
$lines[] = sprintf(
|
||||
'%-9s %5d %5d %5s %5d %5s %5s%s',
|
||||
$row['week'],
|
||||
$row['registered'],
|
||||
$row['subscribed'],
|
||||
$this->rateCell($row['subscribedRate'], $row['subscribedMature'], $row['registered']),
|
||||
$row['paid'],
|
||||
$this->rateCell($row['paidRate'], $row['paidMature'], $row['registered']),
|
||||
$this->rateCell($row['trialToPaidRate'], $row['paidMature'], $row['subscribed']),
|
||||
$row['surge'] ? ' ⚡' : '',
|
||||
);
|
||||
}
|
||||
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{trialDays: int, weeks: list<array<string, mixed>>} $report
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildEmbed(array $report): array
|
||||
{
|
||||
$mature = array_values(array_filter($report['weeks'], fn (array $row): bool => $row['paidMature']));
|
||||
|
||||
$registered = array_sum(array_column($mature, 'registered'));
|
||||
$subscribed = array_sum(array_column($mature, 'subscribed'));
|
||||
$paid = array_sum(array_column($mature, 'paid'));
|
||||
|
||||
$totals = $registered > 0
|
||||
? sprintf(
|
||||
"Registered %d\nSubscribed %d (%s%%)\nPaid %d (%s%% of reg · %s%% of subs)",
|
||||
$registered,
|
||||
$subscribed,
|
||||
$this->pct($subscribed / $registered),
|
||||
$paid,
|
||||
$this->pct($paid / $registered),
|
||||
$subscribed > 0 ? $this->pct($paid / $subscribed) : '—',
|
||||
)
|
||||
: 'No mature cohorts yet.';
|
||||
|
||||
return [
|
||||
'title' => '💸 Subscription Funnel — Weekly Cohorts',
|
||||
'description' => "```\n".implode("\n", $this->tableLines($report))."\n```",
|
||||
'color' => 0x57F287,
|
||||
'fields' => [
|
||||
[
|
||||
'name' => 'Mature cohorts (baseline)',
|
||||
'value' => "```\n".$totals."\n```",
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => 'Legend',
|
||||
'value' => 'Reg = signups · Sub = started a plan ≤30d after signup · Paid = that plan billed past the '.$report['trialDays'].'d trial (active, or canceled only after billing) · Sub%/Pd% of signups · T2P = paid ÷ subscribed · `pend` = cohort too young to score · ⚡ = signup surge',
|
||||
'inline' => false,
|
||||
],
|
||||
[
|
||||
'name' => '⚠️ Directional only',
|
||||
'value' => 'Cohorts compared at equal age. Surge weeks (⚡, e.g. launch/marketing) differ in acquisition channel and are not controlled — compare organic weeks like-for-like. This is the pre-A/B baseline, not a randomised test.',
|
||||
'inline' => false,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function rateCell(?float $rate, bool $mature, int $denominator): string
|
||||
{
|
||||
if ($denominator === 0) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
if (! $mature || $rate === null) {
|
||||
return 'pend';
|
||||
}
|
||||
|
||||
return $this->pct($rate).'%';
|
||||
}
|
||||
|
||||
private function pct(float $rate): string
|
||||
{
|
||||
return (string) ((int) round($rate * 100));
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\Stripe\SubscriptionStatsCollector;
|
||||
use App\Support\Money;
|
||||
use Illuminate\Console\Command;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
|
||||
|
|
@ -78,12 +77,17 @@ class StripeSubscriptionStatsCommand extends Command
|
|||
$this->newLine();
|
||||
}
|
||||
|
||||
/**
|
||||
* MRR figures are held in major units (e.g. euros), so convert to cents for
|
||||
* the shared formatter.
|
||||
*/
|
||||
private function format(float $amount, string $currency): string
|
||||
{
|
||||
return Money::format((int) round($amount * 100), $currency);
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'usd' => '$',
|
||||
'jpy' => '¥',
|
||||
'brl' => 'R$',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amount, 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Console\Commands;
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Jobs\SyncAllBankingConnectionsJob;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\BankingConnection;
|
||||
|
|
@ -56,7 +55,7 @@ class SyncBankingConnections extends Command
|
|||
->orWhere('valid_until', '>', now());
|
||||
});
|
||||
})->orWhere(function ($query) {
|
||||
$query->where('provider', BankingProvider::EnableBanking)
|
||||
$query->where('provider', 'enablebanking')
|
||||
->where('status', BankingConnectionStatus::Active)
|
||||
->whereNotNull('valid_until')
|
||||
->where('valid_until', '<=', now());
|
||||
|
|
@ -93,9 +92,9 @@ class SyncBankingConnections extends Command
|
|||
|
||||
$connections->each(function (BankingConnection $connection) use ($sync, $fullSync) {
|
||||
if ($sync) {
|
||||
$this->info("Syncing {$connection->provider->value} connection {$connection->id}...");
|
||||
$this->info("Syncing {$connection->provider} connection {$connection->id}...");
|
||||
SyncBankingConnectionJob::dispatchSync($connection, $fullSync);
|
||||
$this->info("Finished syncing {$connection->provider->value} connection {$connection->id}.");
|
||||
$this->info("Finished syncing {$connection->provider} connection {$connection->id}.");
|
||||
} else {
|
||||
SyncBankingConnectionJob::dispatch($connection, $fullSync);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Support\Money;
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Stripe\Exception\ApiErrorException;
|
||||
|
|
@ -51,7 +50,6 @@ class SyncStripePricesCommand extends Command
|
|||
$amountInCents = (int) round($plan['price'] * 100);
|
||||
$billingPeriod = $plan['billing_period'] ?? null;
|
||||
$productId = config('subscriptions.products.pro');
|
||||
$formattedAmount = Money::format($amountInCents, $currency);
|
||||
|
||||
$this->line(" <options=bold>{$plan['name']}</>");
|
||||
|
||||
|
|
@ -70,7 +68,7 @@ class SyncStripePricesCommand extends Command
|
|||
|
||||
if (! $dryRun) {
|
||||
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: true);
|
||||
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$formattedAmount}/{$billingPeriod})");
|
||||
$this->line(" <fg=green>✓</> Transferred to {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
|
||||
} else {
|
||||
$this->line(" <fg=cyan>[dry-run]</> Would create new price and transfer lookup key '{$lookupKey}'");
|
||||
}
|
||||
|
|
@ -81,9 +79,9 @@ class SyncStripePricesCommand extends Command
|
|||
|
||||
if (! $dryRun) {
|
||||
$price = $this->createPrice($productId, $amountInCents, $currency, $billingPeriod, $lookupKey, transferLookupKey: false);
|
||||
$this->line(" <fg=green>✓</> Created {$price->id} ({$formattedAmount}/{$billingPeriod})");
|
||||
$this->line(" <fg=green>✓</> Created {$price->id} ({$this->formatAmount($amountInCents, $currency)}/{$billingPeriod})");
|
||||
} else {
|
||||
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$formattedAmount}/{$billingPeriod}");
|
||||
$this->line(" <fg=cyan>[dry-run]</> Would create price '{$lookupKey}' at {$this->formatAmount($amountInCents, $currency)}/{$billingPeriod}");
|
||||
}
|
||||
|
||||
$created++;
|
||||
|
|
@ -150,4 +148,16 @@ class SyncStripePricesCommand extends Command
|
|||
|
||||
return $currencyMatches && $amountMatches && $intervalMatches;
|
||||
}
|
||||
|
||||
private function formatAmount(int $amountInCents, string $currency): string
|
||||
{
|
||||
$symbol = match (strtolower($currency)) {
|
||||
'eur' => '€',
|
||||
'gbp' => '£',
|
||||
'jpy' => '¥',
|
||||
default => strtoupper($currency).' ',
|
||||
};
|
||||
|
||||
return $symbol.number_format($amountInCents / 100, 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,105 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\Subscription\RefundSelfServe;
|
||||
use App\Features\SubscriptionExperiment;
|
||||
use App\Models\User;
|
||||
use App\Services\Subscriptions\ExperimentOffer;
|
||||
use Illuminate\Console\Command;
|
||||
use Laravel\Cashier\Cashier;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
/**
|
||||
* Live sandbox check for the pay_now self-service refund — the one path that
|
||||
* Pest tests can only mock. It creates a real, immediately-charged subscription
|
||||
* against the Stripe test environment, runs the actual RefundSelfServe action,
|
||||
* and confirms via the Stripe API that the charge was refunded and the
|
||||
* subscription canceled. Run before flipping SUBSCRIPTION_EXPERIMENT_STARTED_AT.
|
||||
*
|
||||
* Refuses to run against anything but Stripe test keys.
|
||||
*/
|
||||
class VerifyRefundFlowCommand extends Command
|
||||
{
|
||||
protected $signature = 'stripe:verify-refund';
|
||||
|
||||
protected $description = 'Verify the pay_now self-service refund end-to-end against the Stripe sandbox';
|
||||
|
||||
public function __construct(private ExperimentOffer $offer)
|
||||
{
|
||||
parent::__construct();
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
if (app()->isProduction() || ! str_starts_with((string) config('cashier.secret'), 'sk_test')) {
|
||||
$this->error('Refusing to run: this command requires Stripe test keys and a non-production environment.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
config(['subscriptions.tax_rates' => []]);
|
||||
|
||||
$passed = true;
|
||||
$check = function (string $label, bool $ok) use (&$passed): void {
|
||||
$this->line(($ok ? '<fg=green>PASS</>' : '<fg=red>FAIL</>').' '.$label);
|
||||
$passed = $passed && $ok;
|
||||
};
|
||||
|
||||
$lookup = config('subscriptions.plans.monthly.stripe_lookup_key');
|
||||
$prices = Cashier::stripe()->prices->all(['lookup_keys' => [$lookup], 'limit' => 1]);
|
||||
$priceId = $prices->data[0]->id ?? null;
|
||||
|
||||
if ($priceId === null) {
|
||||
$this->error("No Stripe price found for lookup key '{$lookup}'.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$user = User::factory()->create([
|
||||
'email' => 'refund-sandbox-'.uniqid().'@whisper.test',
|
||||
'created_at' => now(),
|
||||
]);
|
||||
Feature::for($user)->activate(SubscriptionExperiment::class, SubscriptionExperiment::PAY_NOW);
|
||||
|
||||
try {
|
||||
$user->newSubscription('default', $priceId)->create('pm_card_visa');
|
||||
|
||||
$subscription = $user->subscription('default');
|
||||
$check('subscription active after immediate charge', $subscription->active() && $subscription->stripe_status === 'active');
|
||||
$check('canSelfRefund is true before refund', $this->offer->canSelfRefund($user));
|
||||
|
||||
$paymentIntentId = $subscription->latestPayment()?->asStripePaymentIntent()->id;
|
||||
$check('latestPayment() resolves a payment intent', $paymentIntentId !== null);
|
||||
|
||||
app(RefundSelfServe::class)->handle($user->fresh());
|
||||
|
||||
$subscription = $user->subscription('default')->fresh();
|
||||
$check('refunded_at is stamped', $subscription->refunded_at !== null);
|
||||
$check('subscription is canceled', $subscription->canceled());
|
||||
$check('canSelfRefund is false after refund', ! $this->offer->canSelfRefund($user->fresh()));
|
||||
|
||||
$intent = Cashier::stripe()->paymentIntents->retrieve($paymentIntentId, ['expand' => ['latest_charge']]);
|
||||
$charge = $intent->latest_charge;
|
||||
$check('Stripe charge shows a full refund', is_object($charge) && $charge->refunded === true);
|
||||
$this->line(' amount_refunded='.($charge->amount_refunded ?? 'n/a'));
|
||||
} catch (\Throwable $exception) {
|
||||
$this->error($exception::class.': '.$exception->getMessage());
|
||||
$passed = false;
|
||||
} finally {
|
||||
try {
|
||||
if ($user->hasStripeId()) {
|
||||
Cashier::stripe()->customers->delete($user->stripe_id);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// best-effort sandbox cleanup
|
||||
}
|
||||
$user->forceDelete();
|
||||
}
|
||||
|
||||
$this->newLine();
|
||||
$this->{$passed ? 'info' : 'error'}($passed ? 'Refund flow verified.' : 'Refund flow verification FAILED.');
|
||||
|
||||
return $passed ? self::SUCCESS : self::FAILURE;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Contracts;
|
||||
|
||||
use App\Models\BankingConnection;
|
||||
|
||||
interface BankingConnectionSyncer
|
||||
{
|
||||
/**
|
||||
* Sync every account belonging to the connection.
|
||||
*
|
||||
* @return array<string, mixed> Metadata to persist on the sync log.
|
||||
*/
|
||||
public function sync(BankingConnection $connection, bool $isFirstSync): array;
|
||||
|
||||
/**
|
||||
* Whether the connection's consent can expire (consent-based providers).
|
||||
*/
|
||||
public function expires(): bool;
|
||||
|
||||
/**
|
||||
* Whether a permanent auth failure should notify the user (API-key providers).
|
||||
*/
|
||||
public function notifiesOnAuthFailure(): bool;
|
||||
}
|
||||
|
|
@ -45,7 +45,7 @@ interface BankingProviderInterface
|
|||
/**
|
||||
* Get session details and status.
|
||||
*
|
||||
* @return array{status: string, access: array, accounts: array, accounts_data?: array}
|
||||
* @return array{status: string, access: array, accounts: array}
|
||||
*/
|
||||
public function getSession(string $sessionId): array;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,17 +23,7 @@ enum AccountType: string
|
|||
|
||||
public function reducesNetWorth(): bool
|
||||
{
|
||||
return $this === self::Loan;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account type is part of the net worth total at all. Credit
|
||||
* cards are spending accounts, not wealth, so they are excluded entirely
|
||||
* (neither added nor subtracted) while still being tracked on their own.
|
||||
*/
|
||||
public function countsInNetWorth(): bool
|
||||
{
|
||||
return $this !== self::CreditCard;
|
||||
return in_array($this, [self::CreditCard, self::Loan], true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,27 +33,4 @@ enum AccountType: string
|
|||
{
|
||||
return in_array($this, [self::Investment, self::Retirement, self::RealEstate], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account type surfaces a transaction ledger in the UI. The
|
||||
* account detail page renders a transaction list only for these types.
|
||||
*
|
||||
* Mirrors the frontend `isTransactionalAccount`. This is intentionally
|
||||
* distinct from isNonTransactional(): that is the narrower balance-only
|
||||
* concept used for net-worth charts and treats loan accounts as
|
||||
* balance-tracking-with-amortization, whereas a loan has no editable ledger.
|
||||
*/
|
||||
public function hasTransactionLedger(): bool
|
||||
{
|
||||
return ! in_array($this, [self::Investment, self::Loan, self::Retirement, self::RealEstate], true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a bank connection can sync transactions into this account type.
|
||||
* Excludes balance/value-tracking types (loan, investment, retirement, real estate).
|
||||
*/
|
||||
public function canSyncBankTransactions(): bool
|
||||
{
|
||||
return in_array($this, [self::Checking, self::CreditCard, self::Savings, self::Others], true);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,106 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
use App\Services\Banking\CredentialField;
|
||||
|
||||
enum BankingProvider: string
|
||||
{
|
||||
case IndexaCapital = 'indexacapital';
|
||||
case Binance = 'binance';
|
||||
case Bitpanda = 'bitpanda';
|
||||
case Coinbase = 'coinbase';
|
||||
case InteractiveBrokers = 'interactivebrokers';
|
||||
case Wise = 'wise';
|
||||
case EnableBanking = 'enablebanking';
|
||||
|
||||
/**
|
||||
* Whether the provider authenticates with user-supplied API keys
|
||||
* rather than EnableBanking's hosted OAuth flow.
|
||||
*/
|
||||
public function usesApiKey(): bool
|
||||
{
|
||||
return $this !== self::EnableBanking;
|
||||
}
|
||||
|
||||
/**
|
||||
* The account type that this provider's pending accounts default to.
|
||||
*/
|
||||
public function defaultAccountType(): AccountType
|
||||
{
|
||||
return match ($this) {
|
||||
self::IndexaCapital, self::Binance, self::Bitpanda, self::Coinbase, self::InteractiveBrokers => AccountType::Investment,
|
||||
self::Wise, self::EnableBanking => AccountType::Checking,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential inputs this provider collects, each mapped to the
|
||||
* encrypted connection column it is stored in, with its validation rules.
|
||||
*
|
||||
* Single source of truth for credential shape: the connect controllers,
|
||||
* the update request and the update controller all derive from this so a
|
||||
* new provider is described in exactly one place. Empty for consent-based
|
||||
* providers that authenticate without user-supplied credentials.
|
||||
*
|
||||
* @return array<int, CredentialField>
|
||||
*/
|
||||
public function credentialFields(): array
|
||||
{
|
||||
return match ($this) {
|
||||
self::IndexaCapital, self::Wise => [
|
||||
new CredentialField('api_token', 'api_token', ['required', 'string', 'min:10']),
|
||||
],
|
||||
self::Binance => [
|
||||
new CredentialField('api_key', 'api_token', ['required', 'string', 'min:10']),
|
||||
new CredentialField('api_secret', 'api_secret', ['required', 'string', 'min:10']),
|
||||
],
|
||||
self::Bitpanda => [
|
||||
new CredentialField('api_key', 'api_token', ['required', 'string', 'min:10']),
|
||||
],
|
||||
self::Coinbase => [
|
||||
new CredentialField('api_key_name', 'api_token', ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i']),
|
||||
new CredentialField('private_key', 'api_secret', ['required', 'string', 'min:40']),
|
||||
],
|
||||
self::InteractiveBrokers => [
|
||||
new CredentialField('token', 'api_token', ['required', 'string', 'min:10']),
|
||||
new CredentialField('query_id', 'api_secret', ['required', 'string', 'min:3']),
|
||||
],
|
||||
self::EnableBanking => [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation rules for this provider's credential inputs, keyed by input
|
||||
* name. Shared by the connect Form Requests and the update request.
|
||||
*
|
||||
* @return array<string, array<int, mixed>>
|
||||
*/
|
||||
public function credentialRules(): array
|
||||
{
|
||||
$rules = [];
|
||||
|
||||
foreach ($this->credentialFields() as $field) {
|
||||
$rules[$field->input] = $field->rules;
|
||||
}
|
||||
|
||||
return $rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map validated request input to the encrypted connection columns.
|
||||
*
|
||||
* @param array<string, mixed> $input
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function credentialColumns(array $input): array
|
||||
{
|
||||
$columns = [];
|
||||
|
||||
foreach ($this->credentialFields() as $field) {
|
||||
$columns[$field->column] = $input[$field->input];
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,7 +11,5 @@ enum DripEmailType: string
|
|||
case ImportHelp = 'import_help';
|
||||
case Feedback = 'feedback';
|
||||
case SubscriptionCancelled = 'subscription_cancelled';
|
||||
case PaywallFollowUp = 'paywall_follow_up';
|
||||
case AiConsentFollowUp = 'ai_consent_follow_up';
|
||||
case Update = 'update';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ImportConfigType: string
|
||||
{
|
||||
case Transaction = 'transaction';
|
||||
case Balance = 'balance';
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum IntegrationRequestStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Approved = 'approved';
|
||||
case InProgress = 'in_progress';
|
||||
case Rejected = 'rejected';
|
||||
case NotDoable = 'not_doable';
|
||||
case Done = 'done';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::Pending => 'Pending',
|
||||
self::Approved => 'Approved',
|
||||
self::InProgress => 'In progress',
|
||||
self::Rejected => 'Rejected',
|
||||
self::NotDoable => 'Not doable',
|
||||
self::Done => 'Done',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum Locale: string
|
||||
{
|
||||
case English = 'en';
|
||||
case Spanish = 'es';
|
||||
case French = 'fr';
|
||||
|
||||
/**
|
||||
* Detect the best-matching locale from an Accept-Language header,
|
||||
* falling back to English.
|
||||
*/
|
||||
public static function detectFromHeader(?string $acceptLanguage): self
|
||||
{
|
||||
$acceptLanguage ??= '';
|
||||
|
||||
foreach ([self::Spanish, self::French] as $locale) {
|
||||
if ($acceptLanguage === $locale->value || preg_match('/^'.$locale->value.'(-|,|;)/i', $acceptLanguage) === 1) {
|
||||
return $locale;
|
||||
}
|
||||
}
|
||||
|
||||
return self::English;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,6 @@ enum PlanFeature: string
|
|||
{
|
||||
case ConnectedAccounts = 'connected_accounts';
|
||||
case AiSuggestions = 'ai_suggestions';
|
||||
case McpAccess = 'mcp_access';
|
||||
|
||||
/**
|
||||
* Whether access to this feature is gated behind a paid (Pro) plan.
|
||||
|
|
@ -14,7 +13,7 @@ enum PlanFeature: string
|
|||
public function requiresProPlan(): bool
|
||||
{
|
||||
return match ($this) {
|
||||
self::ConnectedAccounts, self::AiSuggestions, self::McpAccess => true,
|
||||
self::ConnectedAccounts, self::AiSuggestions => true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,14 +6,12 @@ enum RuleOrigin: string
|
|||
{
|
||||
case User = 'user';
|
||||
case Ai = 'ai';
|
||||
case Correction = 'correction';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::User => 'User',
|
||||
self::Ai => 'AI',
|
||||
self::Correction => 'Correction',
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,5 +7,4 @@ enum TransactionSource: string
|
|||
case ManuallyCreated = 'manually_created';
|
||||
case Imported = 'imported';
|
||||
case EnableBanking = 'enablebanking';
|
||||
case Wise = 'wise';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
/**
|
||||
* The upsell entry point a subscription checkout was started from, used to
|
||||
* attribute revenue to each upgrade prompt. The value is carried into Stripe as
|
||||
* subscription metadata and persisted onto the local subscription so revenue
|
||||
* can be measured per upsell point.
|
||||
*
|
||||
* Mirrored on the frontend by the UpsellSource union in
|
||||
* resources/js/components/subscription/upgrade-dialog.tsx — keep both in sync
|
||||
* when adding a point (an unknown value is silently dropped by tryFrom()).
|
||||
*/
|
||||
enum UpsellSource: string
|
||||
{
|
||||
case AiCategorization = 'ai_categorization';
|
||||
case Connections = 'connections';
|
||||
case Accounts = 'accounts';
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions\Banking;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Debug\ShouldntReport;
|
||||
use Throwable;
|
||||
|
||||
class ExpiredBankingSessionException extends Exception implements ShouldntReport
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions\Banking;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Debug\ShouldntReport;
|
||||
use Throwable;
|
||||
|
||||
class InaccessibleBankAccountException extends Exception implements ShouldntReport
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Exceptions\Banking;
|
||||
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Debug\ShouldntReport;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* The banking provider rejected the requested transactions date range as wider
|
||||
* than the bank is willing to serve (EnableBanking HTTP 422 "Wrong transactions
|
||||
* period requested"). Recoverable by retrying with a narrower window, so it is
|
||||
* not reported: the sync layer clamps and retries, and only skips the account
|
||||
* if even the narrowest window is refused.
|
||||
*/
|
||||
class WrongTransactionsPeriodException extends Exception implements ShouldntReport
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, 0, $previous);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Carbon;
|
||||
|
||||
/**
|
||||
* Gates AI auto-categorization of transactions. Rolled out to users who signed
|
||||
* up after `ai_categorization.rollout_after`; combined with the pro plan and
|
||||
* AI consent checks in AiCategorizationGate.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class AiCategorization
|
||||
{
|
||||
/**
|
||||
* Resolve the feature's initial value.
|
||||
*/
|
||||
public function resolve(?User $user): bool
|
||||
{
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$rolloutAfter = config('ai_categorization.rollout_after');
|
||||
|
||||
if (blank($rolloutAfter)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $user->created_at !== null
|
||||
&& $user->created_at->gt(Carbon::parse($rolloutAfter));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,70 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Features;
|
||||
|
||||
use App\Models\User;
|
||||
use Carbon\CarbonImmutable;
|
||||
|
||||
/**
|
||||
* A/B/C assignment for the trial/pricing experiment.
|
||||
*
|
||||
* Users who registered before `subscriptions.experiment.started_at` (or any user
|
||||
* while it is null) are "legacy" and behave like the control group. Everyone who
|
||||
* registered on or after the start is split evenly into the three variants by a
|
||||
* stable hash of their id, so the bucket never changes for a given user.
|
||||
*
|
||||
* The split is deterministic (crc32(id) % 3) and persisted by Pennant; the funnel
|
||||
* report reads that persisted value so it always matches what the user was served.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class SubscriptionExperiment
|
||||
{
|
||||
public const LEGACY = 'legacy';
|
||||
|
||||
public const CONTROL = 'control';
|
||||
|
||||
public const REDUCED_TRIAL = 'reduced_trial';
|
||||
|
||||
public const PAY_NOW = 'pay_now';
|
||||
|
||||
/**
|
||||
* In-memory override that pins every user to the winning variant once the
|
||||
* experiment is decided. Returning non-null skips both storage and resolve,
|
||||
* so flipping SUBSCRIPTION_EXPERIMENT_FORCE_VARIANT rolls the winner out to
|
||||
* everyone without a deploy and without rewriting stored assignments.
|
||||
*/
|
||||
public function before(?User $user): ?string
|
||||
{
|
||||
$forced = config('subscriptions.experiment.force_variant');
|
||||
|
||||
return in_array($forced, [self::CONTROL, self::REDUCED_TRIAL, self::PAY_NOW], true)
|
||||
? $forced
|
||||
: null;
|
||||
}
|
||||
|
||||
public function resolve(?User $user): string
|
||||
{
|
||||
$startedAt = config('subscriptions.experiment.started_at');
|
||||
|
||||
if ($user === null || $startedAt === null || $user->created_at?->lt(CarbonImmutable::parse($startedAt))) {
|
||||
return self::LEGACY;
|
||||
}
|
||||
|
||||
return self::bucket((string) $user->getKey());
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic, evenly-split bucket for a post-start user. The funnel report
|
||||
* mirrors this in PHP to attribute users without reading Pennant per row, so
|
||||
* keep the formula here as the single source of truth.
|
||||
*/
|
||||
public static function bucket(string $key): string
|
||||
{
|
||||
return match (crc32($key) % 3) {
|
||||
0 => self::CONTROL,
|
||||
1 => self::REDUCED_TRIAL,
|
||||
default => self::PAY_NOW,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -5,12 +5,9 @@ namespace App\Features;
|
|||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Gates the MCP access settings screen while the feature is being rolled out.
|
||||
* Toggle per user / everyone with `php artisan feature:enable App\\Features\\Mcp <target>`.
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
class Mcp
|
||||
class TransactionAnalysis
|
||||
{
|
||||
/**
|
||||
* Resolve the feature's initial value.
|
||||
|
|
@ -3,15 +3,12 @@
|
|||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\AccountType;
|
||||
use App\Http\Requests\ReorderAccountsRequest;
|
||||
use App\Http\Requests\UpdateAccountVisibilityRequest;
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\LoanDetail;
|
||||
use App\Services\AccountMetricsService;
|
||||
use App\Services\LoanAmortizationService;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
|
@ -32,7 +29,7 @@ class AccountController extends Controller
|
|||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id'])
|
||||
->orderBy('position')
|
||||
->orderByRaw("FIELD(type, 'checking', 'savings', 'investment', 'retirement', 'real_estate', 'loan', 'credit_card', 'others')")
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
|
|
@ -46,27 +43,6 @@ class AccountController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function reorder(ReorderAccountsRequest $request): RedirectResponse
|
||||
{
|
||||
// ponytail: one update per account; fine for the handful of accounts a
|
||||
// user has. Switch to a single CASE update if that ever grows large.
|
||||
foreach (array_values($request->validated('ids')) as $position => $id) {
|
||||
Account::query()
|
||||
->whereKey($id)
|
||||
->where('user_id', $request->user()->id)
|
||||
->update(['position' => $position]);
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function updateVisibility(UpdateAccountVisibilityRequest $request, Account $account): RedirectResponse
|
||||
{
|
||||
$account->update(['hidden_on_dashboard' => $request->validated('hidden')]);
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
public function show(Request $request, Account $account): Response
|
||||
{
|
||||
$this->authorize('view', $account);
|
||||
|
|
@ -122,18 +98,6 @@ class AccountController extends Controller
|
|||
|
||||
return Inertia::render('Accounts/Show', [
|
||||
'account' => $data,
|
||||
// Deferred so the page shell paints without blocking on the ledger
|
||||
// query/serialization. It stays the whole set because search and
|
||||
// filtering run client-side over decrypted rows. ponytail: window it
|
||||
// server-side only if one account's history gets big enough that the
|
||||
// transfer itself hurts.
|
||||
'transactions' => $account->type->hasTransactionLedger()
|
||||
? Inertia::defer(fn () => $account->transactions()
|
||||
->with(['category', 'labels'])
|
||||
->orderBy('transaction_date', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get())
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Controllers\Ai;
|
||||
|
||||
use App\Actions\Ai\StartCategorizationBackfill;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -10,29 +9,13 @@ use Illuminate\Http\Request;
|
|||
class AiConsentController extends Controller
|
||||
{
|
||||
/**
|
||||
* Record the user's broad "use AI to help understand my finances" consent
|
||||
* and kick off a backfill of their uncategorized transactions.
|
||||
* Record the user's broad "use AI to help understand my finances" consent.
|
||||
*/
|
||||
public function store(Request $request, StartCategorizationBackfill $startBackfill): JsonResponse
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
$user->recordAiConsent();
|
||||
$user->dismissAiConsentPrompt();
|
||||
$request->user()->recordAiConsent();
|
||||
|
||||
return response()->json([
|
||||
'consented' => true,
|
||||
'categorization' => $startBackfill->handle($user),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently dismiss the AI consent prompt without granting consent.
|
||||
*/
|
||||
public function dismiss(Request $request): JsonResponse
|
||||
{
|
||||
$request->user()->dismissAiConsentPrompt();
|
||||
|
||||
return response()->json(['dismissed' => true]);
|
||||
return response()->json(['consented' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -1,26 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Ai;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Jobs\CategorizeUncategorizedTransactionsJob;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class CategorizationController extends Controller
|
||||
{
|
||||
/**
|
||||
* Return current progress for a consent-triggered categorization backfill.
|
||||
*/
|
||||
public function status(Request $request, string $jobId): JsonResponse
|
||||
{
|
||||
$progress = Cache::get(CategorizeUncategorizedTransactionsJob::cacheKeyForJobId($request->user()->id, $jobId));
|
||||
|
||||
if ($progress === null) {
|
||||
return response()->json(['message' => 'Job not found.'], 404);
|
||||
}
|
||||
|
||||
return response()->json($progress);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\ImportConfigType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\UpdateAccountImportConfigRequest;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class AccountImportConfigController extends Controller
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
|
||||
public function show(Request $request, Account $account): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $account);
|
||||
|
||||
$validated = $request->validate([
|
||||
'type' => ['required', Rule::enum(ImportConfigType::class)],
|
||||
]);
|
||||
|
||||
$config = $account->importConfigs()
|
||||
->where('type', $validated['type'])
|
||||
->first();
|
||||
|
||||
return response()->json(['data' => $config?->config]);
|
||||
}
|
||||
|
||||
public function update(UpdateAccountImportConfigRequest $request, Account $account): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $account);
|
||||
|
||||
$config = $account->importConfigs()->updateOrCreate(
|
||||
['type' => $request->validated('type')],
|
||||
['config' => $request->validated('config')],
|
||||
);
|
||||
|
||||
return response()->json(['data' => $config->config]);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,11 +4,9 @@ namespace App\Http\Controllers\Api;
|
|||
|
||||
use App\Enums\CategoryCashflowDirection;
|
||||
use App\Enums\CategoryType;
|
||||
use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\CashflowSummaryService;
|
||||
use App\Services\CategoryTree;
|
||||
use App\Services\ExchangeRateService;
|
||||
use App\Services\PeriodComparator;
|
||||
|
|
@ -16,12 +14,9 @@ use Carbon\Carbon;
|
|||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use InvalidArgumentException;
|
||||
|
||||
class CashflowAnalyticsController extends Controller
|
||||
{
|
||||
use ConvertsTransactionCurrency;
|
||||
|
||||
private const MAX_TREND_MONTHS = 24;
|
||||
|
||||
public function __construct(
|
||||
|
|
@ -210,8 +205,14 @@ class CashflowAnalyticsController extends Controller
|
|||
$savings = $this->sumOutflowTransactions($transactions, $userCurrency, CategoryType::Savings);
|
||||
$investments = $this->sumOutflowTransactions($transactions, $userCurrency, CategoryType::Investment);
|
||||
|
||||
$net = $income - $expense;
|
||||
$savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0;
|
||||
|
||||
return [
|
||||
...CashflowSummaryService::summarize($income, $expense),
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'net' => $net,
|
||||
'savings_rate' => $savingsRate,
|
||||
'savings' => $savings,
|
||||
'investments' => $investments,
|
||||
];
|
||||
|
|
@ -219,21 +220,22 @@ class CashflowAnalyticsController extends Controller
|
|||
|
||||
private function sumTransactions(Collection $transactions, string $userCurrency, CategoryType $type): int
|
||||
{
|
||||
$onSide = match ($type) {
|
||||
CategoryType::Income => fn (Transaction $transaction): bool => $transaction->isIncomeSide(),
|
||||
CategoryType::Expense => fn (Transaction $transaction): bool => $transaction->isExpenseSide(),
|
||||
default => throw new InvalidArgumentException("sumTransactions only supports Income and Expense, got {$type->value}."),
|
||||
};
|
||||
|
||||
return $transactions
|
||||
->filter($onSide)
|
||||
->filter(function (Transaction $transaction) use ($type): bool {
|
||||
if ($this->categoryType($transaction) === $type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $transaction->category_id === null
|
||||
&& $this->matchesSign($transaction->amount, $type === CategoryType::Income ? '>' : '<');
|
||||
})
|
||||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency));
|
||||
}
|
||||
|
||||
private function sumOutflowTransactions(Collection $transactions, string $userCurrency, CategoryType $type): int
|
||||
{
|
||||
return abs($transactions
|
||||
->filter(fn (Transaction $transaction): bool => $transaction->categoryType() === $type
|
||||
->filter(fn (Transaction $transaction): bool => $this->categoryType($transaction) === $type
|
||||
&& $transaction->amount < 0)
|
||||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)));
|
||||
}
|
||||
|
|
@ -252,7 +254,7 @@ class CashflowAnalyticsController extends Controller
|
|||
|
||||
$regularCategories = $transactions
|
||||
->filter(function (Transaction $transaction) use ($type): bool {
|
||||
$categoryType = $transaction->categoryType();
|
||||
$categoryType = $this->categoryType($transaction);
|
||||
|
||||
return $transaction->category_id !== null
|
||||
&& ($categoryType === $type
|
||||
|
|
@ -280,7 +282,7 @@ class CashflowAnalyticsController extends Controller
|
|||
$transferCategories = $transactions
|
||||
->filter(function (Transaction $transaction) use ($isIncome): bool {
|
||||
return $transaction->category_id !== null
|
||||
&& $transaction->categoryType() === CategoryType::Transfer
|
||||
&& $this->categoryType($transaction) === CategoryType::Transfer
|
||||
&& $this->categoryCashflowDirection($transaction) === ($isIncome
|
||||
? CategoryCashflowDirection::Inflow
|
||||
: CategoryCashflowDirection::Outflow);
|
||||
|
|
@ -357,7 +359,7 @@ class CashflowAnalyticsController extends Controller
|
|||
|
||||
foreach ($categorized as $categoryTransactions) {
|
||||
$firstTransaction = $categoryTransactions->first();
|
||||
$type = $firstTransaction->categoryType();
|
||||
$type = $this->categoryType($firstTransaction);
|
||||
|
||||
if (! in_array($type, [CategoryType::Income, CategoryType::Expense], true)) {
|
||||
continue;
|
||||
|
|
@ -404,7 +406,7 @@ class CashflowAnalyticsController extends Controller
|
|||
$this->preloadExchangeRates($transactions, $userCurrency);
|
||||
|
||||
$categorized = $transactions
|
||||
->filter(fn (Transaction $transaction): bool => $transaction->categoryType() === $type)
|
||||
->filter(fn (Transaction $transaction): bool => $this->categoryType($transaction) === $type)
|
||||
->groupBy('category_id')
|
||||
->map(function (Collection $transactions) use ($userCurrency): array {
|
||||
$totalAmount = $transactions->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency));
|
||||
|
|
@ -459,6 +461,42 @@ class CashflowAnalyticsController extends Controller
|
|||
});
|
||||
}
|
||||
|
||||
private function convertTransactionAmount(Transaction $transaction, string $userCurrency): int
|
||||
{
|
||||
return $this->exchangeRateService->convert(
|
||||
$transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency,
|
||||
$userCurrency,
|
||||
$transaction->amount,
|
||||
$transaction->transaction_date->toDateString(),
|
||||
);
|
||||
}
|
||||
|
||||
private function preloadExchangeRates(Collection $transactions, string $userCurrency): void
|
||||
{
|
||||
$dates = $transactions
|
||||
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency, $userCurrency) !== 0)
|
||||
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($dates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->exchangeRateService->preloadRates($userCurrency, $dates);
|
||||
}
|
||||
|
||||
private function categoryType(Transaction $transaction): ?CategoryType
|
||||
{
|
||||
$type = $transaction->category?->getAttribute('type');
|
||||
|
||||
if ($type instanceof CategoryType) {
|
||||
return $type;
|
||||
}
|
||||
|
||||
return is_string($type) ? CategoryType::tryFrom($type) : null;
|
||||
}
|
||||
|
||||
private function categoryCashflowDirection(Transaction $transaction): ?CategoryCashflowDirection
|
||||
{
|
||||
$direction = $transaction->category?->getAttribute('cashflow_direction');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Enums\CategoryType;
|
||||
use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
|
|
@ -15,8 +14,6 @@ use Illuminate\Support\Collection;
|
|||
|
||||
class CategoryMonthlyBreakdownController extends Controller
|
||||
{
|
||||
use ConvertsTransactionCurrency;
|
||||
|
||||
/**
|
||||
* The rolling window shown on the chart, in months (including the current).
|
||||
*/
|
||||
|
|
@ -279,4 +276,32 @@ class CategoryMonthlyBreakdownController extends Controller
|
|||
|
||||
return $months;
|
||||
}
|
||||
|
||||
private function convertTransactionAmount(Transaction $transaction, string $currency): int
|
||||
{
|
||||
return $this->exchangeRateService->convert(
|
||||
$transaction->currency_code ?: $transaction->account?->currency_code ?: $currency,
|
||||
$currency,
|
||||
$transaction->amount,
|
||||
$transaction->transaction_date->toDateString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Transaction> $transactions
|
||||
*/
|
||||
private function preloadExchangeRates(Collection $transactions, string $currency): void
|
||||
{
|
||||
$dates = $transactions
|
||||
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0)
|
||||
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($dates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->exchangeRateService->preloadRates($currency, $dates);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Models\Transaction;
|
||||
use App\Services\ExchangeRateService;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Shared currency conversion for the analytics controllers. Each consumer
|
||||
* injects an {@see ExchangeRateService} as `$exchangeRateService`, then reads
|
||||
* transaction amounts in the user's currency through these helpers.
|
||||
*/
|
||||
trait ConvertsTransactionCurrency
|
||||
{
|
||||
protected function convertTransactionAmount(Transaction $transaction, string $currency): int
|
||||
{
|
||||
return $this->exchangeRateService->convert(
|
||||
$transaction->currency_code ?: $transaction->account?->currency_code ?: $currency,
|
||||
$currency,
|
||||
$transaction->amount,
|
||||
$transaction->transaction_date->toDateString(),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Transaction> $transactions
|
||||
*/
|
||||
protected function preloadExchangeRates(Collection $transactions, string $currency): void
|
||||
{
|
||||
$dates = $transactions
|
||||
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0)
|
||||
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($dates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->exchangeRateService->preloadRates($currency, $dates);
|
||||
}
|
||||
}
|
||||
|
|
@ -6,16 +6,19 @@ use App\Enums\AccountType;
|
|||
use App\Enums\CategoryType;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\Category;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\AccountMetricsService;
|
||||
use App\Services\BalanceLookup;
|
||||
use App\Services\CategorySpendingService;
|
||||
use App\Services\CategoryTree;
|
||||
use App\Services\ExchangeRateService;
|
||||
use App\Services\LoanAmortizationService;
|
||||
use App\Services\PeriodComparator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class DashboardAnalyticsController extends Controller
|
||||
{
|
||||
|
|
@ -23,7 +26,7 @@ class DashboardAnalyticsController extends Controller
|
|||
private ExchangeRateService $exchangeRateService,
|
||||
private AccountMetricsService $accountMetricsService,
|
||||
private LoanAmortizationService $loanAmortizationService,
|
||||
private CategorySpendingService $categorySpendingService,
|
||||
private CategoryTree $tree,
|
||||
) {}
|
||||
|
||||
public function netWorth(Request $request)
|
||||
|
|
@ -38,21 +41,9 @@ class DashboardAnalyticsController extends Controller
|
|||
|
||||
$userCurrency = $request->user()->currency_code;
|
||||
|
||||
$accounts = Account::query()
|
||||
->where('user_id', $request->user()->id)
|
||||
->get();
|
||||
|
||||
// Load every account's balance history for the compared range in a
|
||||
// fixed number of queries instead of one balance lookup per account.
|
||||
$lookup = BalanceLookup::forAccounts(
|
||||
$accounts->pluck('id'),
|
||||
$previousPeriod->to,
|
||||
$period->to,
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'current' => $this->calculateNetWorthAt($accounts, $lookup, $period->to, $userCurrency),
|
||||
'previous' => $this->calculateNetWorthAt($accounts, $lookup, $previousPeriod->to, $userCurrency),
|
||||
'current' => $this->calculateNetWorthAt($period->to, $userCurrency),
|
||||
'previous' => $this->calculateNetWorthAt($previousPeriod->to, $userCurrency),
|
||||
'currency_code' => $userCurrency,
|
||||
]);
|
||||
}
|
||||
|
|
@ -448,8 +439,8 @@ class DashboardAnalyticsController extends Controller
|
|||
$previousPeriod = $period->previous();
|
||||
$drillParentId = $validated['parent'] ?? null;
|
||||
|
||||
$currentSpending = $this->categorySpendingService->forPeriod($request->user()->id, $period->from, $period->to, $drillParentId);
|
||||
$previousSpending = $this->categorySpendingService->forPeriod($request->user()->id, $previousPeriod->from, $previousPeriod->to, $drillParentId);
|
||||
$currentSpending = $this->getCategorySpending($request->user()->id, $period->from, $period->to, $drillParentId);
|
||||
$previousSpending = $this->getCategorySpending($request->user()->id, $previousPeriod->from, $previousPeriod->to, $drillParentId);
|
||||
|
||||
$totalAmount = $currentSpending->sum('amount');
|
||||
|
||||
|
|
@ -475,18 +466,50 @@ class DashboardAnalyticsController extends Controller
|
|||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Account> $accounts
|
||||
* Expense spending rolled up the category tree.
|
||||
*
|
||||
* Without a drill target, child amounts fold into their top-level ancestor
|
||||
* so only parents are listed. With one, the parent's children become the
|
||||
* rows (plus a direct node for transactions sitting on the parent itself).
|
||||
*/
|
||||
private function calculateNetWorthAt(Collection $accounts, BalanceLookup $lookup, Carbon $date, string $userCurrency): int
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to, ?string $drillParentId = null): Collection
|
||||
{
|
||||
$perCategory = Transaction::query()
|
||||
->where('transactions.user_id', $userId)
|
||||
->whereBetween('transactions.transaction_date', [$from, $to])
|
||||
->join('categories', function ($join) {
|
||||
$join->on('transactions.category_id', '=', 'categories.id')
|
||||
->where('categories.type', '=', CategoryType::Expense)
|
||||
->whereNull('categories.deleted_at');
|
||||
})
|
||||
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
|
||||
->groupBy('transactions.category_id')
|
||||
->get()
|
||||
->map(fn ($item): array => [
|
||||
'category_id' => $item->category_id,
|
||||
'category' => null,
|
||||
'amount' => (int) -$item->total_amount,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return collect($this->tree->rollUp($perCategory, $userId, $drillParentId))
|
||||
->filter(fn (array $item): bool => $item['amount'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function calculateNetWorthAt(Carbon $date, string $userCurrency): int
|
||||
{
|
||||
$accounts = Account::where('user_id', request()->user()->id)->get();
|
||||
|
||||
$total = 0;
|
||||
|
||||
foreach ($accounts as $account) {
|
||||
if (! $account->type->countsInNetWorth()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$balance = $lookup->getBalanceAt($account->id, $date);
|
||||
$balance = AccountBalance::query()
|
||||
->where('account_id', $account->id)
|
||||
->where('balance_date', '<=', $date->toDateString())
|
||||
->orderBy('balance_date', 'desc')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
$convertedBalance = $this->exchangeRateService->convert(
|
||||
$account->currency_code,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency;
|
||||
use App\Features\TransactionAnalysis;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\IndexTransactionRequest;
|
||||
use App\Models\Label;
|
||||
|
|
@ -12,11 +12,10 @@ use App\Services\ExchangeRateService;
|
|||
use Carbon\Carbon;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Collection;
|
||||
use Laravel\Pennant\Feature;
|
||||
|
||||
class TransactionAnalysisController extends Controller
|
||||
{
|
||||
use ConvertsTransactionCurrency;
|
||||
|
||||
/**
|
||||
* A daily breakdown is used while the filtered set spans this many days or
|
||||
* fewer; beyond that the chart switches to monthly buckets.
|
||||
|
|
@ -38,6 +37,8 @@ class TransactionAnalysisController extends Controller
|
|||
{
|
||||
$user = $request->user();
|
||||
|
||||
abort_unless(Feature::for($user)->active(TransactionAnalysis::class), 403);
|
||||
|
||||
$validated = $request->validated();
|
||||
$currency = $user->currency_code;
|
||||
|
||||
|
|
@ -94,19 +95,15 @@ class TransactionAnalysisController extends Controller
|
|||
$expense = 0;
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if ($transaction->isIncomeSide()) {
|
||||
$income += $this->convertTransactionAmount($transaction, $currency);
|
||||
} elseif ($transaction->isExpenseSide()) {
|
||||
$expense += $this->convertTransactionAmount($transaction, $currency);
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount > 0) {
|
||||
$income += $amount;
|
||||
} else {
|
||||
$expense += abs($amount);
|
||||
}
|
||||
}
|
||||
|
||||
// Refunds net against their side before it is clamped, so a credit in
|
||||
// an expense category lowers spending instead of inflating income —
|
||||
// matching how the cashflow screen reconciles the same transactions.
|
||||
$income = max(0, $income);
|
||||
$expense = max(0, -$expense);
|
||||
|
||||
$days = $this->spanInDays($transactions);
|
||||
|
||||
return [
|
||||
|
|
@ -127,7 +124,7 @@ class TransactionAnalysisController extends Controller
|
|||
private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection
|
||||
{
|
||||
$expenses = $transactions->filter(
|
||||
fn (Transaction $transaction): bool => $transaction->isExpenseSide(),
|
||||
fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0,
|
||||
);
|
||||
|
||||
$grouped = $expenses
|
||||
|
|
@ -135,9 +132,8 @@ class TransactionAnalysisController extends Controller
|
|||
->groupBy('category_id')
|
||||
->map(fn (Collection $group): array => [
|
||||
'category_id' => $group->first()->category_id,
|
||||
'amount' => -$group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)),
|
||||
'amount' => abs($group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))),
|
||||
])
|
||||
->filter(fn (array $node): bool => $node['amount'] > 0)
|
||||
->values()
|
||||
->all();
|
||||
|
||||
|
|
@ -156,9 +152,9 @@ class TransactionAnalysisController extends Controller
|
|||
], $node['children']),
|
||||
], $this->tree->spendingBreakdown($grouped, $userId));
|
||||
|
||||
$uncategorized = -$expenses
|
||||
$uncategorized = abs($expenses
|
||||
->filter(fn (Transaction $transaction): bool => $transaction->category_id === null)
|
||||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency));
|
||||
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)));
|
||||
|
||||
if ($uncategorized > 0) {
|
||||
$rows[] = [
|
||||
|
|
@ -186,15 +182,15 @@ class TransactionAnalysisController extends Controller
|
|||
$totals = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if (! $transaction->isExpenseSide()) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount >= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = -$this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
foreach ($transaction->labels as $label) {
|
||||
$totals[$label->id] ??= ['id' => $label->id, 'name' => $label->name, 'color' => $label->color, 'amount' => 0];
|
||||
$totals[$label->id]['amount'] += $amount;
|
||||
$totals[$label->id]['amount'] += abs($amount);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -214,12 +210,12 @@ class TransactionAnalysisController extends Controller
|
|||
$totals = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if (! $transaction->isExpenseSide()) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount >= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = -$this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
$name = trim((string) $transaction->creditor_name);
|
||||
|
||||
if ($name === '') {
|
||||
|
|
@ -227,11 +223,10 @@ class TransactionAnalysisController extends Controller
|
|||
}
|
||||
|
||||
$totals[$name] ??= ['name' => $name, 'amount' => 0];
|
||||
$totals[$name]['amount'] += $amount;
|
||||
$totals[$name]['amount'] += abs($amount);
|
||||
}
|
||||
|
||||
return collect($totals)
|
||||
->filter(fn (array $payee): bool => $payee['amount'] > 0)
|
||||
->sortByDesc('amount')
|
||||
->values();
|
||||
}
|
||||
|
|
@ -245,12 +240,12 @@ class TransactionAnalysisController extends Controller
|
|||
$totals = [];
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
if (! $transaction->isExpenseSide()) {
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
if ($amount >= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$amount = -$this->convertTransactionAmount($transaction, $currency);
|
||||
|
||||
$account = $transaction->account;
|
||||
|
||||
$totals[$account->id] ??= [
|
||||
|
|
@ -259,11 +254,10 @@ class TransactionAnalysisController extends Controller
|
|||
'bank' => $account->bank ? ['name' => $account->bank->name, 'logo' => $account->bank->logo] : null,
|
||||
'amount' => 0,
|
||||
];
|
||||
$totals[$account->id]['amount'] += $amount;
|
||||
$totals[$account->id]['amount'] += abs($amount);
|
||||
}
|
||||
|
||||
return collect($totals)
|
||||
->filter(fn (array $account): bool => $account['amount'] > 0)
|
||||
->sortByDesc('amount')
|
||||
->values();
|
||||
}
|
||||
|
|
@ -278,7 +272,7 @@ class TransactionAnalysisController extends Controller
|
|||
private function largestExpenses(Collection $transactions, string $currency): array
|
||||
{
|
||||
return $transactions
|
||||
->filter(fn (Transaction $transaction): bool => $transaction->isExpenseSide() && $transaction->amount < 0)
|
||||
->filter(fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0)
|
||||
->sortBy(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))
|
||||
->take(self::LARGEST_EXPENSES_LIMIT)
|
||||
->map(fn (Transaction $transaction): array => [
|
||||
|
|
@ -329,12 +323,13 @@ class TransactionAnalysisController extends Controller
|
|||
$buckets = [];
|
||||
foreach ($transactions as $transaction) {
|
||||
$key = $transaction->transaction_date->format($keyFormat);
|
||||
$amount = $this->convertTransactionAmount($transaction, $currency);
|
||||
$buckets[$key] ??= ['income' => 0, 'expense' => 0];
|
||||
|
||||
if ($transaction->isIncomeSide()) {
|
||||
$buckets[$key]['income'] += $this->convertTransactionAmount($transaction, $currency);
|
||||
} elseif ($transaction->isExpenseSide()) {
|
||||
$buckets[$key]['expense'] -= $this->convertTransactionAmount($transaction, $currency);
|
||||
if ($amount > 0) {
|
||||
$buckets[$key]['income'] += $amount;
|
||||
} else {
|
||||
$buckets[$key]['expense'] += abs($amount);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -376,4 +371,29 @@ class TransactionAnalysisController extends Controller
|
|||
|
||||
return (int) $dates->min()->diffInDays($dates->max()) + 1;
|
||||
}
|
||||
|
||||
private function convertTransactionAmount(Transaction $transaction, string $currency): int
|
||||
{
|
||||
return $this->exchangeRateService->convert(
|
||||
$transaction->currency_code ?: $transaction->account?->currency_code ?: $currency,
|
||||
$currency,
|
||||
$transaction->amount,
|
||||
$transaction->transaction_date->toDateString(),
|
||||
);
|
||||
}
|
||||
|
||||
private function preloadExchangeRates(Collection $transactions, string $currency): void
|
||||
{
|
||||
$dates = $transactions
|
||||
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0)
|
||||
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($dates->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->exchangeRateService->preloadRates($currency, $dates);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ namespace App\Http\Controllers\Api;
|
|||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Api\BulkUpdateTransactionRequest;
|
||||
use App\Http\Requests\Api\CheckDuplicateTransactionsRequest;
|
||||
use App\Models\Transaction;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
|
@ -22,78 +21,15 @@ class TransactionController extends Controller
|
|||
|
||||
if ($request->query('encrypted') === 'true') {
|
||||
$query->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'));
|
||||
} elseif ($request->query('encrypted') === 'false') {
|
||||
$query->whereNull('description_iv')->whereNull('notes_iv');
|
||||
}
|
||||
|
||||
if ($accountId = $request->query('account_id')) {
|
||||
$query->where('account_id', $accountId)
|
||||
->orderBy('transaction_date', 'desc')
|
||||
->orderBy('id', 'desc');
|
||||
}
|
||||
|
||||
$perPage = min(max((int) $request->query('per_page', 100), 1), 100);
|
||||
|
||||
$transactions = $query->simplePaginate($perPage);
|
||||
$transactions = $query->simplePaginate(100);
|
||||
|
||||
return response()->json($transactions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag which of the given (date, amount, description) tuples already exist
|
||||
* on the account. Replaces the old client-side IndexedDB duplicate check.
|
||||
*
|
||||
* Matching mirrors the previous frontend logic: same day, exact amount (both
|
||||
* in integer cents), and case-insensitive description with collapsed
|
||||
* whitespace. Returns a boolean per input transaction, in order.
|
||||
*/
|
||||
public function checkDuplicates(CheckDuplicateTransactionsRequest $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$account = $request->user()->accounts()->findOrFail($validated['account_id']);
|
||||
$incoming = $validated['transactions'];
|
||||
|
||||
$dates = array_map(fn (array $t): string => substr($t['transaction_date'], 0, 10), $incoming);
|
||||
|
||||
$existing = $account->transactions()
|
||||
->whereBetween('transaction_date', [min($dates), max($dates)])
|
||||
->get(['transaction_date', 'amount', 'description']);
|
||||
|
||||
$seen = [];
|
||||
foreach ($existing as $transaction) {
|
||||
$seen[$this->duplicateKey(
|
||||
$transaction->transaction_date->format('Y-m-d'),
|
||||
(int) $transaction->amount,
|
||||
$transaction->description,
|
||||
)] = true;
|
||||
}
|
||||
|
||||
$duplicates = array_map(
|
||||
fn (array $t): bool => isset($seen[$this->duplicateKey(
|
||||
substr($t['transaction_date'], 0, 10),
|
||||
(int) $t['amount'],
|
||||
$t['description'],
|
||||
)]),
|
||||
$incoming,
|
||||
);
|
||||
|
||||
return response()->json(['duplicates' => $duplicates]);
|
||||
}
|
||||
|
||||
private function duplicateKey(string $date, int $amount, string $description): string
|
||||
{
|
||||
// Collapse every Unicode whitespace run (matching JS \s, which includes
|
||||
// the non-breaking spaces common in bank statements) to a single space,
|
||||
// then trim. PHP's default \s is ASCII-only, so without this an existing
|
||||
// "Coffee Shop" and an imported "Coffee Shop" would not be seen as
|
||||
// the same row, unlike the old client-side check.
|
||||
$normalized = trim((string) preg_replace(
|
||||
'/[\s\x{00A0}\x{1680}\x{2000}-\x{200A}\x{2028}\x{2029}\x{202F}\x{205F}\x{3000}\x{FEFF}]+/u',
|
||||
' ',
|
||||
mb_strtolower($description),
|
||||
));
|
||||
|
||||
return $date.'|'.$amount.'|'.$normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update transactions (used for decryption migration).
|
||||
*/
|
||||
|
|
@ -112,14 +48,11 @@ class TransactionController extends Controller
|
|||
abort(403, 'Some transactions do not belong to the authenticated user.');
|
||||
}
|
||||
|
||||
$userId = $request->user()->id;
|
||||
|
||||
foreach ($validated['transactions'] as $data) {
|
||||
$updateData = collect($data)->except('id')->toArray();
|
||||
|
||||
Transaction::query()
|
||||
->where('id', $data['id'])
|
||||
->where('user_id', $userId)
|
||||
->toBase()
|
||||
->update($updateData);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,11 +6,11 @@ use App\Enums\CategoryType;
|
|||
use App\Models\Account;
|
||||
use App\Models\Transaction;
|
||||
use App\Services\AccountMetricsService;
|
||||
use App\Services\CashflowSummaryService;
|
||||
use App\Services\CategorySpendingService;
|
||||
use App\Services\CategoryTree;
|
||||
use App\Services\PeriodComparator;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
|
@ -19,7 +19,7 @@ class DashboardController extends Controller
|
|||
{
|
||||
public function __construct(
|
||||
private AccountMetricsService $accountMetricsService,
|
||||
private CategorySpendingService $categorySpendingService,
|
||||
private CategoryTree $tree,
|
||||
) {}
|
||||
|
||||
public function __invoke(Request $request): Response
|
||||
|
|
@ -42,8 +42,6 @@ class DashboardController extends Controller
|
|||
$accounts = Account::query()
|
||||
->where('user_id', $user->id)
|
||||
->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id'])
|
||||
->orderBy('position')
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
||||
return $this->accountMetricsService->getNetWorthEvolution($user->currency_code, $accounts, $start, $end);
|
||||
|
|
@ -59,8 +57,8 @@ class DashboardController extends Controller
|
|||
$period = new PeriodComparator($from, $to);
|
||||
$previousPeriod = $period->previous();
|
||||
|
||||
$currentSpending = $this->categorySpendingService->forPeriod($user->id, $period->from, $period->to);
|
||||
$previousSpending = $this->categorySpendingService->forPeriod($user->id, $previousPeriod->from, $previousPeriod->to);
|
||||
$currentSpending = $this->getCategorySpending($user->id, $period->from, $period->to);
|
||||
$previousSpending = $this->getCategorySpending($user->id, $previousPeriod->from, $previousPeriod->to);
|
||||
|
||||
$totalAmount = $currentSpending->sum('amount');
|
||||
|
||||
|
|
@ -100,12 +98,50 @@ class DashboardController extends Controller
|
|||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Spending per top-level category: child category amounts roll up into
|
||||
* their root ancestor so the dashboard only lists parents.
|
||||
*/
|
||||
private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection
|
||||
{
|
||||
$perCategory = Transaction::query()
|
||||
->where('transactions.user_id', $userId)
|
||||
->whereBetween('transactions.transaction_date', [$from, $to])
|
||||
->join('categories', function ($join) {
|
||||
$join->on('transactions.category_id', '=', 'categories.id')
|
||||
->where('categories.type', '=', CategoryType::Expense)
|
||||
->whereNull('categories.deleted_at');
|
||||
})
|
||||
->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount'))
|
||||
->groupBy('transactions.category_id')
|
||||
->get()
|
||||
->map(fn ($item): array => [
|
||||
'category_id' => $item->category_id,
|
||||
'category' => null,
|
||||
'amount' => (int) -$item->total_amount,
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return collect($this->tree->rollUp($perCategory, $userId, null))
|
||||
->filter(fn (array $item): bool => $item['amount'] > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array
|
||||
{
|
||||
$income = max(0, $this->getTransactionSum($userId, $from, $to, CategoryType::Income));
|
||||
$expense = max(0, -$this->getTransactionSum($userId, $from, $to, CategoryType::Expense));
|
||||
|
||||
return CashflowSummaryService::summarize($income, $expense);
|
||||
$net = $income - $expense;
|
||||
$savingsRate = $income > 0 ? round((($income - $expense) / $income) * 100, 1) : 0;
|
||||
|
||||
return [
|
||||
'income' => $income,
|
||||
'expense' => $expense,
|
||||
'net' => $net,
|
||||
'savings_rate' => $savingsRate,
|
||||
];
|
||||
}
|
||||
|
||||
private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int
|
||||
|
|
|
|||
|
|
@ -2,11 +2,34 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\SetupEncryptionRequest;
|
||||
use App\Models\EncryptedMessage;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EncryptionController extends Controller
|
||||
{
|
||||
public function setup(SetupEncryptionRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$user->update([
|
||||
'encryption_salt' => $request->validated('salt'),
|
||||
]);
|
||||
|
||||
EncryptedMessage::query()->updateOrCreate(
|
||||
['user_id' => $user->id],
|
||||
[
|
||||
'encrypted_content' => $request->validated('encrypted_content'),
|
||||
'iv' => $request->validated('iv'),
|
||||
]
|
||||
);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Encryption setup completed successfully',
|
||||
]);
|
||||
}
|
||||
|
||||
public function getMessage(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
|
|
|||
|
|
@ -1,167 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\IntegrationRequestStatus;
|
||||
use App\Http\Requests\StoreIntegrationRequestRequest;
|
||||
use App\Models\IntegrationRequest;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Collection;
|
||||
use Inertia\Response;
|
||||
|
||||
class IntegrationRequestController extends Controller
|
||||
{
|
||||
private const FREE_MONTHLY_ACTION_LIMIT = 3;
|
||||
|
||||
private const PRO_MONTHLY_ACTION_LIMIT = 9;
|
||||
|
||||
public function index(Request $request, DashboardController $dashboard): Response
|
||||
{
|
||||
// Render the dashboard with the integration-requests drawer opened on top of it.
|
||||
return $dashboard($request)->with('openIntegrationRequests', true);
|
||||
}
|
||||
|
||||
public function data(Request $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
return response()->json([
|
||||
'requests' => $this->list($user),
|
||||
'actionsRemaining' => $this->actionsRemaining($user),
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(StoreIntegrationRequestRequest $request): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Creating a request also auto-votes it for the author, so it costs two actions.
|
||||
if ($this->actionsRemaining($user) < 2) {
|
||||
return $this->limitReachedResponse($user);
|
||||
}
|
||||
|
||||
$integrationRequest = $user->integrationRequests()->create([
|
||||
...$request->only(['name', 'url']),
|
||||
// The admin curates the board, so their proposals skip moderation.
|
||||
'status' => $user->isAdmin() ? IntegrationRequestStatus::Approved : IntegrationRequestStatus::Pending,
|
||||
]);
|
||||
$integrationRequest->votes()->create(['user_id' => $user->id]);
|
||||
|
||||
return $this->payload($user, 201);
|
||||
}
|
||||
|
||||
public function vote(Request $request, IntegrationRequest $integrationRequest): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
$canVote = in_array($integrationRequest->status, [IntegrationRequestStatus::Approved, IntegrationRequestStatus::InProgress], true)
|
||||
|| ($integrationRequest->status === IntegrationRequestStatus::Pending
|
||||
&& $integrationRequest->user_id === $user->id);
|
||||
|
||||
if (! $canVote) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Votes are not toggles: a user may back the same integration as many
|
||||
// times as they have actions left, each vote pushing it up the board.
|
||||
if ($this->actionsRemaining($user) <= 0) {
|
||||
return $this->limitReachedResponse($user);
|
||||
}
|
||||
|
||||
$integrationRequest->votes()->create(['user_id' => $user->id]);
|
||||
|
||||
return $this->payload($user);
|
||||
}
|
||||
|
||||
public function removeVote(Request $request, IntegrationRequest $integrationRequest): JsonResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
// Closed requests (not-doable or done) are frozen: their tally can no longer be touched.
|
||||
if (in_array($integrationRequest->status, [IntegrationRequestStatus::NotDoable, IntegrationRequestStatus::Done], true)) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
// Only votes cast this month can be undone, so the refund maps back to
|
||||
// the current quota while earlier months' tallies stay locked in.
|
||||
$vote = $integrationRequest->votes()
|
||||
->where('user_id', $user->id)
|
||||
->where('created_at', '>=', now()->startOfMonth())
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
$vote?->delete();
|
||||
|
||||
return $this->payload($user);
|
||||
}
|
||||
|
||||
/**
|
||||
* The board state shared by the page, the drawer and every mutation.
|
||||
*
|
||||
* @return Collection<int, IntegrationRequest>
|
||||
*/
|
||||
private function list(User $user): Collection
|
||||
{
|
||||
return IntegrationRequest::query()
|
||||
->where(function ($query) use ($user) {
|
||||
$query->whereIn('status', [IntegrationRequestStatus::Approved, IntegrationRequestStatus::InProgress, IntegrationRequestStatus::NotDoable, IntegrationRequestStatus::Done])
|
||||
->orWhere(function ($inner) use ($user) {
|
||||
$inner->where('status', IntegrationRequestStatus::Pending)
|
||||
->where('user_id', $user->id);
|
||||
});
|
||||
})
|
||||
->withCount('votes')
|
||||
->withExists([
|
||||
'votes as has_voted' => fn ($query) => $query->where('user_id', $user->id),
|
||||
'votes as can_unvote' => fn ($query) => $query->where('user_id', $user->id)
|
||||
->where('created_at', '>=', now()->startOfMonth()),
|
||||
])
|
||||
// Closed requests (not-doable or done) sink to the bottom regardless of their votes.
|
||||
->orderByRaw('CASE WHEN status IN (?, ?) THEN 1 ELSE 0 END', [IntegrationRequestStatus::NotDoable->value, IntegrationRequestStatus::Done->value])
|
||||
->orderByDesc('votes_count')
|
||||
->orderByDesc('created_at')
|
||||
->get();
|
||||
}
|
||||
|
||||
private function monthlyActionLimit(User $user): int
|
||||
{
|
||||
return $user->hasProPlan()
|
||||
? self::PRO_MONTHLY_ACTION_LIMIT
|
||||
: self::FREE_MONTHLY_ACTION_LIMIT;
|
||||
}
|
||||
|
||||
private function actionsRemaining(User $user): int
|
||||
{
|
||||
$limit = $this->monthlyActionLimit($user);
|
||||
|
||||
// The admin has no monthly cap; report a full quota so neither the
|
||||
// backend checks nor the frontend buttons ever gate them.
|
||||
if ($user->isAdmin()) {
|
||||
return $limit;
|
||||
}
|
||||
|
||||
$start = now()->startOfMonth();
|
||||
|
||||
$used = $user->integrationRequests()->where('created_at', '>=', $start)->count()
|
||||
+ $user->integrationRequestVotes()->where('created_at', '>=', $start)->count();
|
||||
|
||||
return max(0, $limit - $used);
|
||||
}
|
||||
|
||||
private function payload(User $user, int $status = 200): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'requests' => $this->list($user),
|
||||
'actionsRemaining' => $this->actionsRemaining($user),
|
||||
], $status);
|
||||
}
|
||||
|
||||
private function limitReachedResponse(User $user): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'message' => __('You have reached your monthly limit of :count integration actions. Try again next month.', ['count' => $this->monthlyActionLimit($user)]),
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\AccountType;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
|
|
@ -76,7 +77,9 @@ class AccountMappingController extends Controller
|
|||
$pendingAccounts = collect($connection->pending_accounts_data)
|
||||
->keyBy('uid');
|
||||
|
||||
$accountType = $connection->provider->defaultAccountType();
|
||||
$accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda() || $connection->isCoinbase())
|
||||
? AccountType::Investment
|
||||
: AccountType::Checking;
|
||||
|
||||
foreach ($mappings as $mapping) {
|
||||
$uid = $mapping['bank_account_uid'];
|
||||
|
|
@ -88,7 +91,7 @@ class AccountMappingController extends Controller
|
|||
}
|
||||
|
||||
if ($action === 'create') {
|
||||
$currency = $accountUserCurrencyService->resolveImportedCurrency($accountData['currency'] ?? null, $user);
|
||||
$currency = $accountData['currency'] ?? 'EUR';
|
||||
$name = $accountData['name']
|
||||
?? $accountData['account_id']['iban']
|
||||
?? $connection->aspsp_name.' Account';
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ namespace App\Http\Controllers\OpenBanking;
|
|||
|
||||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
|
|
@ -51,7 +50,7 @@ class AuthorizationController extends Controller
|
|||
);
|
||||
|
||||
$connection = $user->bankingConnections()->create([
|
||||
'provider' => BankingProvider::EnableBanking,
|
||||
'provider' => 'enablebanking',
|
||||
'authorization_id' => $result['authorization_id'],
|
||||
'state_token' => $stateToken,
|
||||
'aspsp_name' => $validated['aspsp_name'],
|
||||
|
|
@ -210,10 +209,6 @@ class AuthorizationController extends Controller
|
|||
} catch (\Throwable $e) {
|
||||
Log::error('EnableBanking session creation failed', ['error' => $e->getMessage()]);
|
||||
|
||||
if ($connection) {
|
||||
$connection->update(['state_token' => null]);
|
||||
}
|
||||
|
||||
return $this->finishRedirect($errorRedirectRoute, $errorRedirectParams, 'error', 'Failed to connect to your bank. Please try again.');
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,47 +2,88 @@
|
|||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
use App\Http\Requests\OpenBanking\ConnectBinanceRequest;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Bank;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use App\Services\Banking\BinanceClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BinanceController extends CryptoPortfolioConnectController
|
||||
class BinanceController extends Controller
|
||||
{
|
||||
use CreatesAccountsFromPending;
|
||||
use HandlesSubscriptionGate;
|
||||
|
||||
/**
|
||||
* Validate Binance API credentials and create a connection.
|
||||
*/
|
||||
public function store(ConnectBinanceRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
return $this->connect($request->validated(), $accountUserCurrencyService);
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$user = auth()->user();
|
||||
|
||||
protected function provider(): BankingProvider
|
||||
{
|
||||
return BankingProvider::Binance;
|
||||
}
|
||||
if ($this->shouldBlockOpenBankingAccess($user)) {
|
||||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
protected function providerName(): string
|
||||
{
|
||||
return 'Binance';
|
||||
}
|
||||
|
||||
protected function bankLogo(): ?string
|
||||
{
|
||||
return 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png';
|
||||
}
|
||||
|
||||
protected function fetchProviderData(array $validated): mixed
|
||||
{
|
||||
$client = new BinanceClient($validated['api_key'], $validated['api_secret']);
|
||||
$client->getAccount();
|
||||
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$client->getAccount();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Binance credential validation failed', ['error' => $e->getMessage()]);
|
||||
|
||||
protected function credentialErrorMessage(\Throwable $e): string
|
||||
{
|
||||
return 'Invalid API credentials or failed to connect to Binance.';
|
||||
return response()->json([
|
||||
'message' => 'Invalid API credentials or failed to connect to Binance.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$bank = Bank::firstOrCreate(
|
||||
['name' => 'Binance', 'user_id' => null],
|
||||
['name' => 'Binance', 'logo' => 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png'],
|
||||
);
|
||||
|
||||
$connection = $user->bankingConnections()->create([
|
||||
'provider' => 'binance',
|
||||
'api_token' => $validated['api_key'],
|
||||
'api_secret' => $validated['api_secret'],
|
||||
'aspsp_name' => 'Binance',
|
||||
'aspsp_country' => $validated['country'],
|
||||
'aspsp_logo' => $bank->logo,
|
||||
'status' => BankingConnectionStatus::Pending,
|
||||
]);
|
||||
|
||||
$pendingAccounts = [
|
||||
[
|
||||
'uid' => 'binance-portfolio',
|
||||
'currency' => $user->currency_code,
|
||||
'name' => 'Crypto Portfolio',
|
||||
],
|
||||
];
|
||||
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::AwaitingMapping,
|
||||
'pending_accounts_data' => $pendingAccounts,
|
||||
]);
|
||||
|
||||
if (! $user->isOnboarded()) {
|
||||
$this->createAccountsFromPending($user, $connection, $accountUserCurrencyService);
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('onboarding', ['step' => 'create-account']),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('open-banking.map-accounts', $connection),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,47 +2,87 @@
|
|||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
use App\Http\Requests\OpenBanking\ConnectBitpandaRequest;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Bank;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use App\Services\Banking\BitpandaClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class BitpandaController extends CryptoPortfolioConnectController
|
||||
class BitpandaController extends Controller
|
||||
{
|
||||
use CreatesAccountsFromPending;
|
||||
use HandlesSubscriptionGate;
|
||||
|
||||
/**
|
||||
* Validate Bitpanda API key and create a connection.
|
||||
*/
|
||||
public function store(ConnectBitpandaRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
return $this->connect($request->validated(), $accountUserCurrencyService);
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$user = auth()->user();
|
||||
|
||||
protected function provider(): BankingProvider
|
||||
{
|
||||
return BankingProvider::Bitpanda;
|
||||
}
|
||||
if ($this->shouldBlockOpenBankingAccess($user)) {
|
||||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
protected function providerName(): string
|
||||
{
|
||||
return 'Bitpanda';
|
||||
}
|
||||
|
||||
protected function bankLogo(): ?string
|
||||
{
|
||||
return 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png';
|
||||
}
|
||||
|
||||
protected function fetchProviderData(array $validated): mixed
|
||||
{
|
||||
$client = new BitpandaClient($validated['api_key']);
|
||||
$client->getCryptoWallets();
|
||||
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$client->getCryptoWallets();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Bitpanda credential validation failed', ['error' => $e->getMessage()]);
|
||||
|
||||
protected function credentialErrorMessage(\Throwable $e): string
|
||||
{
|
||||
return 'Invalid API key or failed to connect to Bitpanda.';
|
||||
return response()->json([
|
||||
'message' => 'Invalid API key or failed to connect to Bitpanda.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$bank = Bank::firstOrCreate(
|
||||
['name' => 'Bitpanda', 'user_id' => null],
|
||||
['name' => 'Bitpanda', 'logo' => 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png'],
|
||||
);
|
||||
|
||||
$connection = $user->bankingConnections()->create([
|
||||
'provider' => 'bitpanda',
|
||||
'api_token' => $validated['api_key'],
|
||||
'aspsp_name' => 'Bitpanda',
|
||||
'aspsp_country' => $validated['country'],
|
||||
'aspsp_logo' => $bank->logo,
|
||||
'status' => BankingConnectionStatus::Pending,
|
||||
]);
|
||||
|
||||
$pendingAccounts = [
|
||||
[
|
||||
'uid' => 'bitpanda-portfolio',
|
||||
'currency' => $user->currency_code,
|
||||
'name' => 'Crypto Portfolio',
|
||||
],
|
||||
];
|
||||
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::AwaitingMapping,
|
||||
'pending_accounts_data' => $pendingAccounts,
|
||||
]);
|
||||
|
||||
if (! $user->isOnboarded()) {
|
||||
$this->createAccountsFromPending($user, $connection, $accountUserCurrencyService);
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('onboarding', ['step' => 'create-account']),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('open-banking.map-accounts', $connection),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,47 +2,88 @@
|
|||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
use App\Http\Requests\OpenBanking\ConnectCoinbaseRequest;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Bank;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use App\Services\Banking\CoinbaseClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class CoinbaseController extends CryptoPortfolioConnectController
|
||||
class CoinbaseController extends Controller
|
||||
{
|
||||
use CreatesAccountsFromPending;
|
||||
use HandlesSubscriptionGate;
|
||||
|
||||
/**
|
||||
* Validate Coinbase CDP API credentials and create a connection.
|
||||
*/
|
||||
public function store(ConnectCoinbaseRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
return $this->connect($request->validated(), $accountUserCurrencyService);
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$user = auth()->user();
|
||||
|
||||
protected function provider(): BankingProvider
|
||||
{
|
||||
return BankingProvider::Coinbase;
|
||||
}
|
||||
if ($this->shouldBlockOpenBankingAccess($user)) {
|
||||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
protected function providerName(): string
|
||||
{
|
||||
return 'Coinbase';
|
||||
}
|
||||
|
||||
protected function bankLogo(): ?string
|
||||
{
|
||||
return 'https://whisper.money/storage/banks/logos/coinbase.png';
|
||||
}
|
||||
|
||||
protected function fetchProviderData(array $validated): mixed
|
||||
{
|
||||
$client = new CoinbaseClient($validated['api_key_name'], $validated['private_key']);
|
||||
$client->getAccounts(limit: 1);
|
||||
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
$client->getAccounts(limit: 1);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Coinbase credential validation failed', ['error' => $e->getMessage()]);
|
||||
|
||||
protected function credentialErrorMessage(\Throwable $e): string
|
||||
{
|
||||
return 'Invalid API credentials or failed to connect to Coinbase.';
|
||||
return response()->json([
|
||||
'message' => 'Invalid API credentials or failed to connect to Coinbase.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$bank = Bank::firstOrCreate(
|
||||
['name' => 'Coinbase', 'user_id' => null],
|
||||
['name' => 'Coinbase', 'logo' => 'https://whisper.money/storage/banks/logos/coinbase.png'],
|
||||
);
|
||||
|
||||
$connection = $user->bankingConnections()->create([
|
||||
'provider' => 'coinbase',
|
||||
'api_token' => $validated['api_key_name'],
|
||||
'api_secret' => $validated['private_key'],
|
||||
'aspsp_name' => 'Coinbase',
|
||||
'aspsp_country' => $validated['country'],
|
||||
'aspsp_logo' => $bank->logo,
|
||||
'status' => BankingConnectionStatus::Pending,
|
||||
]);
|
||||
|
||||
$pendingAccounts = [
|
||||
[
|
||||
'uid' => 'coinbase-portfolio',
|
||||
'currency' => $user->currency_code,
|
||||
'name' => 'Crypto Portfolio',
|
||||
],
|
||||
];
|
||||
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::AwaitingMapping,
|
||||
'pending_accounts_data' => $pendingAccounts,
|
||||
]);
|
||||
|
||||
if (! $user->isOnboarded()) {
|
||||
$this->createAccountsFromPending($user, $connection, $accountUserCurrencyService);
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('onboarding', ['step' => 'create-account']),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('open-banking.map-accounts', $connection),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
namespace App\Http\Controllers\OpenBanking\Concerns;
|
||||
|
||||
use App\Enums\AccountType;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\Bank;
|
||||
use App\Models\BankingConnection;
|
||||
|
|
@ -28,7 +29,9 @@ trait CreatesAccountsFromPending
|
|||
$bank->update(['logo' => $connection->aspsp_logo]);
|
||||
}
|
||||
|
||||
$accountType = $connection->provider->defaultAccountType();
|
||||
$accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda() || $connection->isCoinbase())
|
||||
? AccountType::Investment
|
||||
: AccountType::Checking;
|
||||
|
||||
foreach ($connection->pending_accounts_data ?? [] as $accountData) {
|
||||
$uid = $accountData['uid'] ?? null;
|
||||
|
|
@ -37,7 +40,7 @@ trait CreatesAccountsFromPending
|
|||
continue;
|
||||
}
|
||||
|
||||
$currency = $accountUserCurrencyService->resolveImportedCurrency($accountData['currency'] ?? null, $user);
|
||||
$currency = $accountData['currency'] ?? 'EUR';
|
||||
$name = $accountData['name']
|
||||
?? $accountData['account_id']['iban']
|
||||
?? $connection->aspsp_name.' Account';
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\OpenBanking\MapConnectionAccountRequest;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Account;
|
||||
use App\Models\Bank;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ConnectionAccountController extends Controller
|
||||
{
|
||||
public function __construct(private BankingProviderInterface $provider) {}
|
||||
|
||||
public function index(Request $request, BankingConnection $connection): Response
|
||||
{
|
||||
$this->authorizeConnection($connection);
|
||||
|
||||
$user = $request->user();
|
||||
|
||||
return Inertia::render('open-banking/manage-accounts', [
|
||||
'connection' => $connection,
|
||||
'syncedAccounts' => $connection->accounts()->with('bank')->get(),
|
||||
'availableAccounts' => $user->accounts()
|
||||
->whereNull('banking_connection_id')
|
||||
->with('bank')
|
||||
->get(),
|
||||
'discoveredAccounts' => $request->boolean('refresh')
|
||||
? $this->discoverAccounts($connection)
|
||||
: null,
|
||||
]);
|
||||
}
|
||||
|
||||
public function map(MapConnectionAccountRequest $request, BankingConnection $connection, AccountUserCurrencyService $accountUserCurrencyService): RedirectResponse
|
||||
{
|
||||
$validated = $request->validated();
|
||||
$uid = $validated['bank_account_uid'];
|
||||
|
||||
$bank = Bank::firstOrCreate(
|
||||
['name' => $connection->aspsp_name, 'user_id' => null],
|
||||
['name' => $connection->aspsp_name, 'logo' => $connection->aspsp_logo],
|
||||
);
|
||||
|
||||
$currentHolder = $connection->accounts()->where('external_account_id', $uid)->first();
|
||||
|
||||
$iban = $validated['iban'] ?? null;
|
||||
if ($currentHolder) {
|
||||
$iban = $currentHolder->iban;
|
||||
}
|
||||
|
||||
$target = $validated['action'] === 'link'
|
||||
? $connection->user->accounts()->find($validated['existing_account_id'])
|
||||
: null;
|
||||
|
||||
if ($validated['action'] === 'link') {
|
||||
abort_if($target === null, 404);
|
||||
abort_unless($target->type->canSyncBankTransactions(), 422);
|
||||
}
|
||||
|
||||
if ($currentHolder && (! $target || $currentHolder->isNot($target))) {
|
||||
$currentHolder->update([
|
||||
'banking_connection_id' => null,
|
||||
'external_account_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($validated['action'] === 'create') {
|
||||
$account = $connection->user->accounts()->create([
|
||||
'name' => $validated['name'] ?? $iban ?? $connection->aspsp_name.' Account',
|
||||
'name_iv' => null,
|
||||
'encrypted' => false,
|
||||
'bank_id' => $bank->id,
|
||||
'currency_code' => $validated['currency'] ?? 'EUR',
|
||||
'type' => $connection->provider->defaultAccountType()->value,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => $uid,
|
||||
'iban' => $iban,
|
||||
]);
|
||||
} else {
|
||||
$account = $target;
|
||||
$account->update([
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => $uid,
|
||||
'iban' => $iban ?? $account->iban,
|
||||
'bank_id' => $bank->id,
|
||||
'linked_at' => now(),
|
||||
]);
|
||||
}
|
||||
|
||||
$accountUserCurrencyService->syncFromFirstAccount($account);
|
||||
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
||||
return back()->with('success', __('Account synced. Transactions will be updated shortly.'));
|
||||
}
|
||||
|
||||
public function unlink(BankingConnection $connection, Account $account): RedirectResponse
|
||||
{
|
||||
$this->authorizeConnection($connection);
|
||||
|
||||
if ($account->banking_connection_id !== $connection->id) {
|
||||
abort(404);
|
||||
}
|
||||
|
||||
$account->update([
|
||||
'banking_connection_id' => null,
|
||||
'external_account_id' => null,
|
||||
]);
|
||||
|
||||
return back()->with('success', __('Account is no longer syncing. It is now a manual account.'));
|
||||
}
|
||||
|
||||
private function authorizeConnection(BankingConnection $connection): void
|
||||
{
|
||||
if ($connection->user_id !== auth()->id()) {
|
||||
abort(403);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the bank accounts the consent covers that are not yet synced.
|
||||
*
|
||||
* The session only returns account uids, so we enrich the unknown ones with a
|
||||
* details call. Already-synced uids are skipped — they live in syncedAccounts.
|
||||
*
|
||||
* @return array<int, array{uid: string, name: string|null, currency: string|null, iban: string|null}>
|
||||
*/
|
||||
private function discoverAccounts(BankingConnection $connection): array
|
||||
{
|
||||
if (! $connection->isEnableBanking() || ! $connection->isActive() || ! $connection->session_id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
$session = $this->provider->getSession($connection->session_id);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to refresh EnableBanking session accounts', [
|
||||
'connection_id' => $connection->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$knownUids = $connection->accounts()->pluck('external_account_id')->filter()->all();
|
||||
|
||||
$uids = collect($session['accounts_data'] ?? $session['accounts'])
|
||||
->map(fn ($account) => is_array($account) ? ($account['uid'] ?? null) : $account)
|
||||
->filter()
|
||||
->reject(fn (string $uid) => in_array($uid, $knownUids, true))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
return $uids->map(function (string $uid): ?array {
|
||||
try {
|
||||
$details = $this->provider->getAccount($uid);
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'uid' => $uid,
|
||||
'name' => $details['name'] ?? $details['account_id']['iban'] ?? null,
|
||||
'currency' => $details['currency'],
|
||||
'iban' => $details['account_id']['iban'] ?? null,
|
||||
];
|
||||
})->filter()->values()->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,6 @@ namespace App\Http\Controllers\OpenBanking;
|
|||
|
||||
use App\Actions\OpenBanking\DisconnectBankingConnection;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
use App\Http\Requests\OpenBanking\DestroyConnectionRequest;
|
||||
|
|
@ -16,8 +15,6 @@ use App\Services\Banking\BinanceClient;
|
|||
use App\Services\Banking\BitpandaClient;
|
||||
use App\Services\Banking\CoinbaseClient;
|
||||
use App\Services\Banking\IndexaCapitalClient;
|
||||
use App\Services\Banking\InteractiveBrokersClient;
|
||||
use App\Services\Banking\WiseClient;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
|
@ -96,8 +93,16 @@ class ConnectionController extends Controller
|
|||
return back()->withErrors(['credentials' => $validationError]);
|
||||
}
|
||||
|
||||
$updateData = match ($connection->provider) {
|
||||
'indexacapital' => ['api_token' => $validated['api_token']],
|
||||
'binance' => ['api_token' => $validated['api_key'], 'api_secret' => $validated['api_secret']],
|
||||
'bitpanda' => ['api_token' => $validated['api_key']],
|
||||
'coinbase' => ['api_token' => $validated['api_key_name'], 'api_secret' => $validated['private_key']],
|
||||
default => [],
|
||||
};
|
||||
|
||||
$connection->update([
|
||||
...$connection->provider->credentialColumns($validated),
|
||||
...$updateData,
|
||||
'status' => BankingConnectionStatus::Active,
|
||||
'error_message' => null,
|
||||
'consecutive_sync_failures' => 0,
|
||||
|
|
@ -115,12 +120,10 @@ class ConnectionController extends Controller
|
|||
{
|
||||
try {
|
||||
match ($connection->provider) {
|
||||
BankingProvider::IndexaCapital => (new IndexaCapitalClient($validated['api_token']))->getUser(),
|
||||
BankingProvider::Binance => (new BinanceClient($validated['api_key'], $validated['api_secret']))->getAccount(),
|
||||
BankingProvider::Bitpanda => (new BitpandaClient($validated['api_key']))->getCryptoWallets(),
|
||||
BankingProvider::Coinbase => (new CoinbaseClient($validated['api_key_name'], $validated['private_key']))->getAccounts(limit: 1),
|
||||
BankingProvider::InteractiveBrokers => (new InteractiveBrokersClient($validated['token'], $validated['query_id']))->fetchStatement(),
|
||||
BankingProvider::Wise => (new WiseClient($validated['api_token']))->getProfiles(),
|
||||
'indexacapital' => (new IndexaCapitalClient($validated['api_token']))->getUser(),
|
||||
'binance' => (new BinanceClient($validated['api_key'], $validated['api_secret']))->getAccount(),
|
||||
'bitpanda' => (new BitpandaClient($validated['api_key']))->getCryptoWallets(),
|
||||
'coinbase' => (new CoinbaseClient($validated['api_key_name'], $validated['private_key']))->getAccounts(limit: 1),
|
||||
default => throw new \InvalidArgumentException('Unsupported provider for credential update.'),
|
||||
};
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
|
|
@ -128,7 +131,7 @@ class ConnectionController extends Controller
|
|||
} catch (\Throwable $e) {
|
||||
Log::warning('Credential validation failed during update', [
|
||||
'connection_id' => $connection->id,
|
||||
'provider' => $connection->provider->value,
|
||||
'provider' => $connection->provider,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
abstract class CryptoPortfolioConnectController extends OpenBankingConnectController
|
||||
{
|
||||
protected function aspspCountry(array $validated): string
|
||||
{
|
||||
return $validated['country'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Crypto providers expose a single aggregated portfolio, mapped to one
|
||||
* pending account named after the provider (e.g. "binance-portfolio").
|
||||
*/
|
||||
protected function buildPendingAccounts(mixed $providerData, User $user): array
|
||||
{
|
||||
return [
|
||||
[
|
||||
'uid' => $this->provider()->value.'-portfolio',
|
||||
'currency' => $user->currency_code,
|
||||
'name' => 'Crypto Portfolio',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -2,65 +2,94 @@
|
|||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
use App\Http\Requests\OpenBanking\ConnectIndexaCapitalRequest;
|
||||
use App\Models\User;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Bank;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use App\Services\Banking\IndexaCapitalClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class IndexaCapitalController extends OpenBankingConnectController
|
||||
class IndexaCapitalController extends Controller
|
||||
{
|
||||
use CreatesAccountsFromPending;
|
||||
use HandlesSubscriptionGate;
|
||||
|
||||
/**
|
||||
* Validate the Indexa Capital API token and create a connection.
|
||||
*/
|
||||
public function store(ConnectIndexaCapitalRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
return $this->connect($request->validated(), $accountUserCurrencyService);
|
||||
}
|
||||
$validated = $request->validated();
|
||||
$user = auth()->user();
|
||||
|
||||
protected function provider(): BankingProvider
|
||||
{
|
||||
return BankingProvider::IndexaCapital;
|
||||
}
|
||||
if ($this->shouldBlockOpenBankingAccess($user)) {
|
||||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
protected function providerName(): string
|
||||
{
|
||||
return 'Indexa Capital';
|
||||
}
|
||||
|
||||
protected function bankLogo(): ?string
|
||||
{
|
||||
return '/images/banks/logos/indexa-capital.jpg';
|
||||
}
|
||||
|
||||
protected function aspspCountry(array $validated): string
|
||||
{
|
||||
return 'ES';
|
||||
}
|
||||
|
||||
protected function fetchProviderData(array $validated): mixed
|
||||
{
|
||||
$client = new IndexaCapitalClient($validated['api_token']);
|
||||
|
||||
return $client->getUser();
|
||||
}
|
||||
try {
|
||||
$userData = $client->getUser();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Indexa Capital token validation failed', ['error' => $e->getMessage()]);
|
||||
|
||||
protected function credentialErrorMessage(\Throwable $e): string
|
||||
{
|
||||
return 'Invalid API token or failed to connect to Indexa Capital.';
|
||||
return response()->json([
|
||||
'message' => 'Invalid API token or failed to connect to Indexa Capital.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$bank = Bank::firstOrCreate(
|
||||
['name' => 'Indexa Capital', 'user_id' => null],
|
||||
['name' => 'Indexa Capital', 'logo' => '/images/banks/logos/indexa-capital.jpg'],
|
||||
);
|
||||
|
||||
$connection = $user->bankingConnections()->create([
|
||||
'provider' => 'indexacapital',
|
||||
'api_token' => $validated['api_token'],
|
||||
'aspsp_name' => 'Indexa Capital',
|
||||
'aspsp_country' => 'ES',
|
||||
'aspsp_logo' => $bank->logo,
|
||||
'status' => BankingConnectionStatus::Pending,
|
||||
]);
|
||||
|
||||
$pendingAccounts = $this->buildPendingAccounts($userData);
|
||||
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::AwaitingMapping,
|
||||
'pending_accounts_data' => $pendingAccounts,
|
||||
]);
|
||||
|
||||
if (! $user->isOnboarded()) {
|
||||
$this->createAccountsFromPending($user, $connection, $accountUserCurrencyService);
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('onboarding', ['step' => 'create-account']),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('open-banking.map-accounts', $connection),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the pending accounts data in the same format as EnableBanking.
|
||||
*
|
||||
* @param array{accounts?: array<int, array{account_number?: string, type?: string}>} $providerData
|
||||
* @return array<int, array{uid: string, currency: string, name: string}>
|
||||
*/
|
||||
protected function buildPendingAccounts(mixed $providerData, User $user): array
|
||||
private function buildPendingAccounts(array $userData): array
|
||||
{
|
||||
$accounts = [];
|
||||
|
||||
foreach ($providerData['accounts'] ?? [] as $account) {
|
||||
foreach ($userData['accounts'] ?? [] as $account) {
|
||||
$accountNumber = $account['account_number'] ?? null;
|
||||
|
||||
if (! $accountNumber) {
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Exceptions\Banking\TransientBankingProviderException;
|
||||
use App\Http\Requests\OpenBanking\ConnectInteractiveBrokersRequest;
|
||||
use App\Models\User;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use App\Services\Banking\InteractiveBrokersClient;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
class InteractiveBrokersController extends OpenBankingConnectController
|
||||
{
|
||||
/**
|
||||
* Validate the Flex credentials and create a connection.
|
||||
*/
|
||||
public function store(ConnectInteractiveBrokersRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
return $this->connect($request->validated(), $accountUserCurrencyService);
|
||||
}
|
||||
|
||||
protected function provider(): BankingProvider
|
||||
{
|
||||
return BankingProvider::InteractiveBrokers;
|
||||
}
|
||||
|
||||
protected function providerName(): string
|
||||
{
|
||||
return 'Interactive Brokers';
|
||||
}
|
||||
|
||||
protected function bankLogo(): ?string
|
||||
{
|
||||
return '/images/banks/logos/interactive-brokers.png';
|
||||
}
|
||||
|
||||
protected function aspspCountry(array $validated): string
|
||||
{
|
||||
return 'US';
|
||||
}
|
||||
|
||||
protected function fetchProviderData(array $validated): mixed
|
||||
{
|
||||
$client = new InteractiveBrokersClient($validated['token'], $validated['query_id']);
|
||||
|
||||
return $client->fetchStatement();
|
||||
}
|
||||
|
||||
/**
|
||||
* Turn a Flex failure into a message the user can act on: bad credentials,
|
||||
* a busy/rate-limited service, or a statement that is still generating.
|
||||
*/
|
||||
protected function credentialErrorMessage(\Throwable $e): string
|
||||
{
|
||||
if ($e instanceof RequestException && in_array($e->response->status(), [401, 403], true)) {
|
||||
return 'Invalid Flex token or query ID, or failed to connect to Interactive Brokers.';
|
||||
}
|
||||
|
||||
if ($e instanceof RequestException && $e->response->status() === 429) {
|
||||
return 'Interactive Brokers is rate limiting requests. Please wait a few minutes and try again.';
|
||||
}
|
||||
|
||||
if ($e instanceof TransientBankingProviderException) {
|
||||
return 'Interactive Brokers is still preparing your statement. Please try again in a moment.';
|
||||
}
|
||||
|
||||
return 'Invalid Flex token or query ID, or failed to connect to Interactive Brokers.';
|
||||
}
|
||||
|
||||
protected function emptyProviderDataMessage(mixed $providerData): ?string
|
||||
{
|
||||
if (empty($providerData)) {
|
||||
return 'No accounts found in the Flex statement. Check that your Flex Query includes the NAV section.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pending accounts from the parsed Flex statement.
|
||||
*
|
||||
* @param array<string, array{account_id: string, currency: string, navByDate: array<string, float>, investedAmount: float|null}> $providerData
|
||||
*/
|
||||
protected function buildPendingAccounts(mixed $providerData, User $user): array
|
||||
{
|
||||
$pending = [];
|
||||
|
||||
foreach ($providerData as $account) {
|
||||
$pending[] = [
|
||||
'uid' => $account['account_id'],
|
||||
'currency' => $account['currency'] !== '' ? $account['currency'] : 'USD',
|
||||
'name' => "Interactive Brokers ({$account['account_id']})",
|
||||
];
|
||||
}
|
||||
|
||||
return $pending;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Bank;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\User;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
abstract class OpenBankingConnectController extends Controller
|
||||
{
|
||||
use CreatesAccountsFromPending;
|
||||
use HandlesSubscriptionGate;
|
||||
|
||||
abstract protected function provider(): BankingProvider;
|
||||
|
||||
/**
|
||||
* Display name used for both the Bank record and the connection's aspsp_name.
|
||||
*/
|
||||
abstract protected function providerName(): string;
|
||||
|
||||
abstract protected function bankLogo(): ?string;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
abstract protected function aspspCountry(array $validated): string;
|
||||
|
||||
/**
|
||||
* Build the provider client, validate the credentials, and return whatever
|
||||
* data is needed to build pending accounts (or null). Must throw on failure.
|
||||
*
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
abstract protected function fetchProviderData(array $validated): mixed;
|
||||
|
||||
abstract protected function credentialErrorMessage(\Throwable $e): string;
|
||||
|
||||
/**
|
||||
* @return array<int, array{uid: string, currency: string, name: string}>
|
||||
*/
|
||||
abstract protected function buildPendingAccounts(mixed $providerData, User $user): array;
|
||||
|
||||
/**
|
||||
* Optional guard: return a 422 message when the validated provider data is
|
||||
* unusable (e.g. an empty statement). Returning null keeps the flow going.
|
||||
*/
|
||||
protected function emptyProviderDataMessage(mixed $providerData): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared connect flow: gate on subscription, validate the credentials, create
|
||||
* the pending connection, then auto-map (onboarding) or redirect to mapping.
|
||||
*
|
||||
* @param array<string, mixed> $validated
|
||||
*/
|
||||
protected function connect(array $validated, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($this->shouldBlockOpenBankingAccess($user)) {
|
||||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$providerData = $this->fetchProviderData($validated);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('OpenBanking credential validation failed', [
|
||||
'provider' => $this->provider()->value,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => $this->credentialErrorMessage($e),
|
||||
], 422);
|
||||
}
|
||||
|
||||
if (($message = $this->emptyProviderDataMessage($providerData)) !== null) {
|
||||
return response()->json(['message' => $message], 422);
|
||||
}
|
||||
|
||||
$bank = Bank::firstOrCreate(
|
||||
['name' => $this->providerName(), 'user_id' => null],
|
||||
['name' => $this->providerName(), 'logo' => $this->bankLogo()],
|
||||
);
|
||||
|
||||
$connection = $user->bankingConnections()->create([
|
||||
'provider' => $this->provider(),
|
||||
...$this->provider()->credentialColumns($validated),
|
||||
'aspsp_name' => $this->providerName(),
|
||||
'aspsp_country' => $this->aspspCountry($validated),
|
||||
'aspsp_logo' => $bank->logo,
|
||||
'status' => BankingConnectionStatus::Pending,
|
||||
]);
|
||||
|
||||
$connection->update([
|
||||
'status' => BankingConnectionStatus::AwaitingMapping,
|
||||
'pending_accounts_data' => $this->buildPendingAccounts($providerData, $user),
|
||||
]);
|
||||
|
||||
return $this->connectionResponse($user, $connection, $accountUserCurrencyService);
|
||||
}
|
||||
|
||||
private function connectionResponse(User $user, BankingConnection $connection, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
if (! $user->isOnboarded()) {
|
||||
$this->createAccountsFromPending($user, $connection, $accountUserCurrencyService);
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('onboarding', ['step' => 'create-account']),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('open-banking.map-accounts', $connection),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
use App\Http\Requests\OpenBanking\ConnectWiseRequest;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Bank;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use App\Services\Banking\WiseClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class WiseController extends Controller
|
||||
{
|
||||
use CreatesAccountsFromPending;
|
||||
use HandlesSubscriptionGate;
|
||||
|
||||
/**
|
||||
* Validate a Wise API token and create a connection.
|
||||
*
|
||||
* Every currency wallet across all of the token's profiles (personal and
|
||||
* business) becomes a pending account with
|
||||
* external_account_id = "{profileId}:{currency}".
|
||||
*/
|
||||
public function store(ConnectWiseRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($this->shouldBlockOpenBankingAccess($user)) {
|
||||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
$client = new WiseClient($request->api_token);
|
||||
|
||||
try {
|
||||
$profiles = $client->getProfiles();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Wise credential validation failed', ['error' => $e->getMessage()]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Invalid API token or failed to connect to Wise.',
|
||||
], 422);
|
||||
}
|
||||
|
||||
$pendingAccounts = $this->buildPendingAccounts($client, $profiles);
|
||||
|
||||
if ($pendingAccounts === []) {
|
||||
return response()->json(['message' => 'No Wise multi-currency account found for this token.'], 422);
|
||||
}
|
||||
|
||||
$bank = Bank::firstOrCreate(
|
||||
['name' => 'Wise', 'user_id' => null],
|
||||
['name' => 'Wise', 'logo' => null],
|
||||
);
|
||||
|
||||
$connection = $user->bankingConnections()->create([
|
||||
'provider' => BankingProvider::Wise,
|
||||
...BankingProvider::Wise->credentialColumns($request->validated()),
|
||||
'aspsp_name' => 'Wise',
|
||||
'aspsp_country' => 'GB',
|
||||
'aspsp_logo' => $bank->logo,
|
||||
'status' => BankingConnectionStatus::AwaitingMapping,
|
||||
'pending_accounts_data' => $pendingAccounts,
|
||||
]);
|
||||
|
||||
if (! $user->isOnboarded()) {
|
||||
$this->createAccountsFromPending($user, $connection, $accountUserCurrencyService);
|
||||
SyncBankingConnectionJob::dispatch($connection);
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('onboarding', ['step' => 'create-account']),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'redirect_url' => route('open-banking.map-accounts', $connection),
|
||||
'connection_id' => $connection->id,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pending account entries across every profile on the token.
|
||||
*
|
||||
* Each currency wallet becomes one pending account with
|
||||
* external_account_id = "{profileId}:{currency}", the format the Wise
|
||||
* sync services use to fetch activities and balances per profile.
|
||||
*
|
||||
* @param array<int, array{id?: int, type?: string, details?: array<string, mixed>}> $profiles
|
||||
* @return array<int, array{uid: string, currency: string, name: string}>
|
||||
*/
|
||||
private function buildPendingAccounts(WiseClient $client, array $profiles): array
|
||||
{
|
||||
$pendingAccounts = [];
|
||||
|
||||
foreach ($profiles as $profile) {
|
||||
$profileId = $profile['id'] ?? null;
|
||||
|
||||
if ($profileId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$borderlessAccount = $client->getBorderlessAccount($profileId);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to load Wise borderless account', [
|
||||
'profile_id' => $profileId,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$label = ucfirst((string) ($profile['type'] ?? 'account'));
|
||||
|
||||
foreach ($borderlessAccount['balances'] ?? [] as $balance) {
|
||||
$currency = $balance['currency'] ?? null;
|
||||
|
||||
if ($currency === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$pendingAccounts[] = [
|
||||
'uid' => $profileId.':'.$currency,
|
||||
'currency' => $currency,
|
||||
'name' => 'Wise '.$label.' '.$currency,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $pendingAccounts;
|
||||
}
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ class ReEvaluateTransactionRulesController extends Controller
|
|||
|
||||
// Set initial pending state so the first poll returns something meaningful
|
||||
Cache::put(
|
||||
ReEvaluateTransactionRulesJob::cacheKeyForJobId($user->id, $jobId),
|
||||
ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId),
|
||||
['status' => 'pending', 'processed' => 0, 'total' => 0, 'updated' => 0],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
|
@ -64,7 +64,7 @@ class ReEvaluateTransactionRulesController extends Controller
|
|||
*/
|
||||
public function status(Request $request, string $jobId): JsonResponse
|
||||
{
|
||||
$cacheKey = ReEvaluateTransactionRulesJob::cacheKeyForJobId($request->user()->id, $jobId);
|
||||
$cacheKey = ReEvaluateTransactionRulesJob::cacheKeyForJobId($jobId);
|
||||
$progress = Cache::get($cacheKey);
|
||||
|
||||
if ($progress === null) {
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ class AutomationRuleApplicationController extends Controller
|
|||
->with(['account.bank', 'category', 'labels'])
|
||||
->get();
|
||||
|
||||
$changed = $service->applyRuleActionsToTransactions($transactions, $automationRule, $onlyUncategorized);
|
||||
$changed = $service->applyRuleActionsToTransactions($transactions, $automationRule);
|
||||
|
||||
$applied = $transactions->count();
|
||||
|
||||
|
|
@ -121,12 +121,12 @@ class AutomationRuleApplicationController extends Controller
|
|||
$jobId = (string) Str::uuid();
|
||||
|
||||
Cache::put(
|
||||
ApplySingleAutomationRuleJob::cacheKeyForJobId($automationRule->user_id, $jobId),
|
||||
ApplySingleAutomationRuleJob::cacheKeyForJobId($jobId),
|
||||
['status' => 'pending', 'processed' => 0, 'total' => $total, 'applied' => 0, 'updated' => 0],
|
||||
now()->addHour(),
|
||||
);
|
||||
|
||||
ApplySingleAutomationRuleJob::dispatch($automationRule, $jobId, $matchingIds, $onlyUncategorized);
|
||||
ApplySingleAutomationRuleJob::dispatch($automationRule, $jobId, $matchingIds);
|
||||
|
||||
$this->forgetMatchesCache($automationRule, $onlyUncategorized);
|
||||
|
||||
|
|
@ -141,7 +141,7 @@ class AutomationRuleApplicationController extends Controller
|
|||
*/
|
||||
public function status(Request $request, string $jobId): JsonResponse
|
||||
{
|
||||
$progress = Cache::get(ApplySingleAutomationRuleJob::cacheKeyForJobId($request->user()->id, $jobId));
|
||||
$progress = Cache::get(ApplySingleAutomationRuleJob::cacheKeyForJobId($jobId));
|
||||
|
||||
if ($progress === null) {
|
||||
return response()->json(['message' => 'Job not found.'], 404);
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Features\Mcp;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\StoreMcpTokenRequest;
|
||||
use Closure;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Routing\Controllers\HasMiddleware;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Pennant\Feature;
|
||||
use Laravel\Sanctum\PersonalAccessToken;
|
||||
|
||||
class McpTokenController extends Controller implements HasMiddleware
|
||||
{
|
||||
/**
|
||||
* Hide the whole MCP settings surface behind the rollout feature flag.
|
||||
*
|
||||
* @return array<int, Closure>
|
||||
*/
|
||||
public static function middleware(): array
|
||||
{
|
||||
return [
|
||||
function (Request $request, Closure $next): mixed {
|
||||
abort_unless(Feature::active(Mcp::class), 404);
|
||||
|
||||
return $next($request);
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the MCP access page: existing tokens, connection details and the
|
||||
* one-time plaintext secret when a token was just created.
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
return Inertia::render('settings/mcp', [
|
||||
'tokens' => $this->tokensFor($request),
|
||||
'serverUrl' => url('/mcp'),
|
||||
'oauthUrl' => url('/mcp/oauth'),
|
||||
'subscribeUrl' => route('subscribe'),
|
||||
'newToken' => $request->session()->get('mcp_token'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new MCP token. The plaintext secret is flashed once; only its
|
||||
* hash is stored, so it can never be shown again. A "read" token can only
|
||||
* analyse data; a "read_write" token additionally carries `mcp:write`,
|
||||
* unlocking the write tools.
|
||||
*/
|
||||
public function store(StoreMcpTokenRequest $request): RedirectResponse
|
||||
{
|
||||
$abilities = $request->validated('scope') === 'read_write'
|
||||
? ['mcp:read', 'mcp:write']
|
||||
: ['mcp:read'];
|
||||
|
||||
$token = $request->user()->createToken($request->validated('name'), $abilities);
|
||||
|
||||
return to_route('mcp.index')->with('mcp_token', $token->plainTextToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke (delete) a token the user owns.
|
||||
*/
|
||||
public function destroy(Request $request, PersonalAccessToken $token): RedirectResponse
|
||||
{
|
||||
$this->authorizeOwnership($request, $token);
|
||||
|
||||
$token->delete();
|
||||
|
||||
return to_route('mcp.index');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate a token: revoke it and issue a fresh secret keeping the same name
|
||||
* and scope, so a leaked token can be replaced without reconfiguring intent.
|
||||
*/
|
||||
public function rotate(Request $request, PersonalAccessToken $token): RedirectResponse
|
||||
{
|
||||
$this->authorizeOwnership($request, $token);
|
||||
|
||||
$fresh = $request->user()->createToken($token->name, $token->abilities);
|
||||
$token->delete();
|
||||
|
||||
return to_route('mcp.index')->with('mcp_token', $fresh->plainTextToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the token belongs to the requesting user before mutating it.
|
||||
*/
|
||||
private function authorizeOwnership(Request $request, PersonalAccessToken $token): void
|
||||
{
|
||||
abort_unless(
|
||||
$token->tokenable_id === $request->user()->getKey()
|
||||
&& $token->tokenable_type === $request->user()->getMorphClass(),
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{id: int|string, name: string, scope: string, created_at: ?string, last_used_at: ?string}>
|
||||
*/
|
||||
private function tokensFor(Request $request): array
|
||||
{
|
||||
return $request->user()->tokens()
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn (PersonalAccessToken $token): array => [
|
||||
'id' => $token->id,
|
||||
'name' => $token->name,
|
||||
'scope' => in_array('mcp:write', $token->abilities ?? [], true) ? 'read_write' : 'read',
|
||||
'created_at' => $token->created_at?->toIso8601String(),
|
||||
'last_used_at' => $token->last_used_at?->toIso8601String(),
|
||||
])
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -2,13 +2,9 @@
|
|||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Actions\Subscription\RefundSelfServe;
|
||||
use App\Enums\UpsellSource;
|
||||
use App\Features\SubscriptionExperiment;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\User;
|
||||
use App\Models\UserLead;
|
||||
use App\Services\Discord\DiscordWebhook;
|
||||
use App\Services\Subscriptions\ExperimentOffer;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
|
@ -19,11 +15,6 @@ use Laravel\Cashier\Checkout;
|
|||
|
||||
class SubscriptionController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private ExperimentOffer $experimentOffer,
|
||||
private DiscordWebhook $discord,
|
||||
) {}
|
||||
|
||||
public function index(Request $request): Response|RedirectResponse
|
||||
{
|
||||
/** @var User $user */
|
||||
|
|
@ -34,7 +25,7 @@ class SubscriptionController extends Controller
|
|||
}
|
||||
|
||||
$hasBankConnections = $user->bankingConnections()->exists();
|
||||
$canUseFreePlan = ! $hasBankConnections && ! $user->hasActiveAiConsent();
|
||||
$canUseFreePlan = ! $hasBankConnections;
|
||||
|
||||
// Mark the paywall as seen so the middleware stops redirecting here.
|
||||
if ($canUseFreePlan && ! $user->hasSeenPaywall()) {
|
||||
|
|
@ -47,19 +38,36 @@ class SubscriptionController extends Controller
|
|||
'canManageConnectionsForFreePlan' => $user->isOnboarded()
|
||||
&& $hasBankConnections
|
||||
&& $user->hasCanceledSubscription(),
|
||||
'offer' => $this->experimentOffer->offerFor($user),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{accountsCount: int, transactionsCount: int, categoriesCount: int}
|
||||
* @return array{accountsCount: int, transactionsCount: int, categoriesCount: int, automationRulesCount: int, balancesByCurrency: array<string, int>}
|
||||
*/
|
||||
private function getUserStats(User $user): array
|
||||
{
|
||||
$accounts = $user->accounts()->get();
|
||||
|
||||
$balancesByCurrency = [];
|
||||
foreach ($accounts as $account) {
|
||||
$latestBalance = AccountBalance::query()
|
||||
->where('account_id', $account->id)
|
||||
->orderBy('balance_date', 'desc')
|
||||
->value('balance') ?? 0;
|
||||
|
||||
$currency = $account->currency_code;
|
||||
if (! isset($balancesByCurrency[$currency])) {
|
||||
$balancesByCurrency[$currency] = 0;
|
||||
}
|
||||
$balancesByCurrency[$currency] += $latestBalance;
|
||||
}
|
||||
|
||||
return [
|
||||
'accountsCount' => $user->accounts()->count(),
|
||||
'accountsCount' => $accounts->count(),
|
||||
'transactionsCount' => $user->transactions()->count(),
|
||||
'categoriesCount' => $user->categories()->count(),
|
||||
'automationRulesCount' => $user->automationRules()->count(),
|
||||
'balancesByCurrency' => $balancesByCurrency,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
@ -83,19 +91,11 @@ class SubscriptionController extends Controller
|
|||
$subscriptionBuilder->allowPromotionCodes();
|
||||
}
|
||||
|
||||
$trialDays = $this->experimentOffer->trialDaysFor($request->user(), $planKey);
|
||||
$trialDays = (int) ($plan['trial_days'] ?? 0);
|
||||
if ($trialDays > 0) {
|
||||
$subscriptionBuilder->trialDays($trialDays);
|
||||
}
|
||||
|
||||
// Attribute revenue to the upsell point the checkout started from. The
|
||||
// value rides along as Stripe subscription metadata and is persisted
|
||||
// locally when the subscription webhook lands (see
|
||||
// PersistUpsellSourceFromStripe).
|
||||
if ($source = UpsellSource::tryFrom((string) $request->query('source', ''))) {
|
||||
$subscriptionBuilder->withMetadata(['upsell_source' => $source->value]);
|
||||
}
|
||||
|
||||
return $subscriptionBuilder->checkout([
|
||||
'success_url' => route('subscribe.success'),
|
||||
'cancel_url' => route('subscribe.cancel'),
|
||||
|
|
@ -176,68 +176,7 @@ class SubscriptionController extends Controller
|
|||
return redirect()->route('dashboard');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$subscription = $user->subscription('default');
|
||||
|
||||
return Inertia::render('settings/billing', [
|
||||
'hasAiConsent' => $user->hasActiveAiConsent(),
|
||||
'refund' => [
|
||||
'canSelfRefund' => $this->experimentOffer->canSelfRefund($user),
|
||||
'deadline' => $subscription !== null && $this->experimentOffer->variantFor($user) === SubscriptionExperiment::PAY_NOW
|
||||
? $this->experimentOffer->refundDeadlineFor($subscription)->toIso8601String()
|
||||
: null,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function refund(Request $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
if (! $this->experimentOffer->canSelfRefund($user)) {
|
||||
return redirect()->route('settings.billing')
|
||||
->withErrors(['refund' => __('This subscription is no longer eligible for a self-service refund.')]);
|
||||
}
|
||||
|
||||
try {
|
||||
app(RefundSelfServe::class)->handle($user);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->discord->send('', [$this->refundEmbed($user, success: false, detail: $exception->getMessage())]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
$this->discord->send('', [$this->refundEmbed($user, success: true)]);
|
||||
|
||||
return redirect()->route('settings.billing')
|
||||
->with('status', __('Your payment was refunded, your subscription was canceled, and your bank connections were disconnected.'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function refundEmbed(User $user, bool $success, ?string $detail = null): array
|
||||
{
|
||||
if (! $success) {
|
||||
return [
|
||||
'title' => '🔴 Self-service refund FAILED',
|
||||
'description' => 'A pay_now refund threw — the user may have been charged without a refund. Check Stripe and Sentry now.',
|
||||
'color' => 0xED4245,
|
||||
'fields' => [
|
||||
['name' => 'User', 'value' => $user->email, 'inline' => false],
|
||||
['name' => 'Error', 'value' => substr((string) $detail, 0, 1000), 'inline' => false],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'title' => '💸 Self-service refund processed',
|
||||
'description' => 'A pay_now user refunded within the money-back window — subscription canceled and bank connections disconnected.',
|
||||
'color' => 0xFAA61A,
|
||||
'fields' => [
|
||||
['name' => 'User', 'value' => $user->email, 'inline' => false],
|
||||
],
|
||||
];
|
||||
return Inertia::render('settings/billing');
|
||||
}
|
||||
|
||||
public function billingPortal(Request $request): RedirectResponse
|
||||
|
|
|
|||
|
|
@ -30,8 +30,6 @@ class TransactionController extends Controller
|
|||
$user = $request->user();
|
||||
$validated = $request->validated();
|
||||
|
||||
$lastVisitAt = $user->transactions_last_visited_at;
|
||||
|
||||
$perPage = (int) ($validated['per_page'] ?? 50);
|
||||
$sortParam = $validated['sort'] ?? '-transaction_date';
|
||||
|
||||
|
|
@ -79,11 +77,6 @@ class TransactionController extends Controller
|
|||
->append('ai_categorized');
|
||||
});
|
||||
|
||||
$newestServed = $transactions->getCollection()->max('created_at');
|
||||
if ($newestServed && (! $lastVisitAt || $newestServed->gt($lastVisitAt))) {
|
||||
$user->forceFill(['transactions_last_visited_at' => $newestServed])->save();
|
||||
}
|
||||
|
||||
$appliedFilters = [
|
||||
'date_from' => $validated['date_from'] ?? null,
|
||||
'date_to' => $validated['date_to'] ?? null,
|
||||
|
|
@ -134,9 +127,6 @@ class TransactionController extends Controller
|
|||
'banks' => $banks,
|
||||
'labels' => $labels,
|
||||
'automationRules' => $automationRules,
|
||||
'hasAiConsent' => $user->hasActiveAiConsent(),
|
||||
'aiConsentPromptDismissed' => $user->hasDismissedAiConsentPrompt(),
|
||||
'lastVisitAt' => $lastVisitAt?->toISOString(),
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -182,7 +172,7 @@ class TransactionController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
public function store(StoreTransactionRequest $request, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
|
||||
public function store(StoreTransactionRequest $request): JsonResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$labelIds = $data['label_ids'] ?? null;
|
||||
|
|
@ -204,16 +194,12 @@ class TransactionController extends Controller
|
|||
$transaction->labels()->sync($labelIds);
|
||||
}
|
||||
|
||||
if ($request->boolean('update_balance')) {
|
||||
$balanceAdjuster->applyCreatedTransaction($transaction);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->load('labels'),
|
||||
], 201);
|
||||
}
|
||||
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction, ManualBalanceAdjuster $balanceAdjuster): JsonResponse
|
||||
public function update(UpdateTransactionRequest $request, Transaction $transaction): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $transaction);
|
||||
|
||||
|
|
@ -222,15 +208,13 @@ class TransactionController extends Controller
|
|||
$hasLabelUpdate = $request->has('label_ids');
|
||||
unset($data['label_ids']);
|
||||
|
||||
$learnedRule = null;
|
||||
|
||||
// A user-set category overrides any AI assignment: learn the correction as
|
||||
// a forward-looking rule, log/self-heal as needed, and reset provenance.
|
||||
// A user-set category overrides any AI assignment: log the correction,
|
||||
// self-heal the ai rule, and reset the provenance to manual.
|
||||
if ($request->has('category_id')) {
|
||||
$newCategoryId = $data['category_id'] ?? null;
|
||||
|
||||
if ($newCategoryId !== $transaction->category_id) {
|
||||
$learnedRule = app(CategoryOverrideHandler::class)->record($transaction, $newCategoryId);
|
||||
app(CategoryOverrideHandler::class)->record($transaction, $newCategoryId);
|
||||
|
||||
$data['category_source'] = $newCategoryId === null ? null : CategorySource::Manual->value;
|
||||
$data['ai_confidence'] = null;
|
||||
|
|
@ -238,10 +222,6 @@ class TransactionController extends Controller
|
|||
}
|
||||
}
|
||||
|
||||
// Snapshot the pre-edit account/date/amount before filling, so a manual
|
||||
// account balance can be moved off the old values if the edit changes them.
|
||||
$originalSnapshot = clone $transaction;
|
||||
|
||||
// Update attributes directly without firing events yet
|
||||
if (! empty($data)) {
|
||||
$transaction->fill($data);
|
||||
|
|
@ -264,25 +244,8 @@ class TransactionController extends Controller
|
|||
$transaction->save();
|
||||
}
|
||||
|
||||
// Move the manual account balance to match an edited amount/date/account:
|
||||
// strip the pre-edit contribution (exactly as a deletion would) and apply
|
||||
// the new one (exactly as a creation would), both cascading forward.
|
||||
// ponytail: like create/delete, this trusts the opt-in flag and keeps no
|
||||
// record of whether creation adjusted the balance, so mixing the flag
|
||||
// across create and edit can drift; a transaction-derived balance would
|
||||
// remove that trust. Connected accounts are skipped inside the adjuster.
|
||||
if ($request->boolean('update_balance') && $transaction->wasChanged(['amount', 'transaction_date', 'account_id'])) {
|
||||
$balanceAdjuster->reverseDeletedTransaction($originalSnapshot);
|
||||
$balanceAdjuster->applyCreatedTransaction($transaction->load('account'));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'data' => $transaction->fresh()->load('labels'),
|
||||
'learned_rule' => $learnedRule === null ? null : [
|
||||
'id' => $learnedRule->id,
|
||||
'title' => $learnedRule->title,
|
||||
'category_id' => $learnedRule->action_category_id,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
@ -329,10 +292,8 @@ class TransactionController extends Controller
|
|||
if ($request->has('category_id')) {
|
||||
$newCategoryId = $request->input('category_id');
|
||||
|
||||
$overrideHandler = app(CategoryOverrideHandler::class);
|
||||
|
||||
foreach ($transactions as $transaction) {
|
||||
$overrideHandler->record($transaction, $newCategoryId);
|
||||
app(CategoryOverrideHandler::class)->record($transaction, $newCategoryId);
|
||||
}
|
||||
|
||||
$updateData['category_id'] = $newCategoryId;
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ class EnsureUserIsSubscribed
|
|||
return $next($request);
|
||||
}
|
||||
|
||||
if ($user && ! $user->bankingConnections()->exists() && ! $user->hasActiveAiConsent()) {
|
||||
if ($user && ! $user->bankingConnections()->exists()) {
|
||||
if (! $user->hasSeenPaywall()) {
|
||||
return redirect()->route('subscribe');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,8 @@
|
|||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Features\CalculateBalancesOnImport;
|
||||
use App\Features\Mcp;
|
||||
use App\Jobs\PurgeResidualEncryptionArtifactsJob;
|
||||
use App\Features\TransactionAnalysis;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Services\CurrencyOptions;
|
||||
use Illuminate\Foundation\Inspiring;
|
||||
|
|
@ -58,12 +56,12 @@ class HandleInertiaRequests extends Middleware
|
|||
->where(fn ($q) => $q->whereNotNull('description_iv')->orWhereNotNull('notes_iv'))
|
||||
->exists() ?? false;
|
||||
|
||||
// A shared-data provider must stay read-only, so hand the residual
|
||||
// encryption cleanup off to a queued job instead of mutating the user
|
||||
// inline during the render. The job re-checks the condition and is
|
||||
// idempotent, so dispatching it on repeat requests is harmless.
|
||||
if (! $request->is('api/*') && $user?->encryption_salt !== null && ! $hasEncryptedAccounts && ! $hasEncryptedTransactions) {
|
||||
PurgeResidualEncryptionArtifactsJob::dispatch($user);
|
||||
// Clean up encryption data if no encrypted accounts or transactions remain
|
||||
if (! $request->is('api/*') && $user?->encryption_salt !== null) {
|
||||
if (! $hasEncryptedAccounts && ! $hasEncryptedTransactions) {
|
||||
$user->encryptedMessage()->delete();
|
||||
$user->update(['encryption_salt' => null]);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
|
|
@ -87,13 +85,11 @@ class HandleInertiaRequests extends Middleware
|
|||
'status' => 'past_due',
|
||||
'action_url' => route('settings.billing.portal'),
|
||||
] : null,
|
||||
'demoEnabled' => (bool) config('app.demo.enabled'),
|
||||
'demoCredentials' => config('app.demo.enabled') && ($isDemoQuery || $isDemoAccount) ? [
|
||||
'demoCredentials' => ($isDemoQuery || $isDemoAccount) ? [
|
||||
'email' => config('app.demo.email'),
|
||||
'password' => config('app.demo.password'),
|
||||
] : null,
|
||||
'subscriptionsEnabled' => config('subscriptions.enabled', false),
|
||||
'aiCategorizationUpsellRate' => (int) config('ai_categorization.upsell_sample_rate'),
|
||||
'pricing' => [
|
||||
'plans' => config('subscriptions.plans', []),
|
||||
'defaultPlan' => config('subscriptions.default_plan', 'monthly'),
|
||||
|
|
@ -107,7 +103,7 @@ class HandleInertiaRequests extends Middleware
|
|||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
'features' => $this->resolveFeatureFlags(),
|
||||
'expiredBankingConnections' => fn () => $user ? $user->bankingConnections()
|
||||
->where('provider', BankingProvider::EnableBanking)
|
||||
->where('provider', 'enablebanking')
|
||||
->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Expired)
|
||||
->orWhere(function ($query) {
|
||||
|
|
@ -121,18 +117,10 @@ class HandleInertiaRequests extends Middleware
|
|||
->map(fn (BankingConnection $connection): array => [
|
||||
'id' => $connection->id,
|
||||
'aspsp_name' => $connection->aspsp_name,
|
||||
'provider' => $connection->provider->value,
|
||||
'provider' => $connection->provider,
|
||||
'valid_until' => $connection->valid_until?->toIso8601String(),
|
||||
'reconnect_url' => route('open-banking.reconnect', $connection),
|
||||
]) : [],
|
||||
'bankingConnections' => fn () => $user ? $user->bankingConnections()
|
||||
->get(['id', 'aspsp_name', 'provider', 'status'])
|
||||
->map(fn (BankingConnection $connection): array => [
|
||||
'id' => $connection->id,
|
||||
'aspsp_name' => $connection->aspsp_name,
|
||||
'provider' => $connection->provider->value,
|
||||
'status' => $connection->status->value,
|
||||
]) : [],
|
||||
'accounts' => fn () => $user ? $user->accounts()
|
||||
->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id'])
|
||||
->orderBy('name')
|
||||
|
|
@ -180,19 +168,19 @@ class HandleInertiaRequests extends Middleware
|
|||
return [
|
||||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => false,
|
||||
'mcp' => false,
|
||||
'transactionAnalysis' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$features = Feature::for($user)->values([
|
||||
CalculateBalancesOnImport::class,
|
||||
Mcp::class,
|
||||
TransactionAnalysis::class,
|
||||
]);
|
||||
|
||||
return [
|
||||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
|
||||
'mcp' => $features[Mcp::class] !== false,
|
||||
'transactionAnalysis' => $features[TransactionAnalysis::class] !== false,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Enums\Locale;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\App;
|
||||
|
|
@ -30,13 +29,12 @@ class SetLocale
|
|||
protected function determineLocale(Request $request): string
|
||||
{
|
||||
// Priority 1: Check for lang query parameter (user override on welcome page)
|
||||
$lang = $request->get('lang');
|
||||
|
||||
if (is_string($lang) && Locale::tryFrom($lang) !== null) {
|
||||
if ($request->has('lang') && in_array($request->get('lang'), ['en', 'es'])) {
|
||||
$locale = $request->get('lang');
|
||||
// Store in session so subsequent requests remember this choice
|
||||
$request->session()->put('locale', $lang);
|
||||
$request->session()->put('locale', $locale);
|
||||
|
||||
return $lang;
|
||||
return $locale;
|
||||
}
|
||||
|
||||
// Priority 2: Check authenticated user's locale preference
|
||||
|
|
@ -46,11 +44,11 @@ class SetLocale
|
|||
|
||||
// Priority 2b: Authenticated user without locale — detect and persist
|
||||
if ($request->user()) {
|
||||
$sessionLocale = $request->session()->get('locale');
|
||||
$detected = $this->detectLocaleFromHeader($request);
|
||||
|
||||
$detected = is_string($sessionLocale) && Locale::tryFrom($sessionLocale) !== null
|
||||
? $sessionLocale
|
||||
: Locale::detectFromHeader($request->header('Accept-Language'))->value;
|
||||
if (in_array($request->session()->get('locale'), ['en', 'es'])) {
|
||||
$detected = $request->session()->get('locale');
|
||||
}
|
||||
|
||||
$request->user()->update(['locale' => $detected]);
|
||||
|
||||
|
|
@ -63,11 +61,26 @@ class SetLocale
|
|||
}
|
||||
|
||||
// Priority 4: Detect from Accept-Language header
|
||||
$detected = Locale::detectFromHeader($request->header('Accept-Language'))->value;
|
||||
$detected = $this->detectLocaleFromHeader($request);
|
||||
|
||||
// Store in session for subsequent requests
|
||||
$request->session()->put('locale', $detected);
|
||||
|
||||
return $detected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect locale from Accept-Language header.
|
||||
*/
|
||||
protected function detectLocaleFromHeader(Request $request): string
|
||||
{
|
||||
$acceptLanguage = $request->header('Accept-Language', '');
|
||||
|
||||
// Check if Spanish is preferred
|
||||
if (preg_match('/^es(-|,|;)/i', $acceptLanguage) || $acceptLanguage === 'es') {
|
||||
return 'es';
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CheckDuplicateTransactionsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'account_id' => ['required', 'uuid'],
|
||||
'transactions' => ['required', 'array', 'max:10000'],
|
||||
'transactions.*.transaction_date' => ['required', 'date'],
|
||||
'transactions.*.amount' => ['required', 'integer'],
|
||||
'transactions.*.description' => ['required', 'string'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\Api;
|
||||
|
||||
use App\Enums\ImportConfigType;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateAccountImportConfigRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Authorization is handled by the controller via the AccountPolicy.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'type' => ['required', Rule::enum(ImportConfigType::class)],
|
||||
'config' => ['required', 'array'],
|
||||
'config.columnMapping' => ['required', 'array'],
|
||||
'config.dateFormat' => ['required', 'string', 'max:20'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -24,10 +24,7 @@ trait ValidatesAccountDetailRules
|
|||
],
|
||||
'address' => ['nullable', 'string', 'max:500'],
|
||||
'purchase_price' => ['nullable', 'integer', 'min:0'],
|
||||
// Floor the date: a mistyped/ancient year would make the historical
|
||||
// balance generator build a multi-century monthly series and OOM the
|
||||
// queue worker (PHP-LARAVEL-49). 1900 rejects typos, not real assets.
|
||||
'purchase_date' => ['nullable', 'date', 'after_or_equal:1900-01-01', 'before_or_equal:today'],
|
||||
'purchase_date' => ['nullable', 'date', 'before_or_equal:today'],
|
||||
'area_value' => ['nullable', 'numeric', 'min:0', 'max:99999999.99'],
|
||||
'area_unit' => ['nullable', 'string', Rule::in(['sqm', 'sqft', 'acres', 'hectares'])],
|
||||
'linked_loan_account_id' => [
|
||||
|
|
@ -55,9 +52,7 @@ trait ValidatesAccountDetailRules
|
|||
return [
|
||||
'annual_interest_rate' => ['nullable', 'numeric', 'min:0', 'max:100'],
|
||||
'loan_term_months' => ['nullable', 'integer', 'min:1', 'max:600'],
|
||||
// Floor the date for the same reason as purchase_date above: an
|
||||
// ancient loan start date OOMs the balance generator (PHP-LARAVEL-49).
|
||||
'loan_start_date' => ['nullable', 'date', 'after_or_equal:1900-01-01'],
|
||||
'loan_start_date' => ['nullable', 'date'],
|
||||
'original_amount' => ['nullable', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Requests\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ConnectBinanceRequest extends FormRequest
|
||||
|
|
@ -18,7 +17,8 @@ class ConnectBinanceRequest extends FormRequest
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
...BankingProvider::Binance->credentialRules(),
|
||||
'api_key' => ['required', 'string', 'min:10'],
|
||||
'api_secret' => ['required', 'string', 'min:10'],
|
||||
'country' => ['required', 'string', 'size:2'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Requests\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ConnectBitpandaRequest extends FormRequest
|
||||
|
|
@ -18,7 +17,7 @@ class ConnectBitpandaRequest extends FormRequest
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
...BankingProvider::Bitpanda->credentialRules(),
|
||||
'api_key' => ['required', 'string', 'min:10'],
|
||||
'country' => ['required', 'string', 'size:2'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Requests\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ConnectCoinbaseRequest extends FormRequest
|
||||
|
|
@ -18,7 +17,8 @@ class ConnectCoinbaseRequest extends FormRequest
|
|||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
...BankingProvider::Coinbase->credentialRules(),
|
||||
'api_key_name' => ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i'],
|
||||
'private_key' => ['required', 'string', 'min:40'],
|
||||
'country' => ['required', 'string', 'size:2'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
namespace App\Http\Requests\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ConnectIndexaCapitalRequest extends FormRequest
|
||||
|
|
@ -17,6 +16,8 @@ class ConnectIndexaCapitalRequest extends FormRequest
|
|||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return BankingProvider::IndexaCapital->credentialRules();
|
||||
return [
|
||||
'api_token' => ['required', 'string', 'min:10'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ConnectInteractiveBrokersRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return BankingProvider::InteractiveBrokers->credentialRules();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\OpenBanking;
|
||||
|
||||
use App\Enums\BankingProvider;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ConnectWiseRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return BankingProvider::Wise->credentialRules();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'api_token.required' => 'A Wise API token is required.',
|
||||
'api_token.min' => 'The API token appears to be too short.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests\OpenBanking;
|
||||
|
||||
use App\Http\Requests\Concerns\ValidatesUserOwnedResources;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class MapConnectionAccountRequest extends FormRequest
|
||||
{
|
||||
use ValidatesUserOwnedResources;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->route('connection')->user_id === $this->user()->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<mixed>>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'bank_account_uid' => ['required', 'string'],
|
||||
'action' => ['required', 'in:create,link'],
|
||||
'existing_account_id' => [
|
||||
'nullable',
|
||||
'uuid',
|
||||
'required_if:action,link',
|
||||
$this->userOwned('accounts'),
|
||||
],
|
||||
'currency' => ['nullable', 'string', 'size:3'],
|
||||
'name' => ['nullable', 'string', 'max:255'],
|
||||
'iban' => ['nullable', 'string', 'max:34'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -26,6 +26,22 @@ class UpdateConnectionCredentialsRequest extends FormRequest
|
|||
return [];
|
||||
}
|
||||
|
||||
return $connection->provider->credentialRules();
|
||||
return match ($connection->provider) {
|
||||
'indexacapital' => [
|
||||
'api_token' => ['required', 'string', 'min:10'],
|
||||
],
|
||||
'binance' => [
|
||||
'api_key' => ['required', 'string', 'min:10'],
|
||||
'api_secret' => ['required', 'string', 'min:10'],
|
||||
],
|
||||
'bitpanda' => [
|
||||
'api_key' => ['required', 'string', 'min:10'],
|
||||
],
|
||||
'coinbase' => [
|
||||
'api_key_name' => ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i'],
|
||||
'private_key' => ['required', 'string', 'min:40'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class ReorderAccountsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'ids' => ['required', 'array'],
|
||||
'ids.*' => [
|
||||
'string',
|
||||
Rule::exists('accounts', 'id')->where('user_id', $this->user()->id),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue