Compare commits
10 Commits
6a24d07cec
...
5cf0510aa5
| Author | SHA1 | Date | |
|---|---|---|---|
| 5cf0510aa5 | |||
| ea0d625bf9 | |||
| c9dafc0f05 | |||
| 1db50bc69a | |||
| 548100af74 | |||
| 1fdb5f4cda | |||
| 5d2f4d0ee0 | |||
| 1216c8a073 | |||
| a01387bb09 | |||
| 51b50aa1d0 |
@@ -1,6 +1,5 @@
|
|||||||
.git
|
.git
|
||||||
.ssh
|
.ssh
|
||||||
.kamal/secrets*
|
|
||||||
vendor
|
vendor
|
||||||
node_modules
|
node_modules
|
||||||
storage/logs
|
storage/logs
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
name: CD
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows:
|
||||||
|
- CI
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Déploiement production
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Configuration SSH
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.DEPLOY_SSH_KEY }}" \
|
||||||
|
> ~/.ssh/id_ed25519
|
||||||
|
chmod 600 ~/.ssh/id_ed25519
|
||||||
|
ssh-keyscan \
|
||||||
|
-H "${{ secrets.DEPLOY_HOST }}" \
|
||||||
|
>> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
|
||||||
|
- name: 🚀 Deploy Docker
|
||||||
|
run: |
|
||||||
|
ssh \
|
||||||
|
-i ~/.ssh/id_ed25519 \
|
||||||
|
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }} << 'EOF'
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Projet"
|
||||||
|
cd /data/stacks/daily-meal-api
|
||||||
|
|
||||||
|
echo "Pull nouvelle image"
|
||||||
|
docker compose pull
|
||||||
|
|
||||||
|
echo "Redémarrage"
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
echo "Migration Laravel"
|
||||||
|
docker compose exec -T app php artisan migrate --force
|
||||||
|
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- develop
|
||||||
|
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: 🧪 Tests Laravel
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DB_CONNECTION: sqlite
|
||||||
|
DB_DATABASE: database/database.sqlite
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: 🐘 Setup PHP
|
||||||
|
uses: shivammathur/setup-php@v2
|
||||||
|
with:
|
||||||
|
php-version: '8.4'
|
||||||
|
extensions: bcmath,ctype,curl,dom,fileinfo,intl,mbstring,openssl,pdo,tokenizer,xml,zip
|
||||||
|
coverage: none
|
||||||
|
|
||||||
|
- name: Cache Composer
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.composer/cache
|
||||||
|
key: composer-cache
|
||||||
|
|
||||||
|
- name: Install Composer dependencies
|
||||||
|
run: |
|
||||||
|
composer install \
|
||||||
|
--no-interaction \
|
||||||
|
--prefer-dist \
|
||||||
|
--no-progress
|
||||||
|
|
||||||
|
- name: ⚙️ Prepare Laravel
|
||||||
|
run: |
|
||||||
|
cp .env.example .env
|
||||||
|
php artisan key:generate
|
||||||
|
touch database/database.sqlite
|
||||||
|
php artisan migrate --force
|
||||||
|
|
||||||
|
|
||||||
|
- name: 🧪 Run PHPUnit
|
||||||
|
run: |
|
||||||
|
php artisan test
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
docker:
|
||||||
|
name: 🐳 Build & Push Image
|
||||||
|
needs: test
|
||||||
|
if: ${{ github.ref == 'refs/heads/main' }}
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: 📥 Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: 🔐 Login Registry Gitea
|
||||||
|
run: |
|
||||||
|
docker login git.leonmorival.xyz \
|
||||||
|
-u "${{ secrets.REGISTRY_USER }}" \
|
||||||
|
-p "${{ secrets.REGISTRY_PASSWORD }}"
|
||||||
|
|
||||||
|
- name: 🏗️ Build image
|
||||||
|
run: |
|
||||||
|
docker build \
|
||||||
|
-t git.leonmorival.xyz/leonm/daily-meal-api:latest \
|
||||||
|
-t git.leonmorival.xyz/leonm/daily-meal-api:${{ github.sha }} \
|
||||||
|
.
|
||||||
|
|
||||||
|
- name: 📤 Push image
|
||||||
|
run: |
|
||||||
|
docker push git.leonmorival.xyz/leonm/daily-meal-api:latest
|
||||||
|
docker push git.leonmorival.xyz/leonm/daily-meal-api:${{ github.sha }}
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
name: Laravel CI-CD
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches: ["main"]
|
|
||||||
|
|
||||||
env:
|
|
||||||
KAMAL_IMAGE: ghcr.io/basecamp/kamal:v2.11.0
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
test:
|
|
||||||
name: Tests Unitaires
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Run Tests
|
|
||||||
uses: docker://laravelsail/php84-composer:latest
|
|
||||||
env:
|
|
||||||
APP_ENV: testing
|
|
||||||
APP_KEY: base64:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
|
|
||||||
DB_CONNECTION: sqlite
|
|
||||||
DB_DATABASE: ":memory:"
|
|
||||||
with:
|
|
||||||
args: >
|
|
||||||
bash -lc "apt-get update
|
|
||||||
&& apt-get install -y --no-install-recommends libicu-dev
|
|
||||||
&& docker-php-ext-install intl
|
|
||||||
&& php -m | grep -qi '^intl$'
|
|
||||||
&& composer install --no-interaction --prefer-dist
|
|
||||||
&& php -d memory_limit=512M artisan test --compact"
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
name: Deploy with Kamal
|
|
||||||
needs: test
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout code
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.TOKEN_GITEA }}
|
|
||||||
APP_KEY: ${{ secrets.APP_KEY }}
|
|
||||||
DB_DATABASE: ${{ secrets.DB_DATABASE }}
|
|
||||||
DB_USERNAME: ${{ secrets.DB_USERNAME }}
|
|
||||||
DB_PASSWORD: ${{ secrets.DB_PASSWORD }}
|
|
||||||
MEILISEARCH_KEY: ${{ secrets.MEILISEARCH_KEY }}
|
|
||||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
|
||||||
STRIPE_KEY: ${{ secrets.STRIPE_KEY }}
|
|
||||||
STRIPE_SECRET: ${{ secrets.STRIPE_SECRET }}
|
|
||||||
STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
|
|
||||||
SSH_HOST: ${{ secrets.SSH_HOST }}
|
|
||||||
SSH_USER: ${{ secrets.SSH_USER }}
|
|
||||||
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
|
|
||||||
STRAVA_CLIENT_ID: ${{ secrets.STRAVA_CLIENT_ID }}
|
|
||||||
STRAVA_CLIENT_SECRET: ${{ secrets.STRAVA_CLIENT_SECRET }}
|
|
||||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
|
||||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
|
||||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
|
||||||
|
|
||||||
run: |
|
|
||||||
set -eu
|
|
||||||
: "${GEMINI_API_KEY:?GEMINI_API_KEY secret is required}"
|
|
||||||
: "${STRIPE_KEY:?STRIPE_KEY secret is required}"
|
|
||||||
: "${STRIPE_SECRET:?STRIPE_SECRET secret is required}"
|
|
||||||
: "${STRIPE_WEBHOOK_SECRET:?STRIPE_WEBHOOK_SECRET secret is required}"
|
|
||||||
: "${AWS_ACCESS_KEY_ID:?AWS_ACCESS_KEY_ID secret is required}"
|
|
||||||
: "${AWS_SECRET_ACCESS_KEY:?AWS_SECRET_ACCESS_KEY secret is required}"
|
|
||||||
|
|
||||||
WORKSPACE="${GITHUB_WORKSPACE:-$PWD}"
|
|
||||||
test -f "$WORKSPACE/config/deploy.yml"
|
|
||||||
|
|
||||||
tar \
|
|
||||||
--exclude=.kamal/secrets \
|
|
||||||
--exclude=.kamal/secrets.* \
|
|
||||||
--exclude=.env \
|
|
||||||
--exclude=.env.* \
|
|
||||||
-C "$WORKSPACE" -cf - . | docker run --rm -i \
|
|
||||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
|
||||||
-e GITEA_TOKEN \
|
|
||||||
-e APP_KEY \
|
|
||||||
-e DB_DATABASE \
|
|
||||||
-e DB_USERNAME \
|
|
||||||
-e DB_PASSWORD \
|
|
||||||
-e MEILISEARCH_KEY \
|
|
||||||
-e GEMINI_API_KEY \
|
|
||||||
-e STRIPE_KEY \
|
|
||||||
-e STRIPE_SECRET \
|
|
||||||
-e STRIPE_WEBHOOK_SECRET \
|
|
||||||
-e SSH_HOST \
|
|
||||||
-e SSH_USER \
|
|
||||||
-e SSH_PRIVATE_KEY \
|
|
||||||
-e STRAVA_CLIENT_ID \
|
|
||||||
-e STRAVA_CLIENT_SECRET \
|
|
||||||
-e RESEND_API_KEY \
|
|
||||||
-e AWS_ACCESS_KEY_ID \
|
|
||||||
-e AWS_SECRET_ACCESS_KEY \
|
|
||||||
--entrypoint /bin/sh \
|
|
||||||
"$KAMAL_IMAGE" -lc '
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
mkdir -p /workdir /root/.ssh
|
|
||||||
tar -xf - -C /workdir
|
|
||||||
cd /workdir
|
|
||||||
|
|
||||||
chmod 700 /root/.ssh
|
|
||||||
printf "%s\n" "$SSH_PRIVATE_KEY" | tr -d "\r" > /root/.ssh/id_ed25519
|
|
||||||
chmod 600 /root/.ssh/id_ed25519
|
|
||||||
|
|
||||||
REMOTE_HOST="${SSH_HOST:-89.167.35.217}"
|
|
||||||
REMOTE_USER="${SSH_USER:-root}"
|
|
||||||
|
|
||||||
mkdir -p /workdir/.ssh
|
|
||||||
cat > /workdir/.ssh/config <<'"'"'SSH_CONFIG'"'"'
|
|
||||||
Host *
|
|
||||||
IdentityFile /root/.ssh/id_ed25519
|
|
||||||
IdentitiesOnly yes
|
|
||||||
StrictHostKeyChecking yes
|
|
||||||
UserKnownHostsFile /workdir/.ssh/known_hosts
|
|
||||||
GlobalKnownHostsFile /dev/null
|
|
||||||
CheckHostIP no
|
|
||||||
SSH_CONFIG
|
|
||||||
ssh-keyscan -H "$REMOTE_HOST" > /workdir/.ssh/known_hosts
|
|
||||||
chmod 600 /workdir/.ssh/config /workdir/.ssh/known_hosts
|
|
||||||
|
|
||||||
mkdir -p .kamal
|
|
||||||
cat > .kamal/secrets <<'"'"'SECRETS'"'"'
|
|
||||||
GITEA_TOKEN=$GITEA_TOKEN
|
|
||||||
APP_KEY=$APP_KEY
|
|
||||||
DB_DATABASE=$DB_DATABASE
|
|
||||||
DB_USERNAME=$DB_USERNAME
|
|
||||||
DB_PASSWORD=$DB_PASSWORD
|
|
||||||
MEILISEARCH_KEY=$MEILISEARCH_KEY
|
|
||||||
GEMINI_API_KEY=$GEMINI_API_KEY
|
|
||||||
STRIPE_KEY=$STRIPE_KEY
|
|
||||||
STRIPE_SECRET=$STRIPE_SECRET
|
|
||||||
STRIPE_WEBHOOK_SECRET=$STRIPE_WEBHOOK_SECRET
|
|
||||||
SSH_PRIVATE_KEY=$SSH_PRIVATE_KEY
|
|
||||||
STRAVA_CLIENT_ID=$STRAVA_CLIENT_ID
|
|
||||||
STRAVA_CLIENT_SECRET=$STRAVA_CLIENT_SECRET
|
|
||||||
RESEND_API_KEY=$RESEND_API_KEY
|
|
||||||
AWS_ACCESS_KEY_ID=$AWS_ACCESS_KEY_ID
|
|
||||||
AWS_SECRET_ACCESS_KEY=$AWS_SECRET_ACCESS_KEY
|
|
||||||
SECRETS
|
|
||||||
chmod 600 .kamal/secrets
|
|
||||||
|
|
||||||
ssh -F /workdir/.ssh/config "$REMOTE_USER@$REMOTE_HOST" <<'"'"'REMOTE'"'"'
|
|
||||||
set -eu
|
|
||||||
|
|
||||||
docker network inspect bemeal_public >/dev/null 2>&1 || docker network create bemeal_public
|
|
||||||
docker network inspect bemeal_internal >/dev/null 2>&1 || docker network create bemeal_internal
|
|
||||||
|
|
||||||
for container in bemeal-pgsql bemeal-redis bemeal-meilisearch bemeal-adminer; do
|
|
||||||
if docker inspect "$container" >/dev/null 2>&1; then
|
|
||||||
docker network connect bemeal_internal "$container" >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
REMOTE
|
|
||||||
|
|
||||||
/kamal/bin/kamal deploy
|
|
||||||
'
|
|
||||||
@@ -23,4 +23,3 @@ Homestead.json
|
|||||||
Homestead.yaml
|
Homestead.yaml
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
.env.prod
|
.env.prod
|
||||||
.kamal/secrets
|
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Docker set up on $KAMAL_HOSTS..."
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Booted app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# A sample post-deploy hook
|
|
||||||
#
|
|
||||||
# These environment variables are available:
|
|
||||||
# KAMAL_RECORDED_AT
|
|
||||||
# KAMAL_PERFORMER
|
|
||||||
# KAMAL_VERSION
|
|
||||||
# KAMAL_HOSTS
|
|
||||||
# KAMAL_ROLES (if set)
|
|
||||||
# KAMAL_DESTINATION (if set)
|
|
||||||
# KAMAL_RUNTIME
|
|
||||||
|
|
||||||
echo "$KAMAL_PERFORMER deployed $KAMAL_VERSION to $KAMAL_DESTINATION in $KAMAL_RUNTIME seconds"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Rebooted kamal-proxy on $KAMAL_HOSTS"
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Booting app version $KAMAL_VERSION on $KAMAL_HOSTS..."
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
# A sample pre-build hook
|
|
||||||
#
|
|
||||||
# Checks:
|
|
||||||
# 1. We have a clean checkout
|
|
||||||
# 2. A remote is configured
|
|
||||||
# 3. The branch has been pushed to the remote
|
|
||||||
# 4. The version we are deploying matches the remote
|
|
||||||
#
|
|
||||||
# These environment variables are available:
|
|
||||||
# KAMAL_RECORDED_AT
|
|
||||||
# KAMAL_PERFORMER
|
|
||||||
# KAMAL_VERSION
|
|
||||||
# KAMAL_HOSTS
|
|
||||||
# KAMAL_ROLES (if set)
|
|
||||||
# KAMAL_DESTINATION (if set)
|
|
||||||
|
|
||||||
if [ -n "$(git status --porcelain)" ]; then
|
|
||||||
echo "Git checkout is not clean, aborting..." >&2
|
|
||||||
git status --porcelain >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
first_remote=$(git remote)
|
|
||||||
|
|
||||||
if [ -z "$first_remote" ]; then
|
|
||||||
echo "No git remote set, aborting..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
current_branch=$(git branch --show-current)
|
|
||||||
|
|
||||||
if [ -z "$current_branch" ]; then
|
|
||||||
echo "Not on a git branch, aborting..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
remote_head=$(git ls-remote $first_remote --tags $current_branch | cut -f1)
|
|
||||||
|
|
||||||
if [ -z "$remote_head" ]; then
|
|
||||||
echo "Branch not pushed to remote, aborting..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$KAMAL_VERSION" != "$remote_head" ]; then
|
|
||||||
echo "Version ($KAMAL_VERSION) does not match remote HEAD ($remote_head), aborting..." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
exit 0
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
#!/usr/bin/env ruby
|
|
||||||
|
|
||||||
# A sample pre-connect check
|
|
||||||
#
|
|
||||||
# Warms DNS before connecting to hosts in parallel
|
|
||||||
#
|
|
||||||
# These environment variables are available:
|
|
||||||
# KAMAL_RECORDED_AT
|
|
||||||
# KAMAL_PERFORMER
|
|
||||||
# KAMAL_VERSION
|
|
||||||
# KAMAL_HOSTS
|
|
||||||
# KAMAL_ROLES (if set)
|
|
||||||
# KAMAL_DESTINATION (if set)
|
|
||||||
# KAMAL_RUNTIME
|
|
||||||
|
|
||||||
hosts = ENV["KAMAL_HOSTS"].split(",")
|
|
||||||
results = nil
|
|
||||||
max = 3
|
|
||||||
|
|
||||||
elapsed = Benchmark.realtime do
|
|
||||||
results = hosts.map do |host|
|
|
||||||
Thread.new do
|
|
||||||
tries = 1
|
|
||||||
|
|
||||||
begin
|
|
||||||
Socket.getaddrinfo(host, 0, Socket::AF_UNSPEC, Socket::SOCK_STREAM, nil, Socket::AI_CANONNAME)
|
|
||||||
rescue SocketError
|
|
||||||
if tries < max
|
|
||||||
puts "Retrying DNS warmup: #{host}"
|
|
||||||
tries += 1
|
|
||||||
sleep rand
|
|
||||||
retry
|
|
||||||
else
|
|
||||||
puts "DNS warmup failed: #{host}"
|
|
||||||
host
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
tries
|
|
||||||
end
|
|
||||||
end.map(&:value)
|
|
||||||
end
|
|
||||||
|
|
||||||
retries = results.sum - hosts.size
|
|
||||||
nopes = results.count { |r| r == max }
|
|
||||||
|
|
||||||
puts "Prewarmed %d DNS lookups in %.2f sec: %d retries, %d failures" % [ hosts.size, elapsed, retries, nopes ]
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
#!/usr/bin/env ruby
|
|
||||||
|
|
||||||
# A sample pre-deploy hook
|
|
||||||
#
|
|
||||||
# Checks the Github status of the build, waiting for a pending build to complete for up to 720 seconds.
|
|
||||||
#
|
|
||||||
# Fails unless the combined status is "success"
|
|
||||||
#
|
|
||||||
# These environment variables are available:
|
|
||||||
# KAMAL_RECORDED_AT
|
|
||||||
# KAMAL_PERFORMER
|
|
||||||
# KAMAL_VERSION
|
|
||||||
# KAMAL_HOSTS
|
|
||||||
# KAMAL_COMMAND
|
|
||||||
# KAMAL_SUBCOMMAND
|
|
||||||
# KAMAL_ROLES (if set)
|
|
||||||
# KAMAL_DESTINATION (if set)
|
|
||||||
|
|
||||||
# Only check the build status for production deployments
|
|
||||||
if ENV["KAMAL_COMMAND"] == "rollback" || ENV["KAMAL_DESTINATION"] != "production"
|
|
||||||
exit 0
|
|
||||||
end
|
|
||||||
|
|
||||||
require "bundler/inline"
|
|
||||||
|
|
||||||
# true = install gems so this is fast on repeat invocations
|
|
||||||
gemfile(true, quiet: true) do
|
|
||||||
source "https://rubygems.org"
|
|
||||||
|
|
||||||
gem "octokit"
|
|
||||||
gem "faraday-retry"
|
|
||||||
end
|
|
||||||
|
|
||||||
MAX_ATTEMPTS = 72
|
|
||||||
ATTEMPTS_GAP = 10
|
|
||||||
|
|
||||||
def exit_with_error(message)
|
|
||||||
$stderr.puts message
|
|
||||||
exit 1
|
|
||||||
end
|
|
||||||
|
|
||||||
class GithubStatusChecks
|
|
||||||
attr_reader :remote_url, :git_sha, :github_client, :combined_status
|
|
||||||
|
|
||||||
def initialize
|
|
||||||
@remote_url = github_repo_from_remote_url
|
|
||||||
@git_sha = `git rev-parse HEAD`.strip
|
|
||||||
@github_client = Octokit::Client.new(access_token: ENV["GITHUB_TOKEN"])
|
|
||||||
refresh!
|
|
||||||
end
|
|
||||||
|
|
||||||
def refresh!
|
|
||||||
@combined_status = github_client.combined_status(remote_url, git_sha)
|
|
||||||
end
|
|
||||||
|
|
||||||
def state
|
|
||||||
combined_status[:state]
|
|
||||||
end
|
|
||||||
|
|
||||||
def first_status_url
|
|
||||||
first_status = combined_status[:statuses].find { |status| status[:state] == state }
|
|
||||||
first_status && first_status[:target_url]
|
|
||||||
end
|
|
||||||
|
|
||||||
def complete_count
|
|
||||||
combined_status[:statuses].count { |status| status[:state] != "pending"}
|
|
||||||
end
|
|
||||||
|
|
||||||
def total_count
|
|
||||||
combined_status[:statuses].count
|
|
||||||
end
|
|
||||||
|
|
||||||
def current_status
|
|
||||||
if total_count > 0
|
|
||||||
"Completed #{complete_count}/#{total_count} checks, see #{first_status_url} ..."
|
|
||||||
else
|
|
||||||
"Build not started..."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
def github_repo_from_remote_url
|
|
||||||
url = `git config --get remote.origin.url`.strip.delete_suffix(".git")
|
|
||||||
if url.start_with?("https://github.com/")
|
|
||||||
url.delete_prefix("https://github.com/")
|
|
||||||
elsif url.start_with?("git@github.com:")
|
|
||||||
url.delete_prefix("git@github.com:")
|
|
||||||
else
|
|
||||||
url
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
|
|
||||||
$stdout.sync = true
|
|
||||||
|
|
||||||
begin
|
|
||||||
puts "Checking build status..."
|
|
||||||
|
|
||||||
attempts = 0
|
|
||||||
checks = GithubStatusChecks.new
|
|
||||||
|
|
||||||
loop do
|
|
||||||
case checks.state
|
|
||||||
when "success"
|
|
||||||
puts "Checks passed, see #{checks.first_status_url}"
|
|
||||||
exit 0
|
|
||||||
when "failure"
|
|
||||||
exit_with_error "Checks failed, see #{checks.first_status_url}"
|
|
||||||
when "pending"
|
|
||||||
attempts += 1
|
|
||||||
end
|
|
||||||
|
|
||||||
exit_with_error "Checks are still pending, gave up after #{MAX_ATTEMPTS * ATTEMPTS_GAP} seconds" if attempts == MAX_ATTEMPTS
|
|
||||||
|
|
||||||
puts checks.current_status
|
|
||||||
sleep(ATTEMPTS_GAP)
|
|
||||||
checks.refresh!
|
|
||||||
end
|
|
||||||
rescue Octokit::NotFound
|
|
||||||
exit_with_error "Build status could not be found"
|
|
||||||
end
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
|
|
||||||
echo "Rebooting kamal-proxy on $KAMAL_HOSTS..."
|
|
||||||
@@ -110,7 +110,6 @@ class ExpoPushChannel
|
|||||||
'tokens_count' => $tokens->count(),
|
'tokens_count' => $tokens->count(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
enum FollowStatus: string
|
||||||
|
{
|
||||||
|
case Pending = 'pending';
|
||||||
|
case Accepted = 'accepted';
|
||||||
|
case Rejected = 'rejected';
|
||||||
|
case Blocked = 'blocked';
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Filament\Support\Contracts\HasColor;
|
||||||
|
use Filament\Support\Contracts\HasLabel;
|
||||||
|
|
||||||
|
enum PaymentTransactionStatus: string implements HasColor, HasLabel
|
||||||
|
{
|
||||||
|
case PENDING = 'pending';
|
||||||
|
case PAID = 'paid';
|
||||||
|
case CREDITED = 'credited';
|
||||||
|
case FAILED = 'failed';
|
||||||
|
case IGNORED = 'ignored';
|
||||||
|
case ERROR = 'error';
|
||||||
|
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::PENDING => __('enums.payment_transaction_status.pending'),
|
||||||
|
self::PAID => __('enums.payment_transaction_status.paid'),
|
||||||
|
self::CREDITED => __('enums.payment_transaction_status.credited'),
|
||||||
|
self::FAILED => __('enums.payment_transaction_status.failed'),
|
||||||
|
self::IGNORED => __('enums.payment_transaction_status.ignored'),
|
||||||
|
self::ERROR => __('enums.payment_transaction_status.error'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::PENDING => 'gray',
|
||||||
|
self::PAID => 'info',
|
||||||
|
self::CREDITED => 'success',
|
||||||
|
self::FAILED, self::ERROR => 'danger',
|
||||||
|
self::IGNORED => 'warning',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Filament\Support\Contracts\HasColor;
|
||||||
|
use Filament\Support\Contracts\HasLabel;
|
||||||
|
|
||||||
|
enum PaymentTransactionType: string implements HasColor, HasLabel
|
||||||
|
{
|
||||||
|
case CHECKOUT = 'checkout';
|
||||||
|
case SUBSCRIPTION_INVOICE = 'subscription_invoice';
|
||||||
|
|
||||||
|
public function getLabel(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CHECKOUT => __('enums.payment_transaction_type.checkout'),
|
||||||
|
self::SUBSCRIPTION_INVOICE => __('enums.payment_transaction_type.subscription_invoice'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getColor(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::CHECKOUT => 'success',
|
||||||
|
self::SUBSCRIPTION_INVOICE => 'info',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,4 +8,9 @@ use Filament\Resources\Pages\CreateRecord;
|
|||||||
class CreateCreditProduct extends CreateRecord
|
class CreateCreditProduct extends CreateRecord
|
||||||
{
|
{
|
||||||
protected static string $resource = CreditProductResource::class;
|
protected static string $resource = CreditProductResource::class;
|
||||||
|
|
||||||
|
protected function afterCreate(): void
|
||||||
|
{
|
||||||
|
app(\App\Services\StripeCreditProductSyncer::class)->sync($this->record);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,4 +17,9 @@ class EditCreditProduct extends EditRecord
|
|||||||
DeleteAction::make(),
|
DeleteAction::make(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function afterSave(): void
|
||||||
|
{
|
||||||
|
app(\App\Services\StripeCreditProductSyncer::class)->sync($this->record);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ namespace App\Filament\Resources\CreditProducts\Schemas;
|
|||||||
|
|
||||||
use App\Enums\CreditProductType;
|
use App\Enums\CreditProductType;
|
||||||
use Filament\Forms\Components\Select;
|
use Filament\Forms\Components\Select;
|
||||||
use Filament\Forms\Components\TextInput;
|
|
||||||
use Filament\Forms\Components\Textarea;
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
use Filament\Forms\Components\Toggle;
|
use Filament\Forms\Components\Toggle;
|
||||||
use Filament\Schemas\Components\Section;
|
use Filament\Schemas\Components\Section;
|
||||||
use Filament\Schemas\Schema;
|
use Filament\Schemas\Schema;
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PaymentTransactions\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PaymentTransactions\PaymentTransactionResource;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListPaymentTransactions extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = PaymentTransactionResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PaymentTransactions\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PaymentTransactions\PaymentTransactionResource;
|
||||||
|
use Filament\Resources\Pages\ViewRecord;
|
||||||
|
|
||||||
|
class ViewPaymentTransaction extends ViewRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = PaymentTransactionResource::class;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PaymentTransactions;
|
||||||
|
|
||||||
|
use App\Filament\Resources\PaymentTransactions\Pages\ListPaymentTransactions;
|
||||||
|
use App\Filament\Resources\PaymentTransactions\Pages\ViewPaymentTransaction;
|
||||||
|
use App\Filament\Resources\PaymentTransactions\Schemas\PaymentTransactionInfolist;
|
||||||
|
use App\Filament\Resources\PaymentTransactions\Tables\PaymentTransactionsTable;
|
||||||
|
use App\Models\PaymentTransaction;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PaymentTransactionResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = PaymentTransaction::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedReceiptPercent;
|
||||||
|
|
||||||
|
protected static ?string $recordTitleAttribute = 'id';
|
||||||
|
|
||||||
|
public static function getNavigationLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.payment_transactions.navigation.label');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.payment_transactions.navigation.singular');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPluralModelLabel(): string
|
||||||
|
{
|
||||||
|
return __('admin.payment_transactions.navigation.plural');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canCreate(): bool
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function infolist(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return PaymentTransactionInfolist::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return PaymentTransactionsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListPaymentTransactions::route('/'),
|
||||||
|
'view' => ViewPaymentTransaction::route('/{record}'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PaymentTransactions\Schemas;
|
||||||
|
|
||||||
|
use Filament\Infolists\Components\TextEntry;
|
||||||
|
use Filament\Schemas\Components\Section;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class PaymentTransactionInfolist
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
Section::make(__('admin.payment_transactions.sections.summary'))
|
||||||
|
->columns(3)
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('id')
|
||||||
|
->label(__('admin.payment_transactions.fields.id'))
|
||||||
|
->copyable(),
|
||||||
|
TextEntry::make('status')
|
||||||
|
->label(__('admin.payment_transactions.fields.status'))
|
||||||
|
->badge(),
|
||||||
|
TextEntry::make('type')
|
||||||
|
->label(__('admin.payment_transactions.fields.type'))
|
||||||
|
->badge(),
|
||||||
|
TextEntry::make('user.email')
|
||||||
|
->label(__('admin.payment_transactions.fields.user'))
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty'))
|
||||||
|
->copyable(),
|
||||||
|
TextEntry::make('creditProduct.name')
|
||||||
|
->label(__('admin.payment_transactions.fields.credit_product'))
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('amount')
|
||||||
|
->label(__('admin.payment_transactions.fields.amount'))
|
||||||
|
->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100)
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('credits_expected')
|
||||||
|
->label(__('admin.payment_transactions.fields.credits_expected'))
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('credits_granted')
|
||||||
|
->label(__('admin.payment_transactions.fields.credits_granted')),
|
||||||
|
TextEntry::make('processed_at')
|
||||||
|
->label(__('admin.payment_transactions.fields.processed_at'))
|
||||||
|
->dateTime()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('invoice_email_sent_at')
|
||||||
|
->label(__('admin.payment_transactions.fields.invoice_email_sent_at'))
|
||||||
|
->dateTime()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
]),
|
||||||
|
Section::make(__('admin.payment_transactions.sections.stripe'))
|
||||||
|
->columns(2)
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('stripe_event_type')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_event_type'))
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('stripe_event_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_event_id'))
|
||||||
|
->copyable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('stripe_customer_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_customer_id'))
|
||||||
|
->copyable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('stripe_checkout_session_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_checkout_session_id'))
|
||||||
|
->copyable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('stripe_invoice_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_invoice_id'))
|
||||||
|
->copyable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('stripe_payment_intent_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_payment_intent_id'))
|
||||||
|
->copyable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('stripe_subscription_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_subscription_id'))
|
||||||
|
->copyable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('stripe_price_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_price_id'))
|
||||||
|
->copyable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('invoice_url')
|
||||||
|
->label(__('admin.payment_transactions.fields.invoice_url'))
|
||||||
|
->copyable()
|
||||||
|
->url(fn ($state): ?string => $state)
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextEntry::make('invoice_pdf_url')
|
||||||
|
->label(__('admin.payment_transactions.fields.invoice_pdf_url'))
|
||||||
|
->copyable()
|
||||||
|
->url(fn ($state): ?string => $state)
|
||||||
|
->openUrlInNewTab()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
]),
|
||||||
|
Section::make(__('admin.payment_transactions.sections.error'))
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('error_message')
|
||||||
|
->label(__('admin.payment_transactions.fields.error_message'))
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty'))
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
Section::make(__('admin.payment_transactions.sections.payload'))
|
||||||
|
->schema([
|
||||||
|
TextEntry::make('payload')
|
||||||
|
->label(__('admin.payment_transactions.fields.payload'))
|
||||||
|
->formatStateUsing(fn (mixed $state): string => json_encode($state ?: [], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}')
|
||||||
|
->fontFamily('mono')
|
||||||
|
->columnSpanFull(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\PaymentTransactions\Tables;
|
||||||
|
|
||||||
|
use App\Enums\PaymentTransactionStatus;
|
||||||
|
use App\Enums\PaymentTransactionType;
|
||||||
|
use Filament\Actions\ViewAction;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class PaymentTransactionsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->defaultSort('created_at', 'desc')
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->label(__('admin.payment_transactions.fields.created_at'))
|
||||||
|
->dateTime()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('status')
|
||||||
|
->label(__('admin.payment_transactions.fields.status'))
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('type')
|
||||||
|
->label(__('admin.payment_transactions.fields.type'))
|
||||||
|
->badge()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('user.email')
|
||||||
|
->label(__('admin.payment_transactions.fields.user'))
|
||||||
|
->searchable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextColumn::make('creditProduct.name')
|
||||||
|
->label(__('admin.payment_transactions.fields.credit_product'))
|
||||||
|
->searchable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextColumn::make('amount')
|
||||||
|
->label(__('admin.payment_transactions.fields.amount'))
|
||||||
|
->money(fn ($record): string => $record->currency ?: 'eur', divideBy: 100)
|
||||||
|
->sortable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextColumn::make('credits_expected')
|
||||||
|
->label(__('admin.payment_transactions.fields.credits_expected'))
|
||||||
|
->numeric(decimalPlaces: 0)
|
||||||
|
->sortable()
|
||||||
|
->placeholder(__('admin.payment_transactions.placeholders.empty')),
|
||||||
|
TextColumn::make('credits_granted')
|
||||||
|
->label(__('admin.payment_transactions.fields.credits_granted'))
|
||||||
|
->numeric(decimalPlaces: 0)
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('stripe_event_type')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_event_type'))
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('stripe_checkout_session_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_checkout_session_id_short'))
|
||||||
|
->copyable()
|
||||||
|
->searchable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('stripe_invoice_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_invoice_id_short'))
|
||||||
|
->copyable()
|
||||||
|
->searchable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('stripe_price_id')
|
||||||
|
->label(__('admin.payment_transactions.fields.stripe_price_id'))
|
||||||
|
->copyable()
|
||||||
|
->searchable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('error_message')
|
||||||
|
->label(__('admin.payment_transactions.fields.error_message'))
|
||||||
|
->limit(60)
|
||||||
|
->toggleable(),
|
||||||
|
TextColumn::make('invoice_email_sent_at')
|
||||||
|
->label(__('admin.payment_transactions.fields.invoice_email_sent_at'))
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('status')
|
||||||
|
->label(__('admin.payment_transactions.fields.status'))
|
||||||
|
->options(PaymentTransactionStatus::class),
|
||||||
|
SelectFilter::make('type')
|
||||||
|
->label(__('admin.payment_transactions.fields.type'))
|
||||||
|
->options(PaymentTransactionType::class),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
ViewAction::make(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,10 @@ namespace App\Http\Controllers;
|
|||||||
use App\Enums\CreditProductType;
|
use App\Enums\CreditProductType;
|
||||||
use App\Http\Resources\CreditProductResource;
|
use App\Http\Resources\CreditProductResource;
|
||||||
use App\Models\CreditProduct;
|
use App\Models\CreditProduct;
|
||||||
|
use App\Services\MobileDeepLink;
|
||||||
|
use App\Services\PaymentTransactionRecorder;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
@@ -23,8 +26,11 @@ class BillingController extends Controller
|
|||||||
return CreditProductResource::collection($products);
|
return CreditProductResource::collection($products);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function checkout(Request $request, CreditProduct $creditProduct): JsonResponse
|
public function checkout(
|
||||||
{
|
Request $request,
|
||||||
|
CreditProduct $creditProduct,
|
||||||
|
PaymentTransactionRecorder $transactions,
|
||||||
|
): JsonResponse {
|
||||||
abort_unless($creditProduct->is_active, 404);
|
abort_unless($creditProduct->is_active, 404);
|
||||||
abort_unless(filled($creditProduct->stripe_price_id), 404);
|
abort_unless(filled($creditProduct->stripe_price_id), 404);
|
||||||
|
|
||||||
@@ -44,11 +50,24 @@ class BillingController extends Controller
|
|||||||
];
|
];
|
||||||
|
|
||||||
if ($creditProduct->type === CreditProductType::MONTHLY) {
|
if ($creditProduct->type === CreditProductType::MONTHLY) {
|
||||||
|
if ($user->subscribed('default')) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('api.billing.active_subscription_exists'),
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
$checkout = $user
|
$checkout = $user
|
||||||
->newSubscription('default', $creditProduct->stripe_price_id)
|
->newSubscription('default', $creditProduct->stripe_price_id)
|
||||||
->withMetadata($metadata)
|
->withMetadata($metadata)
|
||||||
->checkout($sessionOptions);
|
->checkout($sessionOptions);
|
||||||
} else {
|
} else {
|
||||||
|
$sessionOptions['invoice_creation'] = [
|
||||||
|
'enabled' => true,
|
||||||
|
'invoice_data' => [
|
||||||
|
'metadata' => $metadata,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
$checkout = $user->checkout([
|
$checkout = $user->checkout([
|
||||||
$creditProduct->stripe_price_id => 1,
|
$creditProduct->stripe_price_id => 1,
|
||||||
], $sessionOptions);
|
], $sessionOptions);
|
||||||
@@ -56,6 +75,15 @@ class BillingController extends Controller
|
|||||||
|
|
||||||
$session = $checkout->asStripeCheckoutSession();
|
$session = $checkout->asStripeCheckoutSession();
|
||||||
|
|
||||||
|
$transactions->recordCheckoutSession(
|
||||||
|
user: $user,
|
||||||
|
product: $creditProduct,
|
||||||
|
stripeCheckoutSessionId: $session->id,
|
||||||
|
stripeCustomerId: $session->customer,
|
||||||
|
amount: $session->amount_total,
|
||||||
|
currency: $session->currency,
|
||||||
|
);
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'id' => $session->id,
|
'id' => $session->id,
|
||||||
'url' => $session->url,
|
'url' => $session->url,
|
||||||
@@ -67,6 +95,12 @@ class BillingController extends Controller
|
|||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$this->ensureStripeCustomer($user);
|
$this->ensureStripeCustomer($user);
|
||||||
|
|
||||||
|
if (! $user->subscribed('default')) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('api.billing.no_active_subscription'),
|
||||||
|
], 409);
|
||||||
|
}
|
||||||
|
|
||||||
$portalUrl = $user->billingPortalUrl($this->redirectUrl('portal'));
|
$portalUrl = $user->billingPortalUrl($this->redirectUrl('portal'));
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
@@ -74,6 +108,13 @@ class BillingController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function redirectToMobileApp(string $status): RedirectResponse
|
||||||
|
{
|
||||||
|
abort_unless(in_array($status, ['success', 'cancel', 'portal'], true), 404);
|
||||||
|
|
||||||
|
return redirect()->away(MobileDeepLink::to("billing/{$status}"));
|
||||||
|
}
|
||||||
|
|
||||||
private function ensureStripeCustomer(mixed $user): void
|
private function ensureStripeCustomer(mixed $user): void
|
||||||
{
|
{
|
||||||
if (! is_object($user) || ! method_exists($user, 'createOrGetStripeCustomer')) {
|
if (! is_object($user) || ! method_exists($user, 'createOrGetStripeCustomer')) {
|
||||||
@@ -85,8 +126,6 @@ class BillingController extends Controller
|
|||||||
|
|
||||||
private function redirectUrl(string $status): string
|
private function redirectUrl(string $status): string
|
||||||
{
|
{
|
||||||
$scheme = trim((string) config('app.mobile_scheme', env('APP_MOBILE_SCHEME', 'bowly')), ':/');
|
return route('billing.web-return', ['status' => $status]);
|
||||||
|
|
||||||
return "{$scheme}:///billing/{$status}";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\FollowStatus;
|
||||||
|
use App\Http\Requests\FollowRequest;
|
||||||
|
use App\Http\Resources\FollowResource;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class FollowController extends Controller
|
||||||
|
{
|
||||||
|
public function follow(FollowRequest $request, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$authUser = $request->user();
|
||||||
|
|
||||||
|
if ($authUser->id === $user->id) {
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('api.follows.cannot_follow_self')
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to Accepted as is_private doesn't exist in the DB yet
|
||||||
|
$status = FollowStatus::Accepted;
|
||||||
|
|
||||||
|
$authUser->following()->syncWithPivotValues([$user->id], ['status' => $status], false);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('api.follows.followed'),
|
||||||
|
'status' => $status,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unfollow(FollowRequest $request, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$request->user()->following()->detach($user->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => __('api.follows.unfollowed'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function accept(FollowRequest $request, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$updated = $request->user()->followers()->updateExistingPivot($user->id, [
|
||||||
|
'status' => FollowStatus::Accepted,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $updated ? __('api.follows.accepted') : __('api.follows.not_found'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function reject(FollowRequest $request, User $user): JsonResponse
|
||||||
|
{
|
||||||
|
$detached = $request->user()->followers()->detach($user->id);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'message' => $detached ? __('api.follows.rejected') : __('api.follows.not_found'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function followers(Request $request, User $user): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->id === $user->id, 403);
|
||||||
|
|
||||||
|
$perPage = max(1, min($request->integer('per_page', 15), 100));
|
||||||
|
|
||||||
|
$followers = $user->followers()
|
||||||
|
->wherePivot('status', FollowStatus::Accepted)
|
||||||
|
->paginate($perPage);
|
||||||
|
|
||||||
|
return FollowResource::collection($followers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function following(Request $request, User $user): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
abort_unless($request->user()->id === $user->id, 403);
|
||||||
|
|
||||||
|
$perPage = max(1, min($request->integer('per_page', 15), 100));
|
||||||
|
|
||||||
|
$following = $user->following()
|
||||||
|
->wherePivot('status', FollowStatus::Accepted)
|
||||||
|
->paginate($perPage);
|
||||||
|
|
||||||
|
return FollowResource::collection($following);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,8 +22,7 @@ class MealImageAnalysisController extends Controller
|
|||||||
MealImageAnalysisRequest $request,
|
MealImageAnalysisRequest $request,
|
||||||
AiCreditService $aiCredits,
|
AiCreditService $aiCredits,
|
||||||
AiUsageRecorder $aiUsageRecorder,
|
AiUsageRecorder $aiUsageRecorder,
|
||||||
): JsonResponse
|
): JsonResponse {
|
||||||
{
|
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$image = $request->file('image');
|
$image = $request->file('image');
|
||||||
$input = $this->analysisInput($image);
|
$input = $this->analysisInput($image);
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ class MealPostController extends Controller
|
|||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
if ($request->hasFile('image')) {
|
if ($request->hasFile('image')) {
|
||||||
$path = $request->file('image')->store('meal-posts', );
|
$path = $request->file('image')->store('meal-posts');
|
||||||
$data['image_url'] = $path;
|
$data['image_url'] = $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Enums\FollowStatus;
|
||||||
use App\Enums\MealPostVisibility;
|
use App\Enums\MealPostVisibility;
|
||||||
use App\Http\Resources\MealPostsResource;
|
use App\Http\Resources\MealPostsResource;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
@@ -39,7 +40,19 @@ class PublicUserProfileController extends Controller
|
|||||||
->where('visibility', MealPostVisibility::Public->value)
|
->where('visibility', MealPostVisibility::Public->value)
|
||||||
->whereNull('hidden_at')
|
->whereNull('hidden_at')
|
||||||
->count(),
|
->count(),
|
||||||
|
'followersCount' => $user->followers()
|
||||||
|
->wherePivot('status', FollowStatus::Accepted)
|
||||||
|
->count(),
|
||||||
|
'followingCount' => $user->following()
|
||||||
|
->wherePivot('status', FollowStatus::Accepted)
|
||||||
|
->count(),
|
||||||
],
|
],
|
||||||
|
'isFollowing' => $request->user()
|
||||||
|
? $request->user()->following()
|
||||||
|
->where('following_id', $user->id)
|
||||||
|
->whereIn('status', [FollowStatus::Accepted, FollowStatus::Pending])
|
||||||
|
->exists()
|
||||||
|
: false,
|
||||||
'meals' => MealPostsResource::collection($meals)->resolve($request),
|
'meals' => MealPostsResource::collection($meals)->resolve($request),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Http\Controllers;
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
use App\Http\Requests\WorkoutSessionsRequest;
|
|
||||||
use App\Http\Requests\WorkoutSessionsCalendarRequest;
|
use App\Http\Requests\WorkoutSessionsCalendarRequest;
|
||||||
|
use App\Http\Requests\WorkoutSessionsRequest;
|
||||||
use App\Http\Resources\WorkoutSessionsResource;
|
use App\Http\Resources\WorkoutSessionsResource;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class FollowRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// No specific body rules for now as we use route model binding
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
|
||||||
|
class FollowResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Transform the resource into an array.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->name,
|
||||||
|
'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ class UserResource extends JsonResource
|
|||||||
'role' => $this->role,
|
'role' => $this->role,
|
||||||
'accountVerified' => $this->account_verified_at !== null,
|
'accountVerified' => $this->account_verified_at !== null,
|
||||||
'aiCreditsBalance' => (int) $this->ai_credits_balance,
|
'aiCreditsBalance' => (int) $this->ai_credits_balance,
|
||||||
|
'hasActiveSubscription' => $this->subscribed('default'),
|
||||||
'suspendedAt' => $this->suspended_at?->toISOString(),
|
'suspendedAt' => $this->suspended_at?->toISOString(),
|
||||||
'physicalActivityLevel' => $this->physical_activity_level,
|
'physicalActivityLevel' => $this->physical_activity_level,
|
||||||
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),
|
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),
|
||||||
|
|||||||
@@ -4,13 +4,21 @@ namespace App\Listeners;
|
|||||||
|
|
||||||
use App\Enums\CreditLedgerEntryType;
|
use App\Enums\CreditLedgerEntryType;
|
||||||
use App\Enums\CreditProductType;
|
use App\Enums\CreditProductType;
|
||||||
|
use App\Models\CreditLedgerEntry;
|
||||||
use App\Models\CreditProduct;
|
use App\Models\CreditProduct;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use App\Services\AiCreditService;
|
use App\Services\AiCreditService;
|
||||||
|
use App\Services\PaymentInvoiceEmailer;
|
||||||
|
use App\Services\PaymentTransactionRecorder;
|
||||||
|
use Laravel\Cashier\Cashier;
|
||||||
|
|
||||||
class GrantCreditsFromStripeWebhook
|
class GrantCreditsFromStripeWebhook
|
||||||
{
|
{
|
||||||
public function __construct(private AiCreditService $credits) {}
|
public function __construct(
|
||||||
|
private AiCreditService $credits,
|
||||||
|
private PaymentInvoiceEmailer $invoiceEmails,
|
||||||
|
private PaymentTransactionRecorder $transactions,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array{payload: array<string, mixed>} $event
|
* @param array{payload: array<string, mixed>} $event
|
||||||
@@ -21,7 +29,10 @@ class GrantCreditsFromStripeWebhook
|
|||||||
|
|
||||||
match ($payload['type'] ?? null) {
|
match ($payload['type'] ?? null) {
|
||||||
'checkout.session.completed' => $this->handleCheckoutSessionCompleted($payload),
|
'checkout.session.completed' => $this->handleCheckoutSessionCompleted($payload),
|
||||||
|
'checkout.session.expired' => $this->handleCheckoutSessionExpired($payload),
|
||||||
'invoice.paid' => $this->handleInvoicePaid($payload),
|
'invoice.paid' => $this->handleInvoicePaid($payload),
|
||||||
|
'invoice.payment_succeeded' => $this->handleInvoicePaid($payload),
|
||||||
|
'invoice.payment_failed' => $this->handleInvoicePaymentFailed($payload),
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -32,8 +43,30 @@ class GrantCreditsFromStripeWebhook
|
|||||||
private function handleCheckoutSessionCompleted(array $payload): void
|
private function handleCheckoutSessionCompleted(array $payload): void
|
||||||
{
|
{
|
||||||
$session = $payload['data']['object'] ?? [];
|
$session = $payload['data']['object'] ?? [];
|
||||||
|
$eventId = $this->stringValue($payload['id'] ?? null);
|
||||||
|
$eventType = $this->stringValue($payload['type'] ?? null);
|
||||||
|
$sessionId = $this->stringValue($session['id'] ?? null);
|
||||||
|
|
||||||
|
if (! $sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (($session['mode'] ?? null) !== 'payment' || ($session['payment_status'] ?? null) !== 'paid') {
|
if (($session['mode'] ?? null) !== 'payment' || ($session['payment_status'] ?? null) !== 'paid') {
|
||||||
|
$this->transactions->recordCheckoutSession(
|
||||||
|
user: $this->userFromCustomer($session['customer'] ?? null),
|
||||||
|
product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME),
|
||||||
|
stripeCheckoutSessionId: $sessionId,
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::IGNORED,
|
||||||
|
stripeEventId: $eventId,
|
||||||
|
stripeEventType: $eventType,
|
||||||
|
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||||
|
amount: $this->intValue($session['amount_total'] ?? null),
|
||||||
|
currency: $this->stringValue($session['currency'] ?? null),
|
||||||
|
errorMessage: 'Checkout session is not a paid one-time payment.',
|
||||||
|
payload: $session,
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,21 +74,90 @@ class GrantCreditsFromStripeWebhook
|
|||||||
$user = $this->userFromCustomer($session['customer'] ?? null);
|
$user = $this->userFromCustomer($session['customer'] ?? null);
|
||||||
|
|
||||||
if (! $product || ! $user) {
|
if (! $product || ! $user) {
|
||||||
|
$this->transactions->recordCheckoutSession(
|
||||||
|
user: $user,
|
||||||
|
product: $product,
|
||||||
|
stripeCheckoutSessionId: $sessionId,
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::ERROR,
|
||||||
|
stripeEventId: $eventId,
|
||||||
|
stripeEventType: $eventType,
|
||||||
|
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||||
|
amount: $this->intValue($session['amount_total'] ?? null),
|
||||||
|
currency: $this->stringValue($session['currency'] ?? null),
|
||||||
|
errorMessage: $product
|
||||||
|
? 'Stripe customer does not match any user.'
|
||||||
|
: 'Credit product was not found from checkout metadata.',
|
||||||
|
payload: $session,
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->credits->grant(
|
$source = 'stripe_checkout_session:'.$sessionId;
|
||||||
|
$entry = $this->credits->grant(
|
||||||
user: $user,
|
user: $user,
|
||||||
credits: $product->credits,
|
credits: $product->credits,
|
||||||
type: CreditLedgerEntryType::PURCHASE,
|
type: CreditLedgerEntryType::PURCHASE,
|
||||||
product: $product,
|
product: $product,
|
||||||
source: 'stripe_checkout_session:'.$session['id'],
|
source: $source,
|
||||||
stripeCheckoutSessionId: $session['id'] ?? null,
|
stripeCheckoutSessionId: $sessionId,
|
||||||
stripePaymentIntentId: $session['payment_intent'] ?? null,
|
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||||
metadata: [
|
metadata: [
|
||||||
'stripe_event_id' => $payload['id'] ?? null,
|
'stripe_event_id' => $eventId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$entry ??= CreditLedgerEntry::query()->where('source', $source)->first();
|
||||||
|
$invoice = $this->retrieveInvoice($this->stringValue($session['invoice'] ?? null));
|
||||||
|
|
||||||
|
$transaction = $this->transactions->recordCheckoutSession(
|
||||||
|
user: $user,
|
||||||
|
product: $product,
|
||||||
|
stripeCheckoutSessionId: $sessionId,
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::CREDITED,
|
||||||
|
stripeEventId: $eventId,
|
||||||
|
stripeEventType: $eventType,
|
||||||
|
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||||
|
amount: $this->intValue($session['amount_total'] ?? null),
|
||||||
|
currency: $this->stringValue($session['currency'] ?? null),
|
||||||
|
creditsGranted: $entry ? $product->credits : 0,
|
||||||
|
ledgerEntry: $entry,
|
||||||
|
invoiceUrl: $invoice ? $this->stringValue($invoice['hosted_invoice_url'] ?? null) : null,
|
||||||
|
invoicePdfUrl: $invoice ? $this->stringValue($invoice['invoice_pdf'] ?? null) : null,
|
||||||
|
payload: $session,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invoiceEmails->sendIfAvailable($transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function handleCheckoutSessionExpired(array $payload): void
|
||||||
|
{
|
||||||
|
$session = $payload['data']['object'] ?? [];
|
||||||
|
$sessionId = $this->stringValue($session['id'] ?? null);
|
||||||
|
|
||||||
|
if (! $sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->transactions->recordCheckoutSession(
|
||||||
|
user: $this->userFromCustomer($session['customer'] ?? null),
|
||||||
|
product: $this->productFromMetadata($session['metadata'] ?? null, CreditProductType::ONE_TIME),
|
||||||
|
stripeCheckoutSessionId: $sessionId,
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::FAILED,
|
||||||
|
stripeEventId: $this->stringValue($payload['id'] ?? null),
|
||||||
|
stripeEventType: $this->stringValue($payload['type'] ?? null),
|
||||||
|
stripeCustomerId: $this->stringValue($session['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($session['payment_intent'] ?? null),
|
||||||
|
amount: $this->intValue($session['amount_total'] ?? null),
|
||||||
|
currency: $this->stringValue($session['currency'] ?? null),
|
||||||
|
errorMessage: 'Checkout session expired before payment.',
|
||||||
|
payload: $session,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -64,12 +166,39 @@ class GrantCreditsFromStripeWebhook
|
|||||||
private function handleInvoicePaid(array $payload): void
|
private function handleInvoicePaid(array $payload): void
|
||||||
{
|
{
|
||||||
$invoice = $payload['data']['object'] ?? [];
|
$invoice = $payload['data']['object'] ?? [];
|
||||||
|
$invoiceId = $this->stringValue($invoice['id'] ?? null);
|
||||||
|
$eventId = $this->stringValue($payload['id'] ?? null);
|
||||||
|
$eventType = $this->stringValue($payload['type'] ?? null);
|
||||||
|
|
||||||
|
if (! $invoiceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
$user = $this->userFromCustomer($invoice['customer'] ?? null);
|
$user = $this->userFromCustomer($invoice['customer'] ?? null);
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
|
$this->transactions->recordInvoice(
|
||||||
|
user: null,
|
||||||
|
product: null,
|
||||||
|
stripeInvoiceId: $invoiceId,
|
||||||
|
stripePriceId: null,
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::ERROR,
|
||||||
|
stripeEventId: $eventId,
|
||||||
|
stripeEventType: $eventType,
|
||||||
|
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||||
|
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||||
|
amount: $this->intValue($invoice['amount_paid'] ?? null),
|
||||||
|
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||||
|
errorMessage: 'Stripe customer does not match any user.',
|
||||||
|
payload: $invoice,
|
||||||
|
);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$matchedLine = false;
|
||||||
|
|
||||||
foreach (($invoice['lines']['data'] ?? []) as $line) {
|
foreach (($invoice['lines']['data'] ?? []) as $line) {
|
||||||
$priceId = $line['price']['id'] ?? null;
|
$priceId = $line['price']['id'] ?? null;
|
||||||
|
|
||||||
@@ -83,23 +212,125 @@ class GrantCreditsFromStripeWebhook
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $product) {
|
if (! $product) {
|
||||||
|
$this->transactions->recordInvoice(
|
||||||
|
user: $user,
|
||||||
|
product: null,
|
||||||
|
stripeInvoiceId: $invoiceId,
|
||||||
|
stripePriceId: $this->stringValue($priceId),
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::ERROR,
|
||||||
|
stripeEventId: $eventId,
|
||||||
|
stripeEventType: $eventType,
|
||||||
|
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||||
|
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||||
|
amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null),
|
||||||
|
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||||
|
errorMessage: 'No monthly credit product found for Stripe price.',
|
||||||
|
payload: $invoice,
|
||||||
|
);
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->credits->grant(
|
$matchedLine = true;
|
||||||
|
$source = 'stripe_invoice:'.$invoiceId.':price:'.$priceId;
|
||||||
|
$entry = $this->credits->grant(
|
||||||
user: $user,
|
user: $user,
|
||||||
credits: $product->credits,
|
credits: $product->credits,
|
||||||
type: CreditLedgerEntryType::SUBSCRIPTION_RENEWAL,
|
type: CreditLedgerEntryType::SUBSCRIPTION_RENEWAL,
|
||||||
product: $product,
|
product: $product,
|
||||||
source: 'stripe_invoice:'.$invoice['id'].':price:'.$priceId,
|
source: $source,
|
||||||
stripeInvoiceId: $invoice['id'] ?? null,
|
stripeInvoiceId: $invoiceId,
|
||||||
stripePaymentIntentId: $invoice['payment_intent'] ?? null,
|
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||||
stripeSubscriptionId: $invoice['subscription'] ?? null,
|
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||||
metadata: [
|
metadata: [
|
||||||
'billing_reason' => $invoice['billing_reason'] ?? null,
|
'billing_reason' => $invoice['billing_reason'] ?? null,
|
||||||
'stripe_event_id' => $payload['id'] ?? null,
|
'stripe_event_id' => $eventId,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$entry ??= CreditLedgerEntry::query()->where('source', $source)->first();
|
||||||
|
|
||||||
|
$transaction = $this->transactions->recordInvoice(
|
||||||
|
user: $user,
|
||||||
|
product: $product,
|
||||||
|
stripeInvoiceId: $invoiceId,
|
||||||
|
stripePriceId: $this->stringValue($priceId),
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::CREDITED,
|
||||||
|
stripeEventId: $eventId,
|
||||||
|
stripeEventType: $eventType,
|
||||||
|
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||||
|
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||||
|
amount: $this->intValue($line['amount'] ?? $invoice['amount_paid'] ?? null),
|
||||||
|
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||||
|
creditsGranted: $entry ? $product->credits : 0,
|
||||||
|
ledgerEntry: $entry,
|
||||||
|
invoiceUrl: $this->stringValue($invoice['hosted_invoice_url'] ?? null),
|
||||||
|
invoicePdfUrl: $this->stringValue($invoice['invoice_pdf'] ?? null),
|
||||||
|
payload: $invoice,
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->invoiceEmails->sendIfAvailable($transaction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $matchedLine) {
|
||||||
|
$this->transactions->recordInvoice(
|
||||||
|
user: $user,
|
||||||
|
product: null,
|
||||||
|
stripeInvoiceId: $invoiceId,
|
||||||
|
stripePriceId: null,
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::IGNORED,
|
||||||
|
stripeEventId: $eventId,
|
||||||
|
stripeEventType: $eventType,
|
||||||
|
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||||
|
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||||
|
amount: $this->intValue($invoice['amount_paid'] ?? null),
|
||||||
|
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||||
|
errorMessage: 'Invoice does not contain a monthly credit product line.',
|
||||||
|
payload: $invoice,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
private function handleInvoicePaymentFailed(array $payload): void
|
||||||
|
{
|
||||||
|
$invoice = $payload['data']['object'] ?? [];
|
||||||
|
$invoiceId = $this->stringValue($invoice['id'] ?? null);
|
||||||
|
|
||||||
|
if (! $invoiceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (($invoice['lines']['data'] ?? [null]) as $line) {
|
||||||
|
$priceId = is_array($line) ? $this->stringValue($line['price']['id'] ?? null) : null;
|
||||||
|
$amount = is_array($line)
|
||||||
|
? $this->intValue($line['amount'] ?? $invoice['amount_due'] ?? null)
|
||||||
|
: $this->intValue($invoice['amount_due'] ?? null);
|
||||||
|
$product = $priceId
|
||||||
|
? CreditProduct::query()->where('stripe_price_id', $priceId)->first()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
$this->transactions->recordInvoice(
|
||||||
|
user: $this->userFromCustomer($invoice['customer'] ?? null),
|
||||||
|
product: $product,
|
||||||
|
stripeInvoiceId: $invoiceId,
|
||||||
|
stripePriceId: $priceId,
|
||||||
|
status: \App\Enums\PaymentTransactionStatus::FAILED,
|
||||||
|
stripeEventId: $this->stringValue($payload['id'] ?? null),
|
||||||
|
stripeEventType: $this->stringValue($payload['type'] ?? null),
|
||||||
|
stripeCustomerId: $this->stringValue($invoice['customer'] ?? null),
|
||||||
|
stripePaymentIntentId: $this->stringValue($invoice['payment_intent'] ?? null),
|
||||||
|
stripeSubscriptionId: $this->stringValue($invoice['subscription'] ?? null),
|
||||||
|
amount: $amount,
|
||||||
|
currency: $this->stringValue($invoice['currency'] ?? null),
|
||||||
|
errorMessage: 'Invoice payment failed.',
|
||||||
|
payload: $invoice,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,4 +354,33 @@ class GrantCreditsFromStripeWebhook
|
|||||||
->where('type', $type)
|
->where('type', $type)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function stringValue(mixed $value): ?string
|
||||||
|
{
|
||||||
|
return is_string($value) && $value !== '' ? $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function intValue(mixed $value): ?int
|
||||||
|
{
|
||||||
|
return is_numeric($value) ? (int) $value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
private function retrieveInvoice(?string $invoiceId): ?array
|
||||||
|
{
|
||||||
|
if (! $invoiceId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return Cashier::stripe()
|
||||||
|
->invoices
|
||||||
|
->retrieve($invoiceId)
|
||||||
|
->toArray();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Mail;
|
||||||
|
|
||||||
|
use App\Models\PaymentTransaction;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class PaymentInvoiceMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public User $user,
|
||||||
|
public PaymentTransaction $transaction,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(
|
||||||
|
subject: trans('mail.payment_invoice.subject', [], $this->mailLocale()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
return new Content(
|
||||||
|
view: 'emails.payment-invoice',
|
||||||
|
with: [
|
||||||
|
'amount' => $this->formattedAmount(),
|
||||||
|
'credits' => $this->transaction->credits_granted ?: $this->transaction->credits_expected,
|
||||||
|
'invoicePdfUrl' => $this->transaction->invoice_pdf_url,
|
||||||
|
'invoiceUrl' => $this->transaction->invoice_url,
|
||||||
|
'locale' => $this->mailLocale(),
|
||||||
|
'productName' => $this->transaction->creditProduct?->name,
|
||||||
|
'userName' => $this->user->name,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachments(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function formattedAmount(): string
|
||||||
|
{
|
||||||
|
if ($this->transaction->amount === null) {
|
||||||
|
return trans('mail.payment_invoice.unknown_amount', [], $this->mailLocale());
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format($this->transaction->amount / 100, 2, ',', ' ')
|
||||||
|
.' '
|
||||||
|
.strtoupper($this->transaction->currency ?: 'eur');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mailLocale(): string
|
||||||
|
{
|
||||||
|
return $this->user->preferredLocale() ?? 'en';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,6 +32,11 @@ class CreditProduct extends Model
|
|||||||
return $this->hasMany(CreditLedgerEntry::class);
|
return $this->hasMany(CreditLedgerEntry::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function paymentTransactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PaymentTransaction::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function scopeActive(Builder $query): Builder
|
public function scopeActive(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('is_active', true);
|
return $query->where('is_active', true);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\FollowStatus;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class Follow extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'following_id',
|
||||||
|
'follower_id',
|
||||||
|
'status',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'status' => FollowStatus::class,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function follower(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'follower_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function following(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'following_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use App\Enums\PaymentTransactionStatus;
|
||||||
|
use App\Enums\PaymentTransactionType;
|
||||||
|
use Illuminate\Database\Eloquent\Concerns\HasUlids;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class PaymentTransaction extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\PaymentTransactionFactory> */
|
||||||
|
use HasFactory, HasUlids;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'user_id',
|
||||||
|
'credit_product_id',
|
||||||
|
'credit_ledger_entry_id',
|
||||||
|
'type',
|
||||||
|
'status',
|
||||||
|
'stripe_event_id',
|
||||||
|
'stripe_event_type',
|
||||||
|
'stripe_customer_id',
|
||||||
|
'stripe_checkout_session_id',
|
||||||
|
'stripe_invoice_id',
|
||||||
|
'stripe_payment_intent_id',
|
||||||
|
'stripe_subscription_id',
|
||||||
|
'stripe_price_id',
|
||||||
|
'amount',
|
||||||
|
'currency',
|
||||||
|
'credits_expected',
|
||||||
|
'credits_granted',
|
||||||
|
'invoice_url',
|
||||||
|
'invoice_pdf_url',
|
||||||
|
'invoice_email_sent_at',
|
||||||
|
'error_message',
|
||||||
|
'payload',
|
||||||
|
'processed_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creditProduct(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CreditProduct::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function creditLedgerEntry(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(CreditLedgerEntry::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'type' => PaymentTransactionType::class,
|
||||||
|
'status' => PaymentTransactionStatus::class,
|
||||||
|
'amount' => 'integer',
|
||||||
|
'credits_expected' => 'integer',
|
||||||
|
'credits_granted' => 'integer',
|
||||||
|
'invoice_email_sent_at' => 'datetime',
|
||||||
|
'payload' => 'array',
|
||||||
|
'processed_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,6 +124,11 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
|||||||
return $this->hasMany(CreditLedgerEntry::class);
|
return $this->hasMany(CreditLedgerEntry::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function paymentTransactions(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(PaymentTransaction::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function workouts(): HasMany
|
public function workouts(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(WorkoutSessions::class);
|
return $this->hasMany(WorkoutSessions::class);
|
||||||
@@ -144,6 +149,20 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasLocale
|
|||||||
return $this->morphMany(Report::class, 'reportable');
|
return $this->morphMany(Report::class, 'reportable');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function followers(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(self::class, 'follows', 'following_id', 'follower_id')
|
||||||
|
->withPivot('status')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function following(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(self::class, 'follows', 'follower_id', 'following_id')
|
||||||
|
->withPivot('status')
|
||||||
|
->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
public function moderationCase(): MorphOne
|
public function moderationCase(): MorphOne
|
||||||
{
|
{
|
||||||
return $this->morphOne(ModerationCase::class, 'caseable');
|
return $this->morphOne(ModerationCase::class, 'caseable');
|
||||||
|
|||||||
@@ -7,12 +7,15 @@ use App\Listeners\GrantCreditsFromStripeWebhook;
|
|||||||
use App\Mail\ResetPasswordMail;
|
use App\Mail\ResetPasswordMail;
|
||||||
use App\Mail\VerifyAccount;
|
use App\Mail\VerifyAccount;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use Dedoc\Scramble\Scramble;
|
||||||
|
use Dedoc\Scramble\Support\Generator\OpenApi;
|
||||||
|
use Dedoc\Scramble\Support\Generator\SecurityScheme;
|
||||||
use Illuminate\Auth\Notifications\ResetPassword;
|
use Illuminate\Auth\Notifications\ResetPassword;
|
||||||
use Illuminate\Auth\Notifications\VerifyEmail;
|
use Illuminate\Auth\Notifications\VerifyEmail;
|
||||||
use Illuminate\Cache\RateLimiting\Limit;
|
use Illuminate\Cache\RateLimiting\Limit;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Gate;
|
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\Notification;
|
use Illuminate\Support\Facades\Notification;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
@@ -45,6 +48,12 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
{
|
{
|
||||||
$this->configureRateLimiting();
|
$this->configureRateLimiting();
|
||||||
|
|
||||||
|
Scramble::afterOpenApiGenerated(function (OpenApi $openApi) {
|
||||||
|
$openApi->secure(
|
||||||
|
SecurityScheme::http('bearer')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if ($this->app->environment('production')) {
|
if ($this->app->environment('production')) {
|
||||||
URL::forceScheme('https');
|
URL::forceScheme('https');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Mail\PaymentInvoiceMail;
|
||||||
|
use App\Models\PaymentTransaction;
|
||||||
|
use Illuminate\Support\Facades\Mail;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class PaymentInvoiceEmailer
|
||||||
|
{
|
||||||
|
public function sendIfAvailable(PaymentTransaction $transaction): void
|
||||||
|
{
|
||||||
|
if ($transaction->invoice_email_sent_at !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $transaction->user || ! $transaction->user->email) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $transaction->invoice_url && ! $transaction->invoice_pdf_url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
Mail::to($transaction->user->email)->send(
|
||||||
|
new PaymentInvoiceMail($transaction->user, $transaction->loadMissing('creditProduct'))
|
||||||
|
);
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$transaction->forceFill([
|
||||||
|
'error_message' => 'Invoice email failed: '.$exception->getMessage(),
|
||||||
|
])->save();
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$transaction->forceFill([
|
||||||
|
'invoice_email_sent_at' => now(),
|
||||||
|
])->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Services;
|
||||||
|
|
||||||
|
use App\Enums\PaymentTransactionStatus;
|
||||||
|
use App\Enums\PaymentTransactionType;
|
||||||
|
use App\Models\CreditLedgerEntry;
|
||||||
|
use App\Models\CreditProduct;
|
||||||
|
use App\Models\PaymentTransaction;
|
||||||
|
use App\Models\User;
|
||||||
|
|
||||||
|
class PaymentTransactionRecorder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $payload
|
||||||
|
*/
|
||||||
|
public function recordCheckoutSession(
|
||||||
|
?User $user,
|
||||||
|
?CreditProduct $product,
|
||||||
|
string $stripeCheckoutSessionId,
|
||||||
|
PaymentTransactionStatus $status = PaymentTransactionStatus::PENDING,
|
||||||
|
?string $stripeEventId = null,
|
||||||
|
?string $stripeEventType = null,
|
||||||
|
?string $stripeCustomerId = null,
|
||||||
|
?string $stripePaymentIntentId = null,
|
||||||
|
?int $amount = null,
|
||||||
|
?string $currency = null,
|
||||||
|
?int $creditsExpected = null,
|
||||||
|
?int $creditsGranted = null,
|
||||||
|
?CreditLedgerEntry $ledgerEntry = null,
|
||||||
|
?string $invoiceUrl = null,
|
||||||
|
?string $invoicePdfUrl = null,
|
||||||
|
?string $errorMessage = null,
|
||||||
|
?array $payload = null,
|
||||||
|
): PaymentTransaction {
|
||||||
|
return PaymentTransaction::query()->updateOrCreate([
|
||||||
|
'stripe_checkout_session_id' => $stripeCheckoutSessionId,
|
||||||
|
], [
|
||||||
|
'user_id' => $user?->getKey(),
|
||||||
|
'credit_product_id' => $product?->getKey(),
|
||||||
|
'credit_ledger_entry_id' => $ledgerEntry?->getKey(),
|
||||||
|
'type' => PaymentTransactionType::CHECKOUT,
|
||||||
|
'status' => $status,
|
||||||
|
'stripe_event_id' => $stripeEventId,
|
||||||
|
'stripe_event_type' => $stripeEventType,
|
||||||
|
'stripe_customer_id' => $stripeCustomerId,
|
||||||
|
'stripe_payment_intent_id' => $stripePaymentIntentId,
|
||||||
|
'amount' => $amount,
|
||||||
|
'currency' => $currency,
|
||||||
|
'credits_expected' => $creditsExpected ?? $product?->credits,
|
||||||
|
'credits_granted' => $creditsGranted ?? 0,
|
||||||
|
'invoice_url' => $invoiceUrl,
|
||||||
|
'invoice_pdf_url' => $invoicePdfUrl,
|
||||||
|
'error_message' => $errorMessage,
|
||||||
|
'payload' => $payload,
|
||||||
|
'processed_at' => $status === PaymentTransactionStatus::PENDING ? null : now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $payload
|
||||||
|
*/
|
||||||
|
public function recordInvoice(
|
||||||
|
?User $user,
|
||||||
|
?CreditProduct $product,
|
||||||
|
string $stripeInvoiceId,
|
||||||
|
?string $stripePriceId,
|
||||||
|
PaymentTransactionStatus $status,
|
||||||
|
?string $stripeEventId = null,
|
||||||
|
?string $stripeEventType = null,
|
||||||
|
?string $stripeCustomerId = null,
|
||||||
|
?string $stripePaymentIntentId = null,
|
||||||
|
?string $stripeSubscriptionId = null,
|
||||||
|
?int $amount = null,
|
||||||
|
?string $currency = null,
|
||||||
|
?int $creditsExpected = null,
|
||||||
|
?int $creditsGranted = null,
|
||||||
|
?CreditLedgerEntry $ledgerEntry = null,
|
||||||
|
?string $invoiceUrl = null,
|
||||||
|
?string $invoicePdfUrl = null,
|
||||||
|
?string $errorMessage = null,
|
||||||
|
?array $payload = null,
|
||||||
|
): PaymentTransaction {
|
||||||
|
return PaymentTransaction::query()->updateOrCreate([
|
||||||
|
'stripe_invoice_id' => $stripeInvoiceId,
|
||||||
|
'stripe_price_id' => $stripePriceId,
|
||||||
|
], [
|
||||||
|
'user_id' => $user?->getKey(),
|
||||||
|
'credit_product_id' => $product?->getKey(),
|
||||||
|
'credit_ledger_entry_id' => $ledgerEntry?->getKey(),
|
||||||
|
'type' => PaymentTransactionType::SUBSCRIPTION_INVOICE,
|
||||||
|
'status' => $status,
|
||||||
|
'stripe_event_id' => $stripeEventId,
|
||||||
|
'stripe_event_type' => $stripeEventType,
|
||||||
|
'stripe_customer_id' => $stripeCustomerId,
|
||||||
|
'stripe_payment_intent_id' => $stripePaymentIntentId,
|
||||||
|
'stripe_subscription_id' => $stripeSubscriptionId,
|
||||||
|
'amount' => $amount,
|
||||||
|
'currency' => $currency,
|
||||||
|
'credits_expected' => $creditsExpected ?? $product?->credits,
|
||||||
|
'credits_granted' => $creditsGranted ?? 0,
|
||||||
|
'invoice_url' => $invoiceUrl,
|
||||||
|
'invoice_pdf_url' => $invoicePdfUrl,
|
||||||
|
'error_message' => $errorMessage,
|
||||||
|
'payload' => $payload,
|
||||||
|
'processed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,9 +25,30 @@ class StripeCreditProductSyncer
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$stripeProductId = $stripeProduct->id;
|
$stripeProductId = $stripeProduct->id;
|
||||||
|
} else {
|
||||||
|
$stripe->products->update($stripeProductId, [
|
||||||
|
'name' => $product->name,
|
||||||
|
'description' => $product->description,
|
||||||
|
'active' => (bool) $product->is_active,
|
||||||
|
'metadata' => [
|
||||||
|
'credit_product_id' => $product->getKey(),
|
||||||
|
'credits' => (string) $product->credits,
|
||||||
|
'type' => $product->type->value,
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$needsNewPrice = false;
|
||||||
|
|
||||||
if (! $product->stripe_price_id) {
|
if (! $product->stripe_price_id) {
|
||||||
|
$needsNewPrice = true;
|
||||||
|
} elseif ($product->wasChanged(['amount', 'currency', 'type', 'credits'])) {
|
||||||
|
$needsNewPrice = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stripePriceId = $product->stripe_price_id;
|
||||||
|
|
||||||
|
if ($needsNewPrice) {
|
||||||
$priceData = [
|
$priceData = [
|
||||||
'product' => $stripeProductId,
|
'product' => $stripeProductId,
|
||||||
'unit_amount' => $product->amount,
|
'unit_amount' => $product->amount,
|
||||||
@@ -46,12 +67,26 @@ class StripeCreditProductSyncer
|
|||||||
}
|
}
|
||||||
|
|
||||||
$stripePrice = $stripe->prices->create($priceData);
|
$stripePrice = $stripe->prices->create($priceData);
|
||||||
|
$stripePriceId = $stripePrice->id;
|
||||||
|
|
||||||
|
// Archive the old price if it exists
|
||||||
|
if ($product->stripe_price_id && $product->stripe_price_id !== $stripePriceId) {
|
||||||
|
try {
|
||||||
|
$stripe->prices->update($product->stripe_price_id, [
|
||||||
|
'active' => false,
|
||||||
|
]);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// Ignore error if price not found
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$product->forceFill([
|
if ($product->stripe_product_id !== $stripeProductId || $product->stripe_price_id !== $stripePriceId) {
|
||||||
'stripe_product_id' => $stripeProductId,
|
$product->forceFill([
|
||||||
'stripe_price_id' => $product->stripe_price_id ?: $stripePrice->id,
|
'stripe_product_id' => $stripeProductId,
|
||||||
])->save();
|
'stripe_price_id' => $stripePriceId,
|
||||||
|
])->saveQuietly();
|
||||||
|
}
|
||||||
|
|
||||||
return $product;
|
return $product;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,104 +0,0 @@
|
|||||||
service: bemeal
|
|
||||||
image: daily_meal/bemeal-api
|
|
||||||
deploy_timeout: 120
|
|
||||||
|
|
||||||
x-hosts: &hosts
|
|
||||||
- <%= ENV.fetch("SSH_HOST", "89.167.35.217") %>
|
|
||||||
|
|
||||||
x-web-networks: &web-networks
|
|
||||||
network:
|
|
||||||
- bemeal_public
|
|
||||||
- bemeal_internal
|
|
||||||
|
|
||||||
x-worker-networks: &worker-networks
|
|
||||||
network:
|
|
||||||
- bemeal_internal
|
|
||||||
|
|
||||||
servers:
|
|
||||||
web:
|
|
||||||
hosts: *hosts
|
|
||||||
options: *web-networks
|
|
||||||
horizon:
|
|
||||||
hosts: *hosts
|
|
||||||
cmd: php artisan horizon
|
|
||||||
options: *worker-networks
|
|
||||||
scheduler:
|
|
||||||
hosts: *hosts
|
|
||||||
cmd: php artisan schedule:work
|
|
||||||
options: *worker-networks
|
|
||||||
|
|
||||||
proxy:
|
|
||||||
host: bemeal.leonmorival.com
|
|
||||||
ssl: false
|
|
||||||
app_port: 80
|
|
||||||
run:
|
|
||||||
http_port: 8081
|
|
||||||
https_port: 8443
|
|
||||||
healthcheck:
|
|
||||||
path: /api/health
|
|
||||||
|
|
||||||
registry:
|
|
||||||
server: gitea.leonmorival.com
|
|
||||||
username: leon.morival@gmail.com
|
|
||||||
password:
|
|
||||||
- GITEA_TOKEN
|
|
||||||
|
|
||||||
ssh:
|
|
||||||
user: <%= ENV.fetch("SSH_USER", "root") %>
|
|
||||||
keys_only: true
|
|
||||||
key_data:
|
|
||||||
- SSH_PRIVATE_KEY
|
|
||||||
config: false
|
|
||||||
|
|
||||||
builder:
|
|
||||||
arch: amd64
|
|
||||||
|
|
||||||
env:
|
|
||||||
clear:
|
|
||||||
APP_LOCALE: fr
|
|
||||||
APP_ENV: production
|
|
||||||
APP_DEBUG: "false"
|
|
||||||
APP_NAME: "Bowly"
|
|
||||||
APP_URL: https://bemeal.leonmorival.com
|
|
||||||
APP_MOBILE_SCHEME: bowly
|
|
||||||
SERVER_NAME: ":80"
|
|
||||||
APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime"
|
|
||||||
FILESYSTEM_DISK: s3
|
|
||||||
AWS_DEFAULT_REGION: eu-north-1
|
|
||||||
AWS_BUCKET: bowli
|
|
||||||
AWS_USE_PATH_STYLE_ENDPOINT: "false"
|
|
||||||
DB_CONNECTION: pgsql
|
|
||||||
DB_HOST: bemeal-pgsql
|
|
||||||
DB_PORT: "5432"
|
|
||||||
REDIS_HOST: bemeal-redis
|
|
||||||
REDIS_CLIENT: predis
|
|
||||||
QUEUE_CONNECTION: redis
|
|
||||||
SCOUT_DRIVER: meilisearch
|
|
||||||
MEILISEARCH_HOST: http://bemeal-meilisearch:7700
|
|
||||||
MAIL_MAILER: resend
|
|
||||||
MAIL_FROM_ADDRESS: onboarding@resend.dev
|
|
||||||
MAIL_FROM_NAME: Bowli
|
|
||||||
CASHIER_CURRENCY: eur
|
|
||||||
secret:
|
|
||||||
- APP_KEY
|
|
||||||
- DB_DATABASE
|
|
||||||
- DB_USERNAME
|
|
||||||
- DB_PASSWORD
|
|
||||||
- MEILISEARCH_KEY
|
|
||||||
- GEMINI_API_KEY
|
|
||||||
- STRIPE_KEY
|
|
||||||
- STRIPE_SECRET
|
|
||||||
- STRIPE_WEBHOOK_SECRET
|
|
||||||
- STRAVA_CLIENT_ID
|
|
||||||
- STRAVA_CLIENT_SECRET
|
|
||||||
- RESEND_API_KEY
|
|
||||||
- AWS_ACCESS_KEY_ID
|
|
||||||
- AWS_SECRET_ACCESS_KEY
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- bemeal_storage_data:/var/www/html/storage/app
|
|
||||||
- bemeal_storage_public_data:/var/www/html/storage/app/public
|
|
||||||
|
|
||||||
aliases:
|
|
||||||
artisan: app exec --reuse "php artisan"
|
|
||||||
shell: app exec --interactive --reuse "sh"
|
|
||||||
@@ -13,7 +13,7 @@ return new class extends Migration
|
|||||||
{
|
{
|
||||||
Schema::create('subscriptions', function (Blueprint $table) {
|
Schema::create('subscriptions', function (Blueprint $table) {
|
||||||
$table->id();
|
$table->id();
|
||||||
$table->foreignId('user_id');
|
$table->foreignUlid('user_id');
|
||||||
$table->string('type');
|
$table->string('type');
|
||||||
$table->string('stripe_id')->unique();
|
$table->string('stripe_id')->unique();
|
||||||
$table->string('stripe_status');
|
$table->string('stripe_status');
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('payment_transactions', function (Blueprint $table): void {
|
||||||
|
$table->ulid('id')->primary();
|
||||||
|
$table->foreignUlid('user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||||
|
$table->foreignUlid('credit_product_id')->nullable()->constrained('credit_products')->nullOnDelete();
|
||||||
|
$table->foreignUlid('credit_ledger_entry_id')->nullable()->constrained('credit_ledger_entries')->nullOnDelete();
|
||||||
|
$table->string('type', 64);
|
||||||
|
$table->string('status', 64);
|
||||||
|
$table->string('stripe_event_id')->nullable()->index();
|
||||||
|
$table->string('stripe_event_type')->nullable()->index();
|
||||||
|
$table->string('stripe_customer_id')->nullable()->index();
|
||||||
|
$table->string('stripe_checkout_session_id')->nullable()->index();
|
||||||
|
$table->string('stripe_invoice_id')->nullable()->index();
|
||||||
|
$table->string('stripe_payment_intent_id')->nullable()->index();
|
||||||
|
$table->string('stripe_subscription_id')->nullable()->index();
|
||||||
|
$table->string('stripe_price_id')->nullable()->index();
|
||||||
|
$table->unsignedInteger('amount')->nullable();
|
||||||
|
$table->string('currency', 3)->nullable();
|
||||||
|
$table->unsignedInteger('credits_expected')->nullable();
|
||||||
|
$table->unsignedInteger('credits_granted')->default(0);
|
||||||
|
$table->text('error_message')->nullable();
|
||||||
|
$table->json('payload')->nullable();
|
||||||
|
$table->timestamp('processed_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['status', 'created_at']);
|
||||||
|
$table->index(['type', 'status', 'created_at']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('payment_transactions');
|
||||||
|
}
|
||||||
|
};
|
||||||
+28
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('payment_transactions', function (Blueprint $table): void {
|
||||||
|
$table->string('invoice_url', 2048)->nullable();
|
||||||
|
$table->string('invoice_pdf_url', 2048)->nullable();
|
||||||
|
$table->timestamp('invoice_email_sent_at')->nullable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('payment_transactions', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn([
|
||||||
|
'invoice_url',
|
||||||
|
'invoice_pdf_url',
|
||||||
|
'invoice_email_sent_at',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() !== 'pgsql' || ! Schema::hasTable('subscriptions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement('DROP INDEX IF EXISTS subscriptions_user_id_stripe_status_index');
|
||||||
|
DB::statement('ALTER TABLE subscriptions ALTER COLUMN user_id TYPE CHAR(26) USING user_id::text');
|
||||||
|
DB::statement('CREATE INDEX subscriptions_user_id_stripe_status_index ON subscriptions (user_id, stripe_status)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
if (DB::getDriverName() !== 'pgsql' || ! Schema::hasTable('subscriptions')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::statement('DROP INDEX IF EXISTS subscriptions_user_id_stripe_status_index');
|
||||||
|
DB::statement('ALTER TABLE subscriptions ALTER COLUMN user_id TYPE BIGINT USING user_id::bigint');
|
||||||
|
DB::statement('CREATE INDEX subscriptions_user_id_stripe_status_index ON subscriptions (user_id, stripe_status)');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('follows', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignUlid('follower_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->foreignUlid('following_id')->constrained('users')->cascadeOnDelete();
|
||||||
|
$table->string('status')->default('accepted');
|
||||||
|
$table->timestampsTz();
|
||||||
|
$table->unique([
|
||||||
|
'follower_id',
|
||||||
|
'following_id',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('follows');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -98,6 +98,49 @@ return [
|
|||||||
'sync_stripe_success' => 'Stripe product synchronized.',
|
'sync_stripe_success' => 'Stripe product synchronized.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'payment_transactions' => [
|
||||||
|
'navigation' => [
|
||||||
|
'label' => 'Payments',
|
||||||
|
'singular' => 'Payment',
|
||||||
|
'plural' => 'Payments',
|
||||||
|
],
|
||||||
|
'sections' => [
|
||||||
|
'summary' => 'Summary',
|
||||||
|
'stripe' => 'Stripe',
|
||||||
|
'error' => 'Error',
|
||||||
|
'payload' => 'Stripe payload',
|
||||||
|
],
|
||||||
|
'fields' => [
|
||||||
|
'id' => 'ID',
|
||||||
|
'user' => 'User',
|
||||||
|
'credit_product' => 'Product',
|
||||||
|
'type' => 'Type',
|
||||||
|
'status' => 'Status',
|
||||||
|
'amount' => 'Amount',
|
||||||
|
'credits_expected' => 'Expected credits',
|
||||||
|
'credits_granted' => 'Granted credits',
|
||||||
|
'invoice_url' => 'Invoice URL',
|
||||||
|
'invoice_pdf_url' => 'Invoice PDF',
|
||||||
|
'invoice_email_sent_at' => 'Invoice email sent at',
|
||||||
|
'stripe_event_id' => 'Stripe event ID',
|
||||||
|
'stripe_event_type' => 'Event',
|
||||||
|
'stripe_customer_id' => 'Stripe customer ID',
|
||||||
|
'stripe_checkout_session_id' => 'Stripe checkout session ID',
|
||||||
|
'stripe_checkout_session_id_short' => 'Checkout session',
|
||||||
|
'stripe_invoice_id' => 'Stripe invoice ID',
|
||||||
|
'stripe_invoice_id_short' => 'Invoice',
|
||||||
|
'stripe_payment_intent_id' => 'Stripe payment intent ID',
|
||||||
|
'stripe_subscription_id' => 'Stripe subscription ID',
|
||||||
|
'stripe_price_id' => 'Stripe price ID',
|
||||||
|
'error_message' => 'Message',
|
||||||
|
'payload' => 'Payload',
|
||||||
|
'processed_at' => 'Processed at',
|
||||||
|
'created_at' => 'Created at',
|
||||||
|
],
|
||||||
|
'placeholders' => [
|
||||||
|
'empty' => '-',
|
||||||
|
],
|
||||||
|
],
|
||||||
'moderation_cases' => [
|
'moderation_cases' => [
|
||||||
'navigation' => [
|
'navigation' => [
|
||||||
'label' => 'Moderation',
|
'label' => 'Moderation',
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ return [
|
|||||||
'legal_documents' => [
|
'legal_documents' => [
|
||||||
'not_found' => 'Legal document not found.',
|
'not_found' => 'Legal document not found.',
|
||||||
],
|
],
|
||||||
|
'billing' => [
|
||||||
|
'active_subscription_exists' => 'You already have an active subscription.',
|
||||||
|
'no_active_subscription' => 'There is no active subscription to manage.',
|
||||||
|
],
|
||||||
'notifications' => [
|
'notifications' => [
|
||||||
'test_title' => 'Test',
|
'test_title' => 'Test',
|
||||||
'test_body' => 'Test notification from the Laravel API.',
|
'test_body' => 'Test notification from the Laravel API.',
|
||||||
@@ -86,6 +90,14 @@ return [
|
|||||||
'missing_activity_scope' => 'The Strava connection must allow the activity:read or activity:read_all scope.',
|
'missing_activity_scope' => 'The Strava connection must allow the activity:read or activity:read_all scope.',
|
||||||
'disconnect_failed' => 'Unable to revoke Strava access right now. Please try again in a moment.',
|
'disconnect_failed' => 'Unable to revoke Strava access right now. Please try again in a moment.',
|
||||||
],
|
],
|
||||||
|
'follows' => [
|
||||||
|
'cannot_follow_self' => 'You cannot follow yourself.',
|
||||||
|
'followed' => 'Follow request recorded.',
|
||||||
|
'unfollowed' => 'Unfollowed successfully.',
|
||||||
|
'accepted' => 'Follow request accepted.',
|
||||||
|
'rejected' => 'Follow request rejected.',
|
||||||
|
'not_found' => 'No request found.',
|
||||||
|
],
|
||||||
'validation' => [
|
'validation' => [
|
||||||
'image_required' => 'Add an image to analyze.',
|
'image_required' => 'Add an image to analyze.',
|
||||||
'image_file' => 'The file must be an image.',
|
'image_file' => 'The file must be an image.',
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ return [
|
|||||||
'refund' => 'Refund',
|
'refund' => 'Refund',
|
||||||
'adjustment' => 'Adjustment',
|
'adjustment' => 'Adjustment',
|
||||||
],
|
],
|
||||||
|
'payment_transaction_status' => [
|
||||||
|
'pending' => 'Pending',
|
||||||
|
'paid' => 'Paid',
|
||||||
|
'credited' => 'Credited',
|
||||||
|
'failed' => 'Failed',
|
||||||
|
'ignored' => 'Ignored',
|
||||||
|
'error' => 'Error',
|
||||||
|
],
|
||||||
|
'payment_transaction_type' => [
|
||||||
|
'checkout' => 'Checkout',
|
||||||
|
'subscription_invoice' => 'Subscription invoice',
|
||||||
|
],
|
||||||
'weight_goal' => [
|
'weight_goal' => [
|
||||||
'lose_weight' => 'Lose weight',
|
'lose_weight' => 'Lose weight',
|
||||||
'maintain_weight' => 'Maintain weight',
|
'maintain_weight' => 'Maintain weight',
|
||||||
|
|||||||
@@ -33,4 +33,19 @@ return [
|
|||||||
'password_confirmation' => 'Confirm password',
|
'password_confirmation' => 'Confirm password',
|
||||||
'submit' => 'Update password',
|
'submit' => 'Update password',
|
||||||
],
|
],
|
||||||
|
'payment_invoice' => [
|
||||||
|
'subject' => 'Your Bowli invoice',
|
||||||
|
'title' => 'Your invoice is available',
|
||||||
|
'greeting' => 'Hi :name,',
|
||||||
|
'default_name' => 'there',
|
||||||
|
'intro' => 'Thanks for your payment. Your summary and invoice link are below.',
|
||||||
|
'product' => 'Product',
|
||||||
|
'default_product' => 'Bowli credits',
|
||||||
|
'credits' => 'Credits',
|
||||||
|
'amount' => 'Amount',
|
||||||
|
'unknown_amount' => 'Amount unavailable',
|
||||||
|
'action' => 'View my invoice',
|
||||||
|
'pdf_link' => 'PDF link:',
|
||||||
|
'footer' => 'If you have any question, simply reply to this email.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -98,6 +98,49 @@ return [
|
|||||||
'sync_stripe_success' => 'Produit Stripe synchronisé.',
|
'sync_stripe_success' => 'Produit Stripe synchronisé.',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
'payment_transactions' => [
|
||||||
|
'navigation' => [
|
||||||
|
'label' => 'Paiements',
|
||||||
|
'singular' => 'Paiement',
|
||||||
|
'plural' => 'Paiements',
|
||||||
|
],
|
||||||
|
'sections' => [
|
||||||
|
'summary' => 'Résumé',
|
||||||
|
'stripe' => 'Stripe',
|
||||||
|
'error' => 'Erreur',
|
||||||
|
'payload' => 'Payload Stripe',
|
||||||
|
],
|
||||||
|
'fields' => [
|
||||||
|
'id' => 'ID',
|
||||||
|
'user' => 'Utilisateur',
|
||||||
|
'credit_product' => 'Produit',
|
||||||
|
'type' => 'Type',
|
||||||
|
'status' => 'Statut',
|
||||||
|
'amount' => 'Montant',
|
||||||
|
'credits_expected' => 'Crédits attendus',
|
||||||
|
'credits_granted' => 'Crédits crédités',
|
||||||
|
'invoice_url' => 'URL facture',
|
||||||
|
'invoice_pdf_url' => 'PDF facture',
|
||||||
|
'invoice_email_sent_at' => 'Email facture envoyé le',
|
||||||
|
'stripe_event_id' => 'Stripe event ID',
|
||||||
|
'stripe_event_type' => 'Événement',
|
||||||
|
'stripe_customer_id' => 'Stripe customer ID',
|
||||||
|
'stripe_checkout_session_id' => 'Stripe checkout session ID',
|
||||||
|
'stripe_checkout_session_id_short' => 'Checkout session',
|
||||||
|
'stripe_invoice_id' => 'Stripe invoice ID',
|
||||||
|
'stripe_invoice_id_short' => 'Invoice',
|
||||||
|
'stripe_payment_intent_id' => 'Stripe payment intent ID',
|
||||||
|
'stripe_subscription_id' => 'Stripe subscription ID',
|
||||||
|
'stripe_price_id' => 'Stripe price ID',
|
||||||
|
'error_message' => 'Message',
|
||||||
|
'payload' => 'Payload',
|
||||||
|
'processed_at' => 'Traité le',
|
||||||
|
'created_at' => 'Créé le',
|
||||||
|
],
|
||||||
|
'placeholders' => [
|
||||||
|
'empty' => '-',
|
||||||
|
],
|
||||||
|
],
|
||||||
'moderation_cases' => [
|
'moderation_cases' => [
|
||||||
'navigation' => [
|
'navigation' => [
|
||||||
'label' => 'Modération',
|
'label' => 'Modération',
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ return [
|
|||||||
'legal_documents' => [
|
'legal_documents' => [
|
||||||
'not_found' => 'Document légal introuvable.',
|
'not_found' => 'Document légal introuvable.',
|
||||||
],
|
],
|
||||||
|
'billing' => [
|
||||||
|
'active_subscription_exists' => 'Tu as déjà un abonnement en cours.',
|
||||||
|
'no_active_subscription' => 'Aucun abonnement actif à gérer.',
|
||||||
|
],
|
||||||
'notifications' => [
|
'notifications' => [
|
||||||
'test_title' => 'Test',
|
'test_title' => 'Test',
|
||||||
'test_body' => 'Notification test depuis Laravel API.',
|
'test_body' => 'Notification test depuis Laravel API.',
|
||||||
@@ -86,6 +90,14 @@ return [
|
|||||||
'missing_activity_scope' => 'La connexion Strava doit autoriser le scope activity:read ou activity:read_all.',
|
'missing_activity_scope' => 'La connexion Strava doit autoriser le scope activity:read ou activity:read_all.',
|
||||||
'disconnect_failed' => "Impossible de retirer l'accès Strava pour le moment. Réessaie dans quelques instants.",
|
'disconnect_failed' => "Impossible de retirer l'accès Strava pour le moment. Réessaie dans quelques instants.",
|
||||||
],
|
],
|
||||||
|
'follows' => [
|
||||||
|
'cannot_follow_self' => 'Vous ne pouvez pas vous suivre vous-même.',
|
||||||
|
'followed' => 'Abonnement enregistré.',
|
||||||
|
'unfollowed' => 'Désabonnement effectué.',
|
||||||
|
'accepted' => 'Demande d’abonnement acceptée.',
|
||||||
|
'rejected' => 'Demande d’abonnement refusée.',
|
||||||
|
'not_found' => 'Aucune demande trouvée.',
|
||||||
|
],
|
||||||
'validation' => [
|
'validation' => [
|
||||||
'image_required' => 'Ajoute une image à analyser.',
|
'image_required' => 'Ajoute une image à analyser.',
|
||||||
'image_file' => 'Le fichier doit être une image.',
|
'image_file' => 'Le fichier doit être une image.',
|
||||||
|
|||||||
@@ -61,6 +61,18 @@ return [
|
|||||||
'refund' => 'Remboursement',
|
'refund' => 'Remboursement',
|
||||||
'adjustment' => 'Ajustement',
|
'adjustment' => 'Ajustement',
|
||||||
],
|
],
|
||||||
|
'payment_transaction_status' => [
|
||||||
|
'pending' => 'En attente',
|
||||||
|
'paid' => 'Payé',
|
||||||
|
'credited' => 'Crédité',
|
||||||
|
'failed' => 'Échec',
|
||||||
|
'ignored' => 'Ignoré',
|
||||||
|
'error' => 'Erreur',
|
||||||
|
],
|
||||||
|
'payment_transaction_type' => [
|
||||||
|
'checkout' => 'Checkout',
|
||||||
|
'subscription_invoice' => 'Facture abonnement',
|
||||||
|
],
|
||||||
'weight_goal' => [
|
'weight_goal' => [
|
||||||
'lose_weight' => 'Perdre du poids',
|
'lose_weight' => 'Perdre du poids',
|
||||||
'maintain_weight' => 'Maintenir le poids',
|
'maintain_weight' => 'Maintenir le poids',
|
||||||
|
|||||||
@@ -33,4 +33,19 @@ return [
|
|||||||
'password_confirmation' => 'Confirmer le mot de passe',
|
'password_confirmation' => 'Confirmer le mot de passe',
|
||||||
'submit' => 'Modifier le mot de passe',
|
'submit' => 'Modifier le mot de passe',
|
||||||
],
|
],
|
||||||
|
'payment_invoice' => [
|
||||||
|
'subject' => 'Ta facture Bowli',
|
||||||
|
'title' => 'Ta facture est disponible',
|
||||||
|
'greeting' => 'Bonjour :name,',
|
||||||
|
'default_name' => 'à toi',
|
||||||
|
'intro' => 'Merci pour ton paiement. Tu trouveras ci-dessous le récapitulatif et le lien vers ta facture.',
|
||||||
|
'product' => 'Produit',
|
||||||
|
'default_product' => 'Crédits Bowli',
|
||||||
|
'credits' => 'Crédits',
|
||||||
|
'amount' => 'Montant',
|
||||||
|
'unknown_amount' => 'Montant indisponible',
|
||||||
|
'action' => 'Voir ma facture',
|
||||||
|
'pdf_link' => 'Lien PDF :',
|
||||||
|
'footer' => 'Si tu as une question, réponds simplement à cet email.',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ $locale }}">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ trans('mail.payment_invoice.subject', [], $locale) }}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin: 0; padding: 0; background: #f5efe6; color: #232323; font-family: Arial, sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background: #f5efe6; padding: 32px 16px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="max-width: 560px; background: #ffffff; border-radius: 8px; overflow: hidden;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 32px;">
|
||||||
|
<p style="margin: 0 0 8px; color: #426fb3; font-size: 14px; font-weight: 700; text-transform: uppercase;">
|
||||||
|
Bowli
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 style="margin: 0 0 16px; color: #000000; font-size: 28px; line-height: 34px;">
|
||||||
|
{{ trans('mail.payment_invoice.title', [], $locale) }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 16px; color: #232323; font-size: 16px; line-height: 24px;">
|
||||||
|
{{ trans('mail.payment_invoice.greeting', ['name' => $userName ?: trans('mail.payment_invoice.default_name', [], $locale)], $locale) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="margin: 0 0 20px; color: #232323; font-size: 16px; line-height: 24px;">
|
||||||
|
{{ trans('mail.payment_invoice.intro', [], $locale) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="margin: 0 0 24px; border-collapse: collapse;">
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 0; color: #666666; font-size: 14px;">
|
||||||
|
{{ trans('mail.payment_invoice.product', [], $locale) }}
|
||||||
|
</td>
|
||||||
|
<td align="right" style="padding: 10px 0; color: #232323; font-size: 14px; font-weight: 700;">
|
||||||
|
{{ $productName ?: trans('mail.payment_invoice.default_product', [], $locale) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 0; color: #666666; font-size: 14px; border-top: 1px solid #eeeeee;">
|
||||||
|
{{ trans('mail.payment_invoice.credits', [], $locale) }}
|
||||||
|
</td>
|
||||||
|
<td align="right" style="padding: 10px 0; color: #232323; font-size: 14px; font-weight: 700; border-top: 1px solid #eeeeee;">
|
||||||
|
{{ $credits }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="padding: 10px 0; color: #666666; font-size: 14px; border-top: 1px solid #eeeeee;">
|
||||||
|
{{ trans('mail.payment_invoice.amount', [], $locale) }}
|
||||||
|
</td>
|
||||||
|
<td align="right" style="padding: 10px 0; color: #232323; font-size: 14px; font-weight: 700; border-top: 1px solid #eeeeee;">
|
||||||
|
{{ $amount }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
@if ($invoiceUrl)
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="margin: 0 0 24px;">
|
||||||
|
<tr>
|
||||||
|
<td style="background: #000000; border-radius: 999px;">
|
||||||
|
<a href="{{ $invoiceUrl }}" style="display: inline-block; padding: 14px 24px; color: #ffffff; font-size: 16px; font-weight: 700; text-decoration: none;">
|
||||||
|
{{ trans('mail.payment_invoice.action', [], $locale) }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@if ($invoicePdfUrl)
|
||||||
|
<p style="margin: 0 0 16px; color: #666666; font-size: 14px; line-height: 22px;">
|
||||||
|
{{ trans('mail.payment_invoice.pdf_link', [], $locale) }}
|
||||||
|
<a href="{{ $invoicePdfUrl }}" style="color: #426fb3;">{{ $invoicePdfUrl }}</a>
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<p style="margin: 0; color: #666666; font-size: 14px; line-height: 22px;">
|
||||||
|
{{ trans('mail.payment_invoice.footer', [], $locale) }}
|
||||||
|
</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
use App\Http\Controllers\BillingController;
|
use App\Http\Controllers\BillingController;
|
||||||
use App\Http\Controllers\DeviceTokenController;
|
use App\Http\Controllers\DeviceTokenController;
|
||||||
|
use App\Http\Controllers\FollowController;
|
||||||
use App\Http\Controllers\LegalDocumentController;
|
use App\Http\Controllers\LegalDocumentController;
|
||||||
use App\Http\Controllers\MealImageAnalysisController;
|
use App\Http\Controllers\MealImageAnalysisController;
|
||||||
use App\Http\Controllers\MealPostController;
|
use App\Http\Controllers\MealPostController;
|
||||||
@@ -79,6 +80,12 @@ Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function
|
|||||||
// Meals
|
// Meals
|
||||||
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
Route::middleware(['auth:sanctum', 'verified', 'not_suspended'])->group(function (): void {
|
||||||
Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show');
|
Route::get('users/{user}', [PublicUserProfileController::class, 'show'])->name('users.show');
|
||||||
|
Route::post('users/{user}/follow', [FollowController::class, 'follow'])->middleware('throttle:engagement')->name('users.follow');
|
||||||
|
Route::delete('users/{user}/follow', [FollowController::class, 'unfollow'])->middleware('throttle:engagement')->name('users.unfollow');
|
||||||
|
Route::post('users/{user}/accept', [FollowController::class, 'accept'])->middleware('throttle:engagement')->name('users.accept');
|
||||||
|
Route::post('users/{user}/reject', [FollowController::class, 'reject'])->middleware('throttle:engagement')->name('users.reject');
|
||||||
|
Route::get('users/{user}/followers', [FollowController::class, 'followers'])->name('users.followers');
|
||||||
|
Route::get('users/{user}/following', [FollowController::class, 'following'])->name('users.following');
|
||||||
Route::post('users/{user}/reports', [ReportController::class, 'storeUser'])
|
Route::post('users/{user}/reports', [ReportController::class, 'storeUser'])
|
||||||
->middleware('throttle:reports')
|
->middleware('throttle:reports')
|
||||||
->name('users.reports.store');
|
->name('users.reports.store');
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Http\Controllers\AuthController;
|
use App\Http\Controllers\AuthController;
|
||||||
|
use App\Http\Controllers\BillingController;
|
||||||
use App\Http\Controllers\StravaController;
|
use App\Http\Controllers\StravaController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
@@ -10,3 +11,4 @@ Route::post('password/reset', [AuthController::class, 'resetPasswordFromView'])
|
|||||||
->middleware('throttle:6,1')
|
->middleware('throttle:6,1')
|
||||||
->name('password.web-update');
|
->name('password.web-update');
|
||||||
Route::get('strava/callback', [StravaController::class, 'redirectToMobileApp'])->name('strava.web-callback');
|
Route::get('strava/callback', [StravaController::class, 'redirectToMobileApp'])->name('strava.web-callback');
|
||||||
|
Route::get('billing/return/{status}', [BillingController::class, 'redirectToMobileApp'])->name('billing.web-return');
|
||||||
|
|||||||
@@ -34,4 +34,3 @@ it('returns only the authenticated user meal posts', function () {
|
|||||||
'id' => $otherMealPost->id,
|
'id' => $otherMealPost->id,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user