Merge remote-tracking branch 'upstream/develop' into invalid-encr-key-message
* upstream/develop: (1373 commits) perf: cache dynamic links map in Redis (#28878) fix: Never query `flag_print_sql` in `developer_mode=0` (#28884) fix(restore): remove MariaDB view security definers fix: sanitize user input during setup wizard feat(sanitize_column): improve check refactor: make optimizations.py private entirely (#28872) fix(site_cache): site cache thread safety (#28870) chore(printview): change error message perf: speedup `frappe.call` by ~8x (#28866) test: reduce noise in test output (#28862) chore: spelling_invalid_values (#28858) fix: Remove misleading os.O_NONBLOCK flag (#28859) fix: string replacement in error logger perf(gthread): Pin web workers to a single core (#28854) fix: MariaDBDatabase.get_tables() should not query the entire database schema (#28846) fix: add strings and fields to translation fix: typo in test controller boilerplate perf: faster add_to_date (#28843) perf(version): Make get_versions fast for autoincrement doctypes (#28847) refactor: log in monitor as well ...
|
|
@ -18,4 +18,4 @@ max_line_length = 99
|
|||
[{*.json}]
|
||||
insert_final_newline = false
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
indent_size = 1
|
||||
|
|
|
|||
|
|
@ -52,3 +52,9 @@ de9ac897482013f5464a05f3c171da0072619c3a
|
|||
|
||||
# some new ruff rules
|
||||
48cf19d7e997896d12aee7c7d97f73c8df217204
|
||||
|
||||
# caching decorators docstrings indentation
|
||||
e9bbe03354079cfcef65a77b0c33f57b047a7c93
|
||||
|
||||
# ruff update
|
||||
84ef6ec677c8657c3243ac456a1ef794bfb34a50
|
||||
|
|
|
|||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
|
|
@ -30,4 +30,4 @@ Also, if you're new here
|
|||
|
||||
> Screenshots/GIFs
|
||||
|
||||
<!-- Add images/recordings to better visualize the change: expected/current behviour -->
|
||||
<!-- Add images/recordings to better visualize the change: expected/current behavior -->
|
||||
|
|
|
|||
279
.github/actions/setup/action.yml
vendored
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
name: 'Setup Environment'
|
||||
description: 'Sets up the environment for Frappe development'
|
||||
inputs:
|
||||
python-version:
|
||||
description: 'Python version to use'
|
||||
required: false
|
||||
default: '3.12.6'
|
||||
node-version:
|
||||
description: 'Node.js version to use'
|
||||
required: false
|
||||
default: '20'
|
||||
build-assets:
|
||||
required: false
|
||||
description: 'Wether to build assets'
|
||||
default: true
|
||||
enable-coverage:
|
||||
required: false
|
||||
default: false
|
||||
enable-watch:
|
||||
required: false
|
||||
default: false
|
||||
enable-schedule:
|
||||
required: false
|
||||
default: false
|
||||
disable-web:
|
||||
required: false
|
||||
default: false
|
||||
disable-socketio:
|
||||
required: false
|
||||
default: false
|
||||
db:
|
||||
required: false
|
||||
default: mariadb
|
||||
db-root-password:
|
||||
required: true
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- shell: bash -e {0}
|
||||
run: |
|
||||
# Add 'test_site' to /etc/hosts & setup git config
|
||||
echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
git config --global init.defaultBranch main
|
||||
git config --global advice.detachedHead false
|
||||
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: apps/${{ github.event.repository.name }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- shell: bash -e {0}
|
||||
run: |
|
||||
# Check for valid Python & Merge Conflicts
|
||||
python -m compileall -q -f "${GITHUB_WORKSPACE}/apps/${{ github.event.repository.name }}"
|
||||
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}/apps/${{ github.event.repository.name }}"
|
||||
then echo "Found merge conflicts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Fetch tombl util
|
||||
shell: bash -e {0}
|
||||
run: |
|
||||
# Fetch tombl util
|
||||
pushd $RUNNER_TEMP
|
||||
curl -LO https://github.com/snyball/tombl/releases/download/v0.2.3/tombl-v0.2.3.tar.gz
|
||||
curl -LO https://github.com/snyball/tombl/releases/download/v0.2.3/tombl-v0.2.3.sha256sum
|
||||
sha256sum -c tombl-v0.2.3.sha256sum
|
||||
tar -xzf tombl-v0.2.3.tar.gz
|
||||
chmod +x tombl-v0.2.3/tombl
|
||||
popd
|
||||
echo "$RUNNER_TEMP/tombl-v0.2.3" >> $GITHUB_PATH
|
||||
|
||||
- name: Checkout Frappe
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.FRAPPE_GH_ORG || github.repository_owner }}/frappe
|
||||
ref: ${{ github.event.client_payload.frappe_sha || github.base_ref || github.ref_name }}
|
||||
path: apps/frappe
|
||||
if: github.event.repository.name != 'frappe'
|
||||
|
||||
- name: Maybe clone additional apps
|
||||
shell: bash -e {0}
|
||||
env:
|
||||
org: ${{ env.FRAPPE_GH_ORG || github.repository_owner }}
|
||||
ref: ${{ github.event.client_payload.frappe_sha || github.base_ref || github.ref_name }}
|
||||
run: |
|
||||
# Maybe clone additional apps
|
||||
eval "$(tombl -e CHECKOUTS=tool.frappe-ci.setup.app-checkouts ${GITHUB_WORKSPACE}/apps/${{ github.event.repository.name }}/pyproject.toml)" || exit 0
|
||||
start_time=$(date +%s)
|
||||
|
||||
for spec in "${CHECKOUTS[@]}"; do
|
||||
spec_basename=$(basename "$spec")
|
||||
if [[ "$spec" == *"/"* ]] || [[ "$spec" == http* ]]; then
|
||||
remote_url="$spec"
|
||||
else
|
||||
remote_url="$org/$spec_basename"
|
||||
fi
|
||||
if [[ "$remote_url" != http* ]]; then
|
||||
remote_url="https://github.com/$remote_url"
|
||||
fi
|
||||
mkdir "apps/$spec_basename"
|
||||
pushd "apps/$spec_basename"
|
||||
git init
|
||||
git remote add origin "$remote_url"
|
||||
git fetch origin "$ref" --depth 1
|
||||
git checkout FETCH_HEAD --quiet
|
||||
popd
|
||||
done
|
||||
end_time=$(date +%s)
|
||||
echo -e "\033[33mClone additional apps: $((end_time - start_time)) seconds\033[0m"
|
||||
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ inputs.node-version }}
|
||||
check-latest: true
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- id: yarn-cache-dir-path
|
||||
shell: bash -e {0}
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- shell: bash -e {0}
|
||||
run: |
|
||||
# Install System Dependencies
|
||||
start_time=$(date +%s)
|
||||
|
||||
sudo apt -qq update
|
||||
sudo apt -qq remove mysql-server mysql-client
|
||||
sudo apt -qq install libcups2-dev redis-server mariadb-client
|
||||
|
||||
wget -q -O /tmp/wkhtmltox.deb https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6.1-2/wkhtmltox_0.12.6.1-2.jammy_amd64.deb
|
||||
sudo apt install /tmp/wkhtmltox.deb
|
||||
|
||||
end_time=$(date +%s)
|
||||
echo -e "\033[33mInstall System Dependencies: $((end_time - start_time)) seconds\033[0m"
|
||||
|
||||
- shell: bash -e {0}
|
||||
env:
|
||||
DB: ${{ inputs.db }}
|
||||
run: |
|
||||
# Init Bench & test_site
|
||||
start_time=$(date +%s)
|
||||
mkdir ${GITHUB_WORKSPACE}/{sites,config,logs,config/pids,sites/test_site}
|
||||
python -m venv ${GITHUB_WORKSPACE}/env
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
pip install --quiet --upgrade pip
|
||||
|
||||
pip install --quiet frappe-bench
|
||||
|
||||
python <<EOF
|
||||
from bench.config.common_site_config import setup_config
|
||||
from bench.config.redis import generate_config
|
||||
from bench.config.procfile import setup_procfile
|
||||
|
||||
bench_path = "${{ github.workspace }}"
|
||||
is_true = lambda str: True if str == "true" else False
|
||||
is_not_true = lambda str: True if str != "true" else False
|
||||
|
||||
setup_config(bench_path)
|
||||
generate_config(bench_path)
|
||||
setup_procfile(
|
||||
bench_path,
|
||||
skip_redis=False,
|
||||
skip_web=is_true("${{ inputs.disable-web }}"),
|
||||
skip_watch=is_not_true("${{ inputs.enable-watch }}"),
|
||||
skip_socketio=is_true("${{ inputs.disable-socketio }}"),
|
||||
skip_schedule=is_not_true("${{ inputs.enable-schedule }}"),
|
||||
with_coverage=is_true("${{ inputs.enable-coverage }}"),
|
||||
)
|
||||
EOF
|
||||
end_time=$(date +%s)
|
||||
echo -e "\033[33mInit Bench: $((end_time - start_time)) seconds\033[0m"
|
||||
cat ${GITHUB_WORKSPACE}/Procfile | awk '{print "\033[0;34m" $0 "\033[0m"}'
|
||||
# Attempt to copy the configuration file
|
||||
if cp "${GITHUB_WORKSPACE}/apps/${{ github.event.repository.name }}/.github/helper/db/$DB.json" ${GITHUB_WORKSPACE}/sites/test_site/site_config.json; then
|
||||
echo "Successfully copied ${DB}.json to site_config.json."
|
||||
else
|
||||
echo "Error: The configuration file ${GITHUB_WORKSPACE}/apps/${{ github.event.repository.name }}/.github/helper/db/$DB.json does not exist."
|
||||
echo "Please ensure that the database JSON file is correctly named and located in the helper/db directory."
|
||||
exit 1 # Exit with a non-zero status to indicate failure
|
||||
fi
|
||||
|
||||
if [ "$DB" == "mariadb" ]; then
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "SET GLOBAL character_set_server = 'utf8mb4'";
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'";
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "CREATE DATABASE test_frappe";
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'";
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'";
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -p${{ inputs.db-root-password }} -e "FLUSH PRIVILEGES";
|
||||
elif [ "$DB" == "postgres" ]; then
|
||||
echo "${{ inputs.db-root-password }}" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "${{ inputs.db-root-password }}" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
fi
|
||||
|
||||
- shell: bash -e {0}
|
||||
run: |
|
||||
# Install App(s)
|
||||
step_start_time=$(date +%s)
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
|
||||
for app in ${GITHUB_WORKSPACE}/apps/*/; do
|
||||
app_name="$(basename $app)"
|
||||
if [ -f "${app}setup.py" ] || [ -f "${app}pyproject.toml" ]; then
|
||||
start_time=$(date +%s)
|
||||
echo -e "\033[36mInstalling python app from ${app}\033[0m"
|
||||
pip install --upgrade -e "${app}[dev,test]"
|
||||
end_time=$(date +%s)
|
||||
echo -e "\033[36mTime taken to Install python ${app}: $((end_time - start_time)) seconds\033[0m"
|
||||
fi
|
||||
if [ "${{ inputs.build-assets }}" == "true" ] && [ -f "${app}package.json" ]; then
|
||||
start_time=$(date +%s)
|
||||
echo -e "\033[36mInstalling js app dependencies from ${app}\033[0m"
|
||||
pushd "$app"
|
||||
yarn --check-files
|
||||
popd
|
||||
end_time=$(date +%s)
|
||||
echo -e "\033[36mTime taken to Install js ${app}: $((end_time - start_time)) seconds\033[0m"
|
||||
fi
|
||||
echo "$app_name" >> sites/apps.txt
|
||||
echo -e "\033[32mAdded $app_name to $PWD/sites/apps.txt\033[0m"
|
||||
done
|
||||
step_end_time=$(date +%s)
|
||||
echo -e "\033[33mInstall App(s): $((step_end_time - step_start_time)) seconds\033[0m"
|
||||
env:
|
||||
TYPE: server
|
||||
|
||||
- shell: bash -e {0}
|
||||
run: |
|
||||
# Start Bench
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
bench start &> ${GITHUB_WORKSPACE}/bench_start.log &
|
||||
|
||||
- shell: bash -e {0}
|
||||
if: ${{ inputs.build-assets == 'true' }}
|
||||
run: |
|
||||
# Build Assets
|
||||
start_time=$(date +%s)
|
||||
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
CI=Yes bench build
|
||||
|
||||
end_time=$(date +%s)
|
||||
echo -e "\033[33mBuild Assets: $((end_time - start_time)) seconds\033[0m"
|
||||
|
||||
- shell: bash -e {0}
|
||||
run: |
|
||||
# Reinstall Test Site
|
||||
start_time=$(date +%s)
|
||||
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
bench --site test_site reinstall --yes
|
||||
|
||||
end_time=$(date +%s)
|
||||
echo -e "\033[33mReinstall Test Site: $((end_time - start_time)) seconds\033[0m"
|
||||
|
||||
14
.github/framework-logo-new.svg
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg width="117" height="117" viewBox="0 0 117 117" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1144_67)">
|
||||
<path d="M93.4394 0H23.5606C10.5485 0 0 10.5485 0 23.5606V93.4394C0 106.452 10.5485 117 23.5606 117H93.4394C106.452 117 117 106.452 117 93.4394V23.5606C117 10.5485 106.452 0 93.4394 0Z" fill="#909090"/>
|
||||
<path d="M87.1655 75.4V41.8L58.0828 25L29 41.8V75.4L58.0828 92.2L87.1655 75.4Z" stroke="white" stroke-width="10.59" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M29 41.8003L58.0828 58.6003L87.1655 41.8003" stroke="white" stroke-width="10.59" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M58.083 58.6006V92.2006" stroke="white" stroke-width="10.59" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M37.5 59L19 48" stroke="#909090" stroke-width="10.59"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_1144_67">
|
||||
<rect width="117" height="117" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 942 B |
7
.github/frappe-framework-logo.svg
vendored
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 626 B |
BIN
.github/fw-form-view.png
vendored
Normal file
|
After Width: | Height: | Size: 876 KiB |
BIN
.github/fw-list-view.png
vendored
Normal file
|
After Width: | Height: | Size: 950 KiB |
BIN
.github/fw-rpm.png
vendored
Normal file
|
After Width: | Height: | Size: 940 KiB |
99
.github/helper/ci.py
vendored
|
|
@ -1,99 +0,0 @@
|
|||
# Copyright (c) 2022, Frappe Technologies Pvt. Ltd. and Contributors
|
||||
# MIT License. See LICENSE
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
STANDARD_INCLUSIONS = ["*.py"]
|
||||
|
||||
STANDARD_EXCLUSIONS = [
|
||||
"*.js",
|
||||
"*.xml",
|
||||
"*.pyc",
|
||||
"*.css",
|
||||
"*.less",
|
||||
"*.scss",
|
||||
"*.vue",
|
||||
"*.html",
|
||||
"*/test_*",
|
||||
"*/node_modules/*",
|
||||
"*/doctype/*/*_dashboard.py",
|
||||
"*/patches/*",
|
||||
".github/*",
|
||||
]
|
||||
|
||||
# tested via commands' test suite
|
||||
TESTED_VIA_CLI = [
|
||||
"*/frappe/installer.py",
|
||||
"*/frappe/utils/install.py",
|
||||
"*/frappe/utils/scheduler.py",
|
||||
"*/frappe/utils/doctor.py",
|
||||
"*/frappe/build.py",
|
||||
"*/frappe/database/__init__.py",
|
||||
"*/frappe/database/db_manager.py",
|
||||
"*/frappe/database/**/setup_db.py",
|
||||
]
|
||||
|
||||
FRAPPE_EXCLUSIONS = ["*/tests/*", "*/commands/*", "*/frappe/change_log/*", "*/frappe/exceptions*", "*/frappe/desk/page/setup_wizard/setup_wizard.py", "*/frappe/coverage.py", "*frappe/setup.py", "*/frappe/hooks.py", "*/doctype/*/*_dashboard.py", "*/patches/*", "*/.github/helper/ci.py", *TESTED_VIA_CLI]
|
||||
|
||||
|
||||
def get_bench_path():
|
||||
return Path(__file__).resolve().parents[4]
|
||||
|
||||
|
||||
class CodeCoverage:
|
||||
def __init__(self, with_coverage, app):
|
||||
self.with_coverage = with_coverage
|
||||
self.app = app or "frappe"
|
||||
|
||||
def __enter__(self):
|
||||
if self.with_coverage:
|
||||
import os
|
||||
|
||||
from coverage import Coverage
|
||||
|
||||
# Generate coverage report only for app that is being tested
|
||||
source_path = os.path.join(get_bench_path(), "apps", self.app)
|
||||
print(f"Source path: {source_path}")
|
||||
omit = STANDARD_EXCLUSIONS[:]
|
||||
|
||||
if self.app == "frappe":
|
||||
omit.extend(FRAPPE_EXCLUSIONS)
|
||||
|
||||
self.coverage = Coverage(source=[source_path], omit=omit, include=STANDARD_INCLUSIONS)
|
||||
self.coverage.start()
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
if self.with_coverage:
|
||||
self.coverage.stop()
|
||||
self.coverage.save()
|
||||
self.coverage.xml_report()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = "frappe"
|
||||
site = os.environ.get("SITE") or "test_site"
|
||||
use_orchestrator = bool(os.environ.get("ORCHESTRATOR_URL"))
|
||||
with_coverage = json.loads(os.environ.get("WITH_COVERAGE", "true").lower())
|
||||
build_number = 1
|
||||
total_builds = 1
|
||||
|
||||
try:
|
||||
build_number = int(os.environ.get("BUILD_NUMBER"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
total_builds = int(os.environ.get("TOTAL_BUILDS"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with CodeCoverage(with_coverage=with_coverage, app=app):
|
||||
if use_orchestrator:
|
||||
from frappe.parallel_test_runner import ParallelTestWithOrchestrator
|
||||
|
||||
ParallelTestWithOrchestrator(app, site=site)
|
||||
else:
|
||||
from frappe.parallel_test_runner import ParallelTestRunner
|
||||
|
||||
ParallelTestRunner(app, site=site, build_number=build_number, total_builds=total_builds)
|
||||
36
.github/helper/db/mariadb.json
vendored
|
|
@ -1,19 +1,19 @@
|
|||
{
|
||||
"db_host": "127.0.0.1",
|
||||
"db_port": 3306,
|
||||
"db_name": "test_frappe",
|
||||
"db_password": "test_frappe",
|
||||
"allow_tests": true,
|
||||
"db_type": "mariadb",
|
||||
"auto_email_id": "test@example.com",
|
||||
"mail_server": "localhost",
|
||||
"mail_port": 2525,
|
||||
"mail_login": "test@example.com",
|
||||
"mail_password": "test",
|
||||
"admin_password": "admin",
|
||||
"root_login": "root",
|
||||
"root_password": "travis",
|
||||
"host_name": "http://test_site:8000",
|
||||
"monitor": 1,
|
||||
"server_script_enabled": true
|
||||
}
|
||||
"db_host": "127.0.0.1",
|
||||
"db_port": 3306,
|
||||
"db_name": "test_frappe",
|
||||
"db_password": "test_frappe",
|
||||
"allow_tests": true,
|
||||
"db_type": "mariadb",
|
||||
"auto_email_id": "test@example.com",
|
||||
"mail_server": "localhost",
|
||||
"mail_port": 2525,
|
||||
"mail_login": "test@example.com",
|
||||
"mail_password": "test",
|
||||
"admin_password": "admin",
|
||||
"root_login": "root",
|
||||
"root_password": "db_root",
|
||||
"host_name": "http://test_site:8000",
|
||||
"monitor": 1,
|
||||
"server_script_enabled": true
|
||||
}
|
||||
34
.github/helper/db/postgres.json
vendored
|
|
@ -1,18 +1,18 @@
|
|||
{
|
||||
"db_host": "127.0.0.1",
|
||||
"db_port": 5432,
|
||||
"db_name": "test_frappe",
|
||||
"db_password": "test_frappe",
|
||||
"db_type": "postgres",
|
||||
"allow_tests": true,
|
||||
"auto_email_id": "test@example.com",
|
||||
"mail_server": "localhost",
|
||||
"mail_port": 2525,
|
||||
"mail_login": "test@example.com",
|
||||
"mail_password": "test",
|
||||
"admin_password": "admin",
|
||||
"root_login": "postgres",
|
||||
"root_password": "travis",
|
||||
"host_name": "http://test_site:8000",
|
||||
"server_script_enabled": true
|
||||
}
|
||||
"db_host": "127.0.0.1",
|
||||
"db_port": 5432,
|
||||
"db_name": "test_frappe",
|
||||
"db_password": "test_frappe",
|
||||
"db_type": "postgres",
|
||||
"allow_tests": true,
|
||||
"auto_email_id": "test@example.com",
|
||||
"mail_server": "localhost",
|
||||
"mail_port": 2525,
|
||||
"mail_login": "test@example.com",
|
||||
"mail_password": "test",
|
||||
"admin_password": "admin",
|
||||
"root_login": "postgres",
|
||||
"root_password": "db_root",
|
||||
"host_name": "http://test_site:8000",
|
||||
"server_script_enabled": true
|
||||
}
|
||||
74
.github/helper/install.sh
vendored
|
|
@ -1,74 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
cd ~ || exit
|
||||
|
||||
echo "::group::Install Bench"
|
||||
pip install frappe-bench
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Init Bench"
|
||||
bench -v init frappe-bench --skip-assets --python "$(which python)" --frappe-path "${GITHUB_WORKSPACE}"
|
||||
cd ./frappe-bench || exit
|
||||
|
||||
bench -v setup requirements --dev
|
||||
if [ "$TYPE" == "ui" ]
|
||||
then
|
||||
bench -v setup requirements --node;
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Create Test Site"
|
||||
mkdir ~/frappe-bench/sites/test_site
|
||||
cp "${GITHUB_WORKSPACE}/.github/helper/db/$DB.json" ~/frappe-bench/sites/test_site/site_config.json
|
||||
|
||||
if [ "$DB" == "mariadb" ]
|
||||
then
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -ptravis -e "SET GLOBAL character_set_server = 'utf8mb4'";
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -ptravis -e "SET GLOBAL collation_server = 'utf8mb4_unicode_ci'";
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -ptravis -e "CREATE DATABASE test_frappe";
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -ptravis -e "CREATE USER 'test_frappe'@'localhost' IDENTIFIED BY 'test_frappe'";
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -ptravis -e "GRANT ALL PRIVILEGES ON \`test_frappe\`.* TO 'test_frappe'@'localhost'";
|
||||
|
||||
mariadb --host 127.0.0.1 --port 3306 -u root -ptravis -e "FLUSH PRIVILEGES";
|
||||
fi
|
||||
if [ "$DB" == "postgres" ]
|
||||
then
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE DATABASE test_frappe" -U postgres;
|
||||
echo "travis" | psql -h 127.0.0.1 -p 5432 -c "CREATE USER test_frappe WITH PASSWORD 'test_frappe'" -U postgres;
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
echo "::group::Modify processes"
|
||||
sed -i 's/^watch:/# watch:/g' Procfile
|
||||
sed -i 's/^schedule:/# schedule:/g' Procfile
|
||||
|
||||
if [ "$TYPE" == "server" ]
|
||||
then
|
||||
sed -i 's/^socketio:/# socketio:/g' Procfile
|
||||
sed -i 's/^redis_socketio:/# redis_socketio:/g' Procfile
|
||||
fi
|
||||
|
||||
if [ "$TYPE" == "ui" ]
|
||||
then
|
||||
sed -i 's/^web: bench serve/web: bench serve --with-coverage/g' Procfile
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
|
||||
bench start &> ~/frappe-bench/bench_start.log &
|
||||
|
||||
echo "::group::Install site"
|
||||
if [ "$TYPE" == "server" ]
|
||||
then
|
||||
CI=Yes bench build --app frappe &
|
||||
build_pid=$!
|
||||
fi
|
||||
|
||||
bench --site test_site reinstall --yes
|
||||
|
||||
if [ "$TYPE" == "server" ]
|
||||
then
|
||||
# wait till assets are built successfully
|
||||
wait $build_pid
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
16
.github/helper/install_dependencies.sh
vendored
|
|
@ -1,16 +0,0 @@
|
|||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Setting Up System Dependencies..."
|
||||
|
||||
echo "::group::apt packages"
|
||||
sudo apt update
|
||||
sudo apt remove mysql-server mysql-client
|
||||
sudo apt install libcups2-dev redis-server mariadb-client-10.6
|
||||
|
||||
install_wkhtmltopdf() {
|
||||
wget -q https://github.com/wkhtmltopdf/packaging/releases/download/0.12.6-1/wkhtmltox_0.12.6-1.focal_amd64.deb
|
||||
sudo apt install ./wkhtmltox_0.12.6-1.focal_amd64.deb
|
||||
}
|
||||
install_wkhtmltopdf &
|
||||
echo "::endgroup::"
|
||||
77
.github/helper/roulette.py
vendored
|
|
@ -1,3 +1,23 @@
|
|||
"""
|
||||
GitHub Pull Request Analysis and CI Build Decision Script
|
||||
|
||||
This script analyzes changes in a GitHub pull request and determines whether to run specific CI builds.
|
||||
It checks for file types changed, presence of certain labels, and other criteria to make decisions about
|
||||
which parts of the CI pipeline should run. The script is designed to optimize CI resources by skipping
|
||||
unnecessary builds based on the nature of changes in the pull request.
|
||||
|
||||
Key features:
|
||||
- Fetches pull request data from GitHub API
|
||||
- Analyzes changed files
|
||||
- Checks for specific labels on the pull request
|
||||
- Determines whether to run server-side, UI, or all tests
|
||||
- Handles rate limiting for GitHub API requests
|
||||
- Supports environment variables for configuration
|
||||
|
||||
Usage:
|
||||
This script is intended to be run as part of a CI pipeline, with the necessary environment variables set.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -12,17 +32,28 @@ from urllib.error import HTTPError
|
|||
|
||||
@cache
|
||||
def fetch_pr_data(pr_number, repo, endpoint=""):
|
||||
api_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}"
|
||||
"""
|
||||
Fetch pull request data from GitHub API.
|
||||
|
||||
:param pr_number: Pull request number
|
||||
:param repo: Repository name (e.g., "frappe/frappe")
|
||||
:param endpoint: Additional API endpoint (e.g., "files")
|
||||
:return: JSON response from GitHub API
|
||||
"""
|
||||
api_url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}"
|
||||
if endpoint:
|
||||
api_url += f"/{endpoint}"
|
||||
|
||||
res = req(api_url)
|
||||
return json.loads(res.read().decode("utf8"))
|
||||
|
||||
|
||||
def req(url):
|
||||
"Simple resilient request call to handle rate limits."
|
||||
"""
|
||||
Make a resilient request to handle rate limits.
|
||||
|
||||
:param url: URL to request
|
||||
:return: URLResponse object
|
||||
"""
|
||||
headers = None
|
||||
token = os.environ.get("GITHUB_TOKEN")
|
||||
if token:
|
||||
|
|
@ -42,28 +73,53 @@ def req(url):
|
|||
|
||||
|
||||
def get_files_list(pr_number, repo="frappe/frappe"):
|
||||
"""
|
||||
Get list of files changed in the pull request.
|
||||
|
||||
:param pr_number: Pull request number
|
||||
:param repo: Repository name
|
||||
:return: List of changed file names
|
||||
"""
|
||||
return [change["filename"] for change in fetch_pr_data(pr_number, repo, "files")]
|
||||
|
||||
|
||||
def get_output(command, shell=True):
|
||||
"""
|
||||
Execute a shell command and return its output.
|
||||
|
||||
:param command: Command to execute
|
||||
:param shell: Whether to use shell
|
||||
:return: Command output as string
|
||||
"""
|
||||
print(command)
|
||||
command = shlex.split(command)
|
||||
return subprocess.check_output(command, shell=shell, encoding="utf8").strip()
|
||||
|
||||
|
||||
def has_skip_ci_label(pr_number, repo="frappe/frappe"):
|
||||
"""Check if the PR has the 'Skip CI' label."""
|
||||
return has_label(pr_number, "Skip CI", repo)
|
||||
|
||||
|
||||
def has_run_server_tests_label(pr_number, repo="frappe/frappe"):
|
||||
"""Check if the PR has the 'Run Server Tests' label."""
|
||||
return has_label(pr_number, "Run Server Tests", repo)
|
||||
|
||||
|
||||
def has_run_ui_tests_label(pr_number, repo="frappe/frappe"):
|
||||
"""Check if the PR has the 'Run UI Tests' label."""
|
||||
return has_label(pr_number, "Run UI Tests", repo)
|
||||
|
||||
|
||||
def has_label(pr_number, label, repo="frappe/frappe"):
|
||||
"""
|
||||
Check if the pull request has a specific label.
|
||||
|
||||
:param pr_number: Pull request number
|
||||
:param label: Label to check for
|
||||
:param repo: Repository name
|
||||
:return: Boolean indicating presence of label
|
||||
"""
|
||||
return any(
|
||||
[
|
||||
fetched_label["name"]
|
||||
|
|
@ -74,19 +130,22 @@ def has_label(pr_number, label, repo="frappe/frappe"):
|
|||
|
||||
|
||||
def is_server_side_code(file):
|
||||
"""File exclusively affects server side code"""
|
||||
"""Check if the file is server-side code (Python or .po files)."""
|
||||
return file.endswith("py") or file.endswith(".po")
|
||||
|
||||
|
||||
def is_ci(file):
|
||||
"""Check if the file is related to CI configuration."""
|
||||
return ".github" in file
|
||||
|
||||
|
||||
def is_frontend_code(file):
|
||||
"""Check if the file is frontend code."""
|
||||
return file.lower().endswith((".css", ".scss", ".less", ".sass", ".styl", ".js", ".ts", ".vue", ".html"))
|
||||
|
||||
|
||||
def is_docs(file):
|
||||
"""Check if the file is documentation or image."""
|
||||
regex = re.compile(r"\.(md|png|jpg|jpeg|csv|svg)$|^.github|LICENSE")
|
||||
return bool(regex.search(file))
|
||||
|
||||
|
|
@ -97,23 +156,26 @@ if __name__ == "__main__":
|
|||
pr_number = os.environ.get("PR_NUMBER")
|
||||
repo = os.environ.get("REPO_NAME")
|
||||
|
||||
# this is a push build, run all builds
|
||||
# If it's a push build, run all builds
|
||||
if not pr_number:
|
||||
os.system('echo "build=strawberry" >> $GITHUB_OUTPUT')
|
||||
sys.exit(0)
|
||||
|
||||
# Get list of changed files if not provided
|
||||
files_list = files_list or get_files_list(pr_number=pr_number, repo=repo)
|
||||
|
||||
if not files_list:
|
||||
print("No files' changes detected. Build is shutting")
|
||||
sys.exit(0)
|
||||
|
||||
# Analyze changed files
|
||||
ci_files_changed = any(f for f in files_list if is_ci(f))
|
||||
only_docs_changed = len(list(filter(is_docs, files_list))) == len(files_list)
|
||||
only_frontend_code_changed = len(list(filter(is_frontend_code, files_list))) == len(files_list)
|
||||
updated_py_file_count = len(list(filter(is_server_side_code, files_list)))
|
||||
only_py_changed = updated_py_file_count == len(files_list)
|
||||
|
||||
# Check for Skip CI label and other conditions
|
||||
if has_skip_ci_label(pr_number, repo):
|
||||
if build_type == "ui" and has_run_ui_tests_label(pr_number, repo):
|
||||
print("Running UI tests only.")
|
||||
|
|
@ -122,14 +184,11 @@ if __name__ == "__main__":
|
|||
else:
|
||||
print("Found `Skip CI` label on pr, stopping build process.")
|
||||
sys.exit(0)
|
||||
|
||||
elif ci_files_changed:
|
||||
print("CI related files were updated, running all build processes.")
|
||||
|
||||
elif only_docs_changed:
|
||||
print("Only docs were updated, stopping build process.")
|
||||
sys.exit(0)
|
||||
|
||||
elif (
|
||||
only_frontend_code_changed
|
||||
and build_type == "server"
|
||||
|
|
@ -137,9 +196,9 @@ if __name__ == "__main__":
|
|||
):
|
||||
print("Only Frontend code was updated; Stopping Python build process.")
|
||||
sys.exit(0)
|
||||
|
||||
elif build_type == "ui" and only_py_changed and not has_run_ui_tests_label(pr_number, repo):
|
||||
print("Only Python code was updated, stopping Cypress build process.")
|
||||
sys.exit(0)
|
||||
|
||||
# If we reach here, run the build
|
||||
os.system('echo "build=strawberry" >> $GITHUB_OUTPUT')
|
||||
|
|
|
|||
2
.github/helper/update_pot_file.sh
vendored
|
|
@ -27,7 +27,7 @@ branch_name="pot_${BASE_BRANCH}_${isodate}"
|
|||
git checkout -b "${branch_name}"
|
||||
|
||||
echo "Commiting changes..."
|
||||
git add .
|
||||
git add frappe/locale/main.pot
|
||||
git commit -m "chore: update POT file"
|
||||
|
||||
gh auth setup-git
|
||||
|
|
|
|||
BIN
.github/hero-image.png
vendored
Normal file
|
After Width: | Height: | Size: 949 KiB |
4
.github/stale.yml
vendored
|
|
@ -10,6 +10,7 @@ daysUntilClose: 3
|
|||
# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable
|
||||
exemptLabels:
|
||||
- hotfix
|
||||
- no-stale
|
||||
|
||||
# Set to true to ignore issues in a project (defaults to false)
|
||||
exemptProjects: false
|
||||
|
|
@ -17,6 +18,9 @@ exemptProjects: false
|
|||
# Set to true to ignore issues in a milestone (defaults to false)
|
||||
exemptMilestones: true
|
||||
|
||||
# Skip the stale action for draft PRs
|
||||
exemptDraftPr: true
|
||||
|
||||
# Label to use when marking as stale
|
||||
staleLabel: inactive
|
||||
|
||||
|
|
|
|||
201
.github/workflows/_base-migration.yml
vendored
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
name: Migration Base
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
pre:
|
||||
required: false
|
||||
type: string
|
||||
fake-success:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
python-version:
|
||||
required: false
|
||||
type: string
|
||||
default: '3.10'
|
||||
node-version:
|
||||
required: false
|
||||
type: number
|
||||
default: 20
|
||||
db-artifact-url:
|
||||
required: false
|
||||
type: string
|
||||
jobs:
|
||||
migration-test:
|
||||
name: Migrate
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.fake-success == false }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
env:
|
||||
PYTHONWARNINGS: "ignore"
|
||||
DB_ROOT_PASSWORD: db_root
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
node-version: ${{ inputs.node-version }}
|
||||
disable-socketio: true
|
||||
disable-web: true
|
||||
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
|
||||
|
||||
- name: Execute pre-migration tasks
|
||||
if: inputs.pre
|
||||
run: |
|
||||
${{ inputs.pre }}
|
||||
|
||||
- name: Download database artifact
|
||||
env:
|
||||
DB_ARTIFACT_URL: ${{ inputs.db-artifact-url }}
|
||||
run: |
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
wget "$DB_ARTIFACT_URL"
|
||||
bench --site test_site --force restore ${GITHUB_WORKSPACE}/$(basename "$DB_ARTIFACT_URL")
|
||||
|
||||
function update_to_version() {
|
||||
version="$1"
|
||||
if [ -z "$version" ]; then
|
||||
base_ref="${{ github.base_ref || github.ref_name }}"
|
||||
head_ref="${{ github.sha }}"
|
||||
else
|
||||
base_ref="version-$version-hotfix"
|
||||
head_ref="version-$version-hotfix"
|
||||
fi
|
||||
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
echo "Updating to version ${version:-$head_ref}"
|
||||
|
||||
# Fetch and checkout branches
|
||||
for app in ${GITHUB_WORKSPACE}/apps/*/; do
|
||||
app_name=$(basename "$app")
|
||||
echo "Processing app: $app_name"
|
||||
|
||||
if [[ "$app_name" == "${{ github.event.repository.name }}" ]]; then
|
||||
git -C "$app" fetch --depth 1 origin $head_ref:$head_ref
|
||||
if git -C "$app" checkout --quiet --force $head_ref; then
|
||||
echo "Checked out $head_ref successfully at $app"
|
||||
else
|
||||
echo "Failed to checkout $ref at $app" >&2
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
git -C "$app" fetch --depth 1 origin $base_ref:$base_ref
|
||||
if git -C "$app" checkout --quiet --force $base_ref; then
|
||||
echo "Checked out $base_ref successfully at $app"
|
||||
else
|
||||
echo "Failed to checkout $base_ref at $app" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# Resetup env and install apps
|
||||
if pgrep honcho > /dev/null; then
|
||||
echo "Stopping honcho process..."
|
||||
pgrep honcho | xargs kill
|
||||
sleep 10
|
||||
fi
|
||||
|
||||
echo "Setting up environment..."
|
||||
if rm -rf ${GITHUB_WORKSPACE}/env && python -m venv ${GITHUB_WORKSPACE}/env; then
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
pip install --quiet --upgrade pip
|
||||
pip install --quiet frappe-bench
|
||||
echo "Environment setup completed."
|
||||
else
|
||||
echo "Environment setup failed." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Installing apps..."
|
||||
for app in ${GITHUB_WORKSPACE}/apps/*/; do
|
||||
if pip install --upgrade -e "$app"; then
|
||||
echo "Installed $app successfully."
|
||||
else
|
||||
echo "Failed to install $app." >&2
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Starting bench..."
|
||||
bench start &>> ${GITHUB_WORKSPACE}/bench_start.log &
|
||||
|
||||
echo "Running migrations on test_site..."
|
||||
if bench --site test_site migrate; then
|
||||
echo "Migration completed successfully."
|
||||
else
|
||||
echo "Migration failed." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "Update to version ${version:-$base_ref} completed."
|
||||
}
|
||||
|
||||
# Save this script into a file for later use.
|
||||
declare -f update_to_version > "$RUNNER_TEMP/migrate"
|
||||
|
||||
- name: Update to v14
|
||||
run: |
|
||||
source $RUNNER_TEMP/migrate
|
||||
update_to_version 14
|
||||
exit $?
|
||||
|
||||
- name: Update to v15
|
||||
run: |
|
||||
source $RUNNER_TEMP/migrate
|
||||
update_to_version 15
|
||||
exit $?
|
||||
|
||||
- name: Update to last commit
|
||||
run: |
|
||||
source $RUNNER_TEMP/migrate
|
||||
update_to_version
|
||||
exit $?
|
||||
bench --site test_site execute frappe.tests.utils.check_orpahned_doctypes
|
||||
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
if: ${{ failure() && contains( github.event.pull_request.labels.*.name, 'debug-gha') }}
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
cat bench_start.log || true
|
||||
cd logs
|
||||
for f in ${GITHUB_WORKSPACE}/*.log*; do
|
||||
echo "Printing log: $f";
|
||||
cat $f
|
||||
done
|
||||
|
||||
# TIP: Use these for checks, e.g. Migration / Success
|
||||
success:
|
||||
name: Success
|
||||
needs: [migration-test]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Migration '${{ needs.migration-test.result }}'
|
||||
shell: python
|
||||
run: |
|
||||
stati = [
|
||||
'${{ needs.migration-test.result }}',
|
||||
]
|
||||
|
||||
nopass = ["failure", "cancelled"]
|
||||
dopass = ["success", "skipped"]
|
||||
if any(r in nopass for r in stati):
|
||||
exit(1)
|
||||
if all(r in dopass for r in stati):
|
||||
exit(0)
|
||||
exit(1)
|
||||
184
.github/workflows/_base-server-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
name: Server Base
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
fake-success:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
python-version:
|
||||
required: false
|
||||
type: string
|
||||
default: '3.13'
|
||||
node-version:
|
||||
required: false
|
||||
type: number
|
||||
default: 20
|
||||
parallel-runs:
|
||||
required: false
|
||||
type: number
|
||||
default: 2
|
||||
enable-postgres:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
enable-coverage:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
|
||||
jobs:
|
||||
unit-test:
|
||||
name: Unit
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- id: placeholder
|
||||
run: |
|
||||
echo "Evolution towards a set of (fast) unit tests which run without a DB connection is being planned"
|
||||
gen-idx-integration:
|
||||
name: Gen Integration Test Matrix
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.fake-success == false }}
|
||||
outputs:
|
||||
indices: ${{ steps.set-indices.outputs.indices }}
|
||||
steps:
|
||||
- id: set-indices
|
||||
run: |
|
||||
indices=$(seq -s ',' 1 ${{ inputs.parallel-runs }}); echo "indices=[${indices}]" >> $GITHUB_OUTPUT
|
||||
|
||||
integration-test:
|
||||
needs: gen-idx-integration
|
||||
name: Integration
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.fake-success == false }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
PYTHONOPTIMIZE: 2
|
||||
# noisy 3rd party library warnings
|
||||
PYTHONWARNINGS: "module,ignore:::babel.messages.extract"
|
||||
DB_ROOT_PASSWORD: db_root
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db: ${{ fromJson(inputs.enable-postgres && '["mariadb", "postgres"]' || '["mariadb"]') }}
|
||||
index: ${{ fromJson(needs.gen-idx-integration.outputs.indices) }}
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
postgres:
|
||||
image: postgres:12.4
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
env:
|
||||
POSTGRES_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
smtp_server:
|
||||
image: rnwood/smtp4dev
|
||||
ports:
|
||||
- 2525:25
|
||||
- 3000:80
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
node-version: ${{ inputs.node-version }}
|
||||
disable-socketio: true
|
||||
enable-coverage: ${{ inputs.enable-coverage }}
|
||||
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
|
||||
db: ${{ matrix.db }}
|
||||
env:
|
||||
PYTHONWARNINGS: "ignore:Unimplemented abstract methods {'locate_file'}:DeprecationWarning"
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
bench --site test_site \
|
||||
run-parallel-tests \
|
||||
--app "${{ github.event.repository.name }}" \
|
||||
--total-builds ${{ inputs.parallel-runs }} \
|
||||
--build-number ${{ matrix.index }} 2> >(tee -a stderr.log >&2)
|
||||
|
||||
# Process warnings and create annotations
|
||||
if [ -s stderr.log ] && [ "$DB" == "mariadb" ]; then
|
||||
echo "Processing deprecation warnings..."
|
||||
grep -E "DeprecationWarning" stderr.log | sort -u | while read -r warning; do
|
||||
# Extract file path, line number, and warning type
|
||||
file_info=$(echo "$warning" | grep -oP '^.*?:\d+:')
|
||||
file_path=$(echo "$file_info" | cut -d':' -f1)
|
||||
line_number=$(echo "$file_info" | cut -d':' -f2)
|
||||
warning_type=$(echo "$warning" | grep -oP '\w+Warning')
|
||||
|
||||
# Extract the actual warning message
|
||||
message=$(echo "$warning" | sed -E "s/^.*$warning_type: //")
|
||||
|
||||
# Create the annotation
|
||||
echo "::warning file=${file_path},line=${line_number}::${warning_type}: ${message}"
|
||||
done
|
||||
else
|
||||
echo "No deprecation warnings found."
|
||||
fi
|
||||
env:
|
||||
DB: ${{ matrix.db }}
|
||||
# consumed by bench run-parallel-tests
|
||||
CAPTURE_COVERAGE: ${{ inputs.enable-coverage }}
|
||||
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN || '' }}
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v3
|
||||
if: inputs.enable-coverage
|
||||
with:
|
||||
name: coverage-${{ matrix.db }}-${{ matrix.index }}
|
||||
path: ./sites/*-coverage*.xml
|
||||
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
if: ${{ failure() && contains( github.event.pull_request.labels.*.name, 'debug-gha') }}
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
cat bench_start.log || true
|
||||
cd logs
|
||||
for f in ${GITHUB_WORKSPACE}/*.log*; do
|
||||
echo "Printing log: $f";
|
||||
cat $f
|
||||
done
|
||||
|
||||
|
||||
# TIP: Use these for checks, e.g. Server / Tests / Success
|
||||
success:
|
||||
name: Success
|
||||
needs: [unit-test, integration-test]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Unit '${{ needs.unit-test.result }}' / Integration '${{ needs.integration-test.result }}'
|
||||
shell: python
|
||||
run: |
|
||||
stati = [
|
||||
'${{ needs.unit-test.result }}',
|
||||
'${{ needs.integration-test.result }}',
|
||||
]
|
||||
|
||||
nopass = ["failure", "cancelled"]
|
||||
dopass = ["success", "skipped"]
|
||||
if any(r in nopass for r in stati):
|
||||
exit(1)
|
||||
if all(r in dopass for r in stati):
|
||||
exit(0)
|
||||
exit(1)
|
||||
|
||||
90
.github/workflows/_base-type-check.yml
vendored
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
name: Type Check Base
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
python-version:
|
||||
required: false
|
||||
type: string
|
||||
default: '3.13.0'
|
||||
|
||||
jobs:
|
||||
typecheck:
|
||||
name: Check
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: npm install toml
|
||||
- name: Get pyproject.toml
|
||||
uses: actions/github-script@v7
|
||||
id: get-pyproject
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const ref = context.payload.pull_request ? context.payload.pull_request.head.sha : context.sha;
|
||||
const { data: pyprojectContent } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path: 'pyproject.toml',
|
||||
ref: ref
|
||||
});
|
||||
const content = Buffer.from(pyprojectContent.content, 'base64').toString();
|
||||
const toml = require('toml');
|
||||
const parsed = toml.parse(content);
|
||||
const mypyFiles = parsed.tool?.mypy?.files ?? [];
|
||||
return { mypyFiles, content };
|
||||
|
||||
- name: Check for changes in mypy files
|
||||
uses: actions/github-script@v7
|
||||
id: check-changes
|
||||
with:
|
||||
github-token: ${{secrets.GITHUB_TOKEN}}
|
||||
script: |
|
||||
const { mypyFiles } = ${{ steps.get-pyproject.outputs.result }};
|
||||
|
||||
let changedMypyFiles = [];
|
||||
|
||||
if (context.payload.pull_request) {
|
||||
const { data: changedFiles } = await github.rest.pulls.listFiles({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: context.payload.pull_request.number
|
||||
});
|
||||
changedMypyFiles = changedFiles
|
||||
.filter(file => mypyFiles.includes(file.filename))
|
||||
.map(file => file.filename);
|
||||
} else {
|
||||
// If not a pull request, assume all mypy files are changed
|
||||
changedMypyFiles = mypyFiles;
|
||||
}
|
||||
return changedMypyFiles.length > 0;
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.check-changes.outputs.result == 'true'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
if: steps.check-changes.outputs.result == 'true'
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
if: steps.check-changes.outputs.result == 'true'
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-typecheck-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-typecheck-
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check-changes.outputs.result == 'true'
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -e .[dev,test]
|
||||
|
||||
- name: Run mypy
|
||||
if: steps.check-changes.outputs.result == 'true'
|
||||
run: |
|
||||
mypy --version
|
||||
mypy
|
||||
169
.github/workflows/_base-ui-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
name: UI Base
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
fake-success:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
python-version:
|
||||
required: false
|
||||
type: string
|
||||
default: '3.13'
|
||||
node-version:
|
||||
required: false
|
||||
type: number
|
||||
default: 20
|
||||
parallel-runs:
|
||||
required: false
|
||||
type: number
|
||||
default: 2
|
||||
enable-coverage:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
|
||||
jobs:
|
||||
gen-idx-integration:
|
||||
name: Gen Integration Test Matrix
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.fake-success == false }}
|
||||
outputs:
|
||||
indices: ${{ steps.set-indices.outputs.indices }}
|
||||
steps:
|
||||
- id: set-indices
|
||||
run: |
|
||||
indices=$(seq -s ',' 1 ${{ inputs.parallel-runs }}); echo "indices=[${indices}]" >> $GITHUB_OUTPUT
|
||||
|
||||
ui-test:
|
||||
needs: gen-idx-integration
|
||||
name: Integration
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ inputs.fake-success == false }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
PYTHONOPTIMIZE: 2
|
||||
# noisy 3rd party library warnings
|
||||
PYTHONWARNINGS: "ignore"
|
||||
DB_ROOT_PASSWORD: db_root
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db: ["mariadb"]
|
||||
index: ${{ fromJson(needs.gen-idx-integration.outputs.indices) }}
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: ${{ env.DB_ROOT_PASSWORD }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
name: Environment Setup
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
node-version: ${{ inputs.node-version }}
|
||||
build-assets: true
|
||||
enable-coverage: ${{ inputs.enable-coverage }}
|
||||
db-root-password: ${{ env.DB_ROOT_PASSWORD }}
|
||||
db: ${{ matrix.db }}
|
||||
|
||||
- name: Verify yarn.lock
|
||||
run: |
|
||||
cd ${GITHUB_WORKSPACE}/apps/${{ github.event.repository.name }}
|
||||
git diff --exit-code yarn.lock
|
||||
|
||||
- name: Cache cypress binary
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/Cypress
|
||||
key: ${{ runner.os }}-cypress
|
||||
|
||||
- name: Instrument Source Code
|
||||
run: |
|
||||
cd ${GITHUB_WORKSPACE}/apps/${{ github.event.repository.name }}
|
||||
npx nyc instrument \
|
||||
-x '${{ github.event.repository.name }}/public/dist/**' \
|
||||
-x '${{ github.event.repository.name }}/public/js/lib/**' \
|
||||
-x '**/*.bundle.js' --compact=false --in-place ${{ github.event.repository.name }}
|
||||
|
||||
- name: Site Setup
|
||||
run: |
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
bench --site test_site execute frappe.utils.install.complete_setup_wizard
|
||||
bench --site test_site execute frappe.tests.ui_test_helpers.create_test_user
|
||||
|
||||
- name: Run Tests
|
||||
run: |
|
||||
source ${GITHUB_WORKSPACE}/env/bin/activate
|
||||
bench --site test_site \
|
||||
run-ui-tests ${{ github.event.repository.name }} \
|
||||
--with-coverage \
|
||||
--headless \
|
||||
--parallel \
|
||||
--ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb
|
||||
|
||||
- name: Stop server and wait for coverage file
|
||||
if: inputs.enable-coverage
|
||||
run: |
|
||||
ps -ef | grep "[f]rappe serve" | awk '{print $2}' | xargs kill -s SIGINT
|
||||
sleep 5
|
||||
( tail -f ${GITHUB_WORKSPACE}/sites/*-coverage*.xml & ) | grep -q "\/coverage"
|
||||
|
||||
- name: Upload JS coverage data
|
||||
uses: actions/upload-artifact@v3
|
||||
if: inputs.enable-coverage
|
||||
with:
|
||||
name: coverage-js-${{ matrix.index }}
|
||||
path: ./apps/${{ github.event.repository.name }}/.cypress-coverage/clover.xml
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v3
|
||||
if: inputs.enable-coverage
|
||||
with:
|
||||
name: coverage-py-${{ matrix.index }}
|
||||
path: ./sites/*-coverage*.xml
|
||||
|
||||
- name: Setup tmate session
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
if: ${{ failure() && contains( github.event.pull_request.labels.*.name, 'debug-gha') }}
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
cat bench_start.log || true
|
||||
cd logs
|
||||
for f in ${GITHUB_WORKSPACE}/*.log*; do
|
||||
echo "Printing log: $f";
|
||||
cat $f
|
||||
done
|
||||
|
||||
# TIP: Use these for checks, e.g. UI / Tests (Cypress) / Success
|
||||
success:
|
||||
name: Success
|
||||
needs: [ui-test]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: UI Test '${{ needs.ui-test.result }}'
|
||||
shell: python
|
||||
run: |
|
||||
stati = [
|
||||
'${{ needs.ui-test.result }}',
|
||||
]
|
||||
|
||||
nopass = ["failure", "cancelled"]
|
||||
dopass = ["success", "skipped"]
|
||||
if any(r in nopass for r in stati):
|
||||
exit(1)
|
||||
if all(r in dopass for r in stati):
|
||||
exit(0)
|
||||
exit(1)
|
||||
6
.github/workflows/generate-pot-file.yml
vendored
|
|
@ -6,8 +6,8 @@ on:
|
|||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
regeneratee-pot-file:
|
||||
name: Release
|
||||
regenerate-pot-file:
|
||||
name: Regenerate POT file
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
|
|
@ -25,7 +25,7 @@ jobs:
|
|||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Run script to update POT file
|
||||
run: |
|
||||
|
|
|
|||
21
.github/workflows/linters.yml
vendored
|
|
@ -24,7 +24,7 @@ jobs:
|
|||
fetch-depth: 200
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
|
||||
- name: Check commit titles
|
||||
|
|
@ -41,7 +41,7 @@ jobs:
|
|||
- name: 'Setup Environment'
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.13'
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Validate Docs
|
||||
|
|
@ -60,7 +60,7 @@ jobs:
|
|||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.13'
|
||||
cache: pip
|
||||
|
||||
- name: Download Semgrep rules
|
||||
|
|
@ -78,7 +78,7 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.13'
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
|
|
@ -96,3 +96,16 @@ jobs:
|
|||
pip install pip-audit
|
||||
cd ${GITHUB_WORKSPACE}
|
||||
pip-audit --desc on .
|
||||
|
||||
precommit:
|
||||
name: 'Pre-Commit'
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
cache: pip
|
||||
- uses: pre-commit/action@v3.0.1
|
||||
|
|
|
|||
4
.github/workflows/on_release.yml
vendored
|
|
@ -22,11 +22,11 @@ jobs:
|
|||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
python-version: '3.13'
|
||||
- name: Set up bench and build assets
|
||||
run: |
|
||||
npm install -g yarn
|
||||
|
|
|
|||
170
.github/workflows/patch-mariadb-tests.yml
vendored
|
|
@ -1,170 +0,0 @@
|
|||
name: Patch (MariaDB)
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: patch-mariadb-develop-${{ github.event_name }}-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
# Do not change this as GITHUB_TOKEN is being used by roulette
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
checkrun:
|
||||
name: Build Check
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
build: ${{ steps.check-build.outputs.build }}
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if build should be run
|
||||
id: check-build
|
||||
run: |
|
||||
python "${GITHUB_WORKSPACE}/.github/helper/roulette.py"
|
||||
env:
|
||||
TYPE: "server"
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test:
|
||||
name: Patch
|
||||
runs-on: ubuntu-latest
|
||||
needs: checkrun
|
||||
if: ${{ needs.checkrun.outputs.build == 'strawberry' }}
|
||||
timeout-minutes: 60
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: travis
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check for Merge Conflicts
|
||||
run: |
|
||||
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
|
||||
then echo "Found merge conflicts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.10"
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
|
||||
pip install frappe-bench
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
|
||||
AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
|
||||
TYPE: server
|
||||
DB: mariadb
|
||||
|
||||
- name: Run Patch Tests
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
sed -i 's/^worker:/# worker:/g' Procfile
|
||||
wget https://frappeframework.com/files/v13-frappe.sql.gz
|
||||
bench --site test_site --force restore ~/frappe-bench/v13-frappe.sql.gz
|
||||
|
||||
source env/bin/activate
|
||||
cd apps/frappe/
|
||||
git remote set-url upstream https://github.com/frappe/frappe.git
|
||||
|
||||
function update_to_version() {
|
||||
version=$1
|
||||
|
||||
branch_name="version-$version-hotfix"
|
||||
echo "Updating to v$version"
|
||||
git fetch --depth 1 upstream $branch_name:$branch_name
|
||||
git checkout -q -f $branch_name
|
||||
|
||||
pgrep honcho | xargs kill
|
||||
sleep 3
|
||||
rm -rf ~/frappe-bench/env
|
||||
bench -v setup env
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
|
||||
bench --site test_site migrate
|
||||
}
|
||||
|
||||
update_to_version 14
|
||||
update_to_version 15
|
||||
|
||||
echo "Updating to last commit"
|
||||
pgrep honcho | xargs kill
|
||||
sleep 3
|
||||
rm -rf ~/frappe-bench/env
|
||||
git checkout -q -f "$GITHUB_SHA"
|
||||
bench -v setup env
|
||||
bench start &>> ~/frappe-bench/bench_start.log &
|
||||
bench --site test_site migrate
|
||||
bench --site test_site execute frappe.tests.utils.check_orpahned_doctypes
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
cd ~/frappe-bench
|
||||
cat bench_start.log || true
|
||||
cd logs
|
||||
for f in ./*.log*; do
|
||||
echo "Printing log: $f";
|
||||
cat $f
|
||||
done
|
||||
|
||||
faux-test:
|
||||
name: Patch
|
||||
runs-on: ubuntu-latest
|
||||
needs: checkrun
|
||||
if: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||
|
||||
steps:
|
||||
- name: Pass skipped tests unconditionally
|
||||
run: "echo Skipped"
|
||||
26
.github/workflows/pre-commit.yml
vendored
|
|
@ -1,26 +0,0 @@
|
|||
name: Pre-commit
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: precommit-frappe-${{ github.event_name }}-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
linter:
|
||||
name: 'precommit'
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'pull_request'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.10'
|
||||
cache: pip
|
||||
- uses: pre-commit/action@v3.0.1
|
||||
4
.github/workflows/publish-assets-develop.yml
vendored
|
|
@ -16,10 +16,10 @@ jobs:
|
|||
path: 'frappe'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
python-version: '3.13'
|
||||
- name: Set up bench and build assets
|
||||
run: |
|
||||
npm install -g yarn
|
||||
|
|
|
|||
138
.github/workflows/run-indinvidual-tests.yml
vendored
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
name: Individual
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: server-individual-tests-develop-${{ github.event_name }}-${{ github.event.number || github.event_name == 'workflow_dispatch' && github.run_id || '' }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
discover:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
- id: set-matrix
|
||||
run: |
|
||||
# Use grep and find to get the list of test files
|
||||
matrix=$(find . -path '*/doctype/*/test_*.py' | xargs grep -l 'def test_' | awk '{
|
||||
# Remove ./ prefix, file extension, and replace / with .
|
||||
gsub(/^\.\//, "", $0)
|
||||
gsub(/\.py$/, "", $0)
|
||||
gsub(/\//, ".", $0)
|
||||
# Add to array
|
||||
tests[NR] = $0
|
||||
}
|
||||
END {
|
||||
# Start JSON array
|
||||
printf "{\n \"include\": [\n"
|
||||
# Loop through array and create JSON objects
|
||||
for (i=1; i<=NR; i++) {
|
||||
printf " {\"test\": \"%s\"}", tests[i]
|
||||
if (i < NR) printf ","
|
||||
printf "\n"
|
||||
}
|
||||
# Close JSON array
|
||||
printf " ]\n}"
|
||||
}')
|
||||
|
||||
# Output the matrix
|
||||
echo "matrix=$(echo "$matrix" | jq -c)" >> $GITHUB_OUTPUT
|
||||
|
||||
# For debugging (optional)
|
||||
echo "Generated matrix:"
|
||||
echo "$matrix"
|
||||
test:
|
||||
needs: discover
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{fromJson(needs.discover.outputs.matrix)}}
|
||||
|
||||
name: Test
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mariadb:11.3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: db_root
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.13'
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Cache node modules
|
||||
uses: actions/cache@v4
|
||||
env:
|
||||
cache-name: cache-node-modules
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-build-${{ env.cache-name }}-
|
||||
${{ runner.os }}-build-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "::set-output name=dir::$(yarn cache dir)"
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
|
||||
|
||||
- name: Init Bench
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install_bench.sh
|
||||
env:
|
||||
TYPE: server
|
||||
- name: Init Test Site
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install_site.sh
|
||||
env:
|
||||
TYPE: server
|
||||
DB: mariadb
|
||||
|
||||
- name: Run Tests
|
||||
run: 'cd ~/frappe-bench/ && bench --site test_site run-tests --app frappe --module ${{ matrix.test }}'
|
||||
209
.github/workflows/server-tests.yml
vendored
|
|
@ -1,6 +1,8 @@
|
|||
name: Server
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [frappe-framework-change]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
|
|
@ -8,7 +10,7 @@ on:
|
|||
- cron: "0 0 * * *"
|
||||
|
||||
concurrency:
|
||||
group: server-develop-${{ github.event_name }}-${{ github.event.number }}
|
||||
group: server-${{ github.event_name }}-${{ github.event.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
|
|
@ -16,18 +18,20 @@ permissions:
|
|||
contents: read
|
||||
|
||||
jobs:
|
||||
checkrun:
|
||||
name: Build Check
|
||||
runs-on: ubuntu-latest
|
||||
typecheck:
|
||||
name: Types
|
||||
uses: ./.github/workflows/_base-type-check.yml
|
||||
|
||||
checkrun:
|
||||
name: Plan Tests
|
||||
runs-on: ubuntu-latest
|
||||
needs: typecheck
|
||||
outputs:
|
||||
build: ${{ steps.check-build.outputs.build }}
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check if build should be run
|
||||
- name: Check if unit tests should be run
|
||||
id: check-build
|
||||
run: |
|
||||
python "${GITHUB_WORKSPACE}/.github/helper/roulette.py"
|
||||
|
|
@ -36,175 +40,62 @@ jobs:
|
|||
PR_NUMBER: ${{ github.event.number }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
name: Tests
|
||||
uses: ./.github/workflows/_base-server-tests.yml
|
||||
with:
|
||||
enable-postgres: true # This will test against both MariaDB and PostgreSQL
|
||||
parallel-runs: 2
|
||||
enable-coverage: ${{ github.event_name != 'pull_request' }}
|
||||
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||
needs: checkrun
|
||||
if: ${{ needs.checkrun.outputs.build == 'strawberry' }}
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
secrets: inherit
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db: ["mariadb", "postgres"]
|
||||
container: [1, 2]
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: travis
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
postgres:
|
||||
image: postgres:12.4
|
||||
env:
|
||||
POSTGRES_PASSWORD: travis
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
ports:
|
||||
- 5432:5432
|
||||
|
||||
smtp_server:
|
||||
image: rnwood/smtp4dev
|
||||
ports:
|
||||
- 2525:25
|
||||
- 3000:80
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
python -m compileall -q -f "${GITHUB_WORKSPACE}"
|
||||
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
|
||||
then echo "Found merge conflicts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: |
|
||||
echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
|
||||
AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
|
||||
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
TYPE: server
|
||||
DB: ${{ matrix.db }}
|
||||
|
||||
- name: Run Tests
|
||||
run: ../env/bin/python3 ../apps/frappe/.github/helper/ci.py
|
||||
working-directory: /home/runner/frappe-bench/sites
|
||||
env:
|
||||
SITE: test_site
|
||||
CI_BUILD_ID: ${{ github.run_id }}
|
||||
BUILD_NUMBER: ${{ matrix.container }}
|
||||
WITH_COVERAGE: ${{ github.event_name != 'pull_request' }}
|
||||
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
TOTAL_BUILDS: 2
|
||||
COVERAGE_RCFILE: /home/runner/frappe-bench/apps/frappe/.coveragerc
|
||||
- name: Setup tmate session
|
||||
|
||||
uses: mxschmitt/action-tmate@v3
|
||||
if: ${{ failure() && contains( github.event.pull_request.labels.*.name, 'debug-gha') }}
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
cd ~/frappe-bench
|
||||
cat bench_start.log || true
|
||||
cd logs
|
||||
for f in ./*.log*; do
|
||||
echo "Printing log: $f";
|
||||
cat $f
|
||||
done
|
||||
|
||||
|
||||
- name: Upload coverage data
|
||||
uses: actions/upload-artifact@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: coverage-${{ matrix.db }}-${{ matrix.container }}
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
# This is required because github still doesn't understand knowingly skipped tests
|
||||
faux-test:
|
||||
name: Unit Tests
|
||||
runs-on: ubuntu-latest
|
||||
migrate:
|
||||
name: Migration
|
||||
needs: checkrun
|
||||
if: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
db: ["mariadb", "postgres"]
|
||||
container: [1, 2]
|
||||
|
||||
steps:
|
||||
- name: Pass skipped tests unconditionally
|
||||
run: "echo Skipped"
|
||||
uses: ./.github/workflows/_base-migration.yml
|
||||
with:
|
||||
db-artifact-url: https://frappeframework.com/files/v13-frappe.sql.gz
|
||||
python-version: '3.10'
|
||||
node-version: 20
|
||||
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: [test, checkrun]
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.event_name != 'pull_request' }}
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
- name: Upload coverage data
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: Server
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
flags: server
|
||||
|
||||
dispatch:
|
||||
name: Downstream
|
||||
runs-on: "ubuntu-latest"
|
||||
needs: [test, migrate]
|
||||
if: ${{ contains( github.event.pull_request.labels.*.name, 'trigger-downstream-ci') }}
|
||||
strategy:
|
||||
matrix:
|
||||
repo:
|
||||
- frappe/erpnext
|
||||
- frappe/lending
|
||||
- frappe/hrms
|
||||
steps:
|
||||
- name: Dispatch Downstream CI (if supported)
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.CI_PAT }}
|
||||
repository: ${{ matrix.repo }}
|
||||
event-type: frappe-framework-change
|
||||
client-payload: '{"frappe_sha": "${{ github.sha }}"}'
|
||||
|
|
|
|||
178
.github/workflows/ui-tests.yml
vendored
|
|
@ -2,6 +2,8 @@ name: UI
|
|||
|
||||
on:
|
||||
pull_request:
|
||||
repository_dispatch:
|
||||
types: [frappe-framework-change]
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
# Run everday at midnight UTC / 5:30 IST
|
||||
|
|
@ -17,7 +19,7 @@ permissions:
|
|||
|
||||
jobs:
|
||||
checkrun:
|
||||
name: Build Check
|
||||
name: Plan Tests
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
outputs:
|
||||
|
|
@ -38,189 +40,39 @@ jobs:
|
|||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Tests (Cypress)
|
||||
uses: ./.github/workflows/_base-ui-tests.yml
|
||||
with:
|
||||
parallel-runs: 3
|
||||
enable-coverage: ${{ github.event_name != 'pull_request' }}
|
||||
fake-success: ${{ needs.checkrun.outputs.build != 'strawberry' }}
|
||||
needs: checkrun
|
||||
if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.repository_owner == 'frappe' }}
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Make sure you modify coverage submission file list if changing this
|
||||
container: [1, 2, 3]
|
||||
|
||||
name: UI Tests (Cypress)
|
||||
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.3
|
||||
env:
|
||||
MARIADB_ROOT_PASSWORD: travis
|
||||
ports:
|
||||
- 3306:3306
|
||||
options: --health-cmd="healthcheck.sh --connect --innodb_initialized" --health-interval=5s --health-timeout=2s --health-retries=3
|
||||
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Check for valid Python & Merge Conflicts
|
||||
run: |
|
||||
python -m compileall -q -f "${GITHUB_WORKSPACE}"
|
||||
if grep -lr --exclude-dir=node_modules "^<<<<<<< " "${GITHUB_WORKSPACE}"
|
||||
then echo "Found merge conflicts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 18
|
||||
check-latest: true
|
||||
|
||||
- name: Add to Hosts
|
||||
run: |
|
||||
echo "127.0.0.1 test_site" | sudo tee -a /etc/hosts
|
||||
|
||||
- name: Cache pip
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/pip
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('**/*requirements.txt', '**/pyproject.toml', '**/setup.py') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-
|
||||
${{ runner.os }}-
|
||||
|
||||
- name: Get yarn cache directory path
|
||||
id: yarn-cache-dir-path
|
||||
run: echo "dir=$(yarn cache dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- uses: actions/cache@v4
|
||||
id: yarn-cache
|
||||
with:
|
||||
path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
|
||||
key: ${{ runner.os }}-yarn-ui-${{ hashFiles('**/yarn.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-yarn-ui-
|
||||
|
||||
- name: Cache cypress binary
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/Cypress
|
||||
key: ${{ runner.os }}-cypress
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install_dependencies.sh
|
||||
bash ${GITHUB_WORKSPACE}/.github/helper/install.sh
|
||||
env:
|
||||
BEFORE: ${{ env.GITHUB_EVENT_PATH.before }}
|
||||
AFTER: ${{ env.GITHUB_EVENT_PATH.after }}
|
||||
FRAPPE_SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
TYPE: ui
|
||||
DB: mariadb
|
||||
|
||||
- name: Verify yarn.lock
|
||||
run: |
|
||||
cd ~/frappe-bench/apps/frappe
|
||||
git diff --exit-code yarn.lock
|
||||
|
||||
- name: Instrument Source Code
|
||||
run: cd ~/frappe-bench/apps/frappe/ && npx nyc instrument -x 'frappe/public/dist/**' -x 'frappe/public/js/lib/**' -x '**/*.bundle.js' --compact=false --in-place frappe
|
||||
|
||||
- name: Build
|
||||
run: cd ~/frappe-bench/ && bench build --apps frappe
|
||||
|
||||
- name: Site Setup
|
||||
run: |
|
||||
cd ~/frappe-bench/
|
||||
bench --site test_site execute frappe.utils.install.complete_setup_wizard
|
||||
bench --site test_site execute frappe.tests.ui_test_helpers.create_test_user
|
||||
|
||||
- name: UI Tests
|
||||
run: cd ~/frappe-bench/ && bench --site test_site run-ui-tests frappe --with-coverage --headless --parallel --ci-build-id $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT
|
||||
env:
|
||||
CYPRESS_RECORD_KEY: 4a48f41c-11b3-425b-aa88-c58048fa69eb
|
||||
|
||||
- name: Stop server and wait for coverage file
|
||||
if: github.event_name != 'pull_request'
|
||||
run: |
|
||||
ps -ef | grep "[f]rappe serve" | awk '{print $2}' | xargs kill -s SIGINT
|
||||
sleep 5
|
||||
( tail -f /home/runner/frappe-bench/sites/coverage.xml & ) | grep -q "\/coverage"
|
||||
|
||||
- name: Upload JS coverage data
|
||||
uses: actions/upload-artifact@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: coverage-js-${{ matrix.container }}
|
||||
path: /home/runner/frappe-bench/apps/frappe/.cypress-coverage/clover.xml
|
||||
|
||||
- name: Upload python coverage data
|
||||
uses: actions/upload-artifact@v3
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
name: coverage-py-${{ matrix.container }}
|
||||
path: /home/runner/frappe-bench/sites/coverage.xml
|
||||
|
||||
- name: Show bench output
|
||||
if: ${{ always() }}
|
||||
run: |
|
||||
cd ~/frappe-bench
|
||||
cat bench_start.log || true
|
||||
cd logs
|
||||
for f in ./*.log*; do
|
||||
echo "Printing log: $f";
|
||||
cat $f
|
||||
done
|
||||
|
||||
faux-test:
|
||||
runs-on: ubuntu-latest
|
||||
needs: checkrun
|
||||
if: ${{ needs.checkrun.outputs.build != 'strawberry' && github.repository_owner == 'frappe' }}
|
||||
name: UI Tests (Cypress)
|
||||
strategy:
|
||||
matrix:
|
||||
container: [1, 2, 3]
|
||||
|
||||
steps:
|
||||
- name: Pass skipped tests unconditionally
|
||||
run: "echo Skipped"
|
||||
|
||||
coverage:
|
||||
name: Coverage Wrap Up
|
||||
needs: [test, checkrun]
|
||||
if: ${{ needs.checkrun.outputs.build == 'strawberry' && github.event_name != 'pull_request' }}
|
||||
if: ${{ github.event_name != 'pull_request' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Clone
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Download artifacts
|
||||
uses: actions/download-artifact@v3
|
||||
|
||||
uses: actions/download-artifact@v4.1.8
|
||||
- name: Upload python coverage data
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: UIBackend
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
verbose: true
|
||||
files: ./coverage-py-1/coverage.xml,./coverage-py-2/coverage.xml,./coverage-py-3/coverage.xml
|
||||
exclude: coverage-js*
|
||||
flags: server-ui
|
||||
|
||||
- name: Upload JS coverage data
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
name: Cypress
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: true
|
||||
files: ./coverage-js-1/clover.xml,./coverage-js-2/clover.xml,./coverage-js-3/clover.xml
|
||||
exclude: coverage-py*
|
||||
verbose: true
|
||||
flags: ui-tests
|
||||
|
|
|
|||
9
.gitignore
vendored
|
|
@ -194,3 +194,12 @@ cypress/videos
|
|||
|
||||
# JetBrains IDEs
|
||||
.idea/
|
||||
# Helix Editor
|
||||
.helix/
|
||||
# Aider AI Chat
|
||||
.aider*
|
||||
|
||||
# frappecloud billing
|
||||
billing/node_modules
|
||||
frappe/public/billing
|
||||
frappe/www/billing.html
|
||||
|
|
|
|||
|
|
@ -1,16 +1,15 @@
|
|||
exclude: 'node_modules|.git'
|
||||
default_stages: [commit]
|
||||
default_stages: [pre-commit]
|
||||
fail_fast: false
|
||||
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
files: "frappe.*"
|
||||
exclude: ".*json$|.*txt$|.*csv|.*md|.*svg"
|
||||
- id: check-yaml
|
||||
- id: no-commit-to-branch
|
||||
args: ['--branch', 'develop']
|
||||
- id: check-merge-conflict
|
||||
|
|
@ -19,9 +18,10 @@ repos:
|
|||
- id: check-toml
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
exclude: ^frappe/tests/classes/context_managers\.py$
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.2.0
|
||||
rev: v0.8.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
name: "Run ruff import sorter"
|
||||
|
|
|
|||
182
README.md
|
|
@ -1,82 +1,136 @@
|
|||
<div align="center">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/frappe-framework-logo-dark.svg">
|
||||
<img src=".github/frappe-framework-logo.svg" height="50">
|
||||
</picture>
|
||||
<h3>
|
||||
a web framework with <a href="https://www.youtube.com/watch?v=LOjk3m0wTwg">"batteries included"</a>
|
||||
</h3>
|
||||
<h5>
|
||||
it's pronounced - <em>fra-pay</em>
|
||||
</h5>
|
||||
<div align="center" markdown="1">
|
||||
<img src=".github/framework-logo-new.svg" width="80" height="80"/>
|
||||
<h1>Frappe Framework</h1>
|
||||
|
||||
**Low Code Web Framework For Real World Applications, In Python And JavaScript**
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a target="_blank" href="#LICENSE" title="License: MIT">
|
||||
<img src="https://img.shields.io/badge/License-MIT-success.svg">
|
||||
</a>
|
||||
<a target="_blank" href="https://www.python.org/downloads/" title="Python version">
|
||||
<img src="https://img.shields.io/badge/python-%3E=_3.10-success.svg">
|
||||
</a>
|
||||
<a href="https://frappeframework.com/docs">
|
||||
<img src="https://img.shields.io/badge/docs-%F0%9F%93%96-success.svg"/>
|
||||
</a>
|
||||
<a href="https://github.com/frappe/frappe/actions/workflows/server-tests.yml">
|
||||
<img src="https://github.com/frappe/frappe/actions/workflows/server-tests.yml/badge.svg">
|
||||
</a>
|
||||
<a href="https://github.com/frappe/frappe/actions/workflows/ui-tests.yml">
|
||||
<img src="https://github.com/frappe/frappe/actions/workflows/ui-tests.yml/badge.svg?branch=develop">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/frappe/frappe">
|
||||
<img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/>
|
||||
</a>
|
||||
<a target="_blank" href="#LICENSE" title="License: MIT"><img src="https://img.shields.io/badge/License-MIT-success.svg"></a>
|
||||
<a href="https://codecov.io/gh/frappe/frappe"><img src="https://codecov.io/gh/frappe/frappe/branch/develop/graph/badge.svg?token=XoTa679hIj"/></a>
|
||||
</div>
|
||||
<div align="center">
|
||||
<img src=".github/hero-image.png" alt="Hero Image" />
|
||||
</div>
|
||||
<div align="center">
|
||||
<a href="https://frappe.io/framework">Website</a>
|
||||
-
|
||||
<a href="https://docs.frappe.io/framework">Documentation</a>
|
||||
</div>
|
||||
|
||||
## Frappe Framework
|
||||
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for ERPNext.
|
||||
|
||||
Full-stack web application framework that uses Python and MariaDB on the server side and a tightly integrated client side library. Built for [ERPNext](https://erpnext.com).
|
||||
### Motivation
|
||||
Started in 2005, Frappe Framework was inspired by the Semantic Web. The "big idea" behind semantic web was of a framework that not only described how information is shown (like headings, body etc), but also what it means, like name, address etc.
|
||||
|
||||
<div align="center" style="max-height: 40px;">
|
||||
<a href="https://frappecloud.com/frappe/signup">
|
||||
<img src=".github/try-on-f-cloud-button.svg" height="40">
|
||||
</a>
|
||||
<a href="https://labs.play-with-docker.com/?stack=https://raw.githubusercontent.com/gavindsouza/install-scripts/main/frappe/pwd.yml">
|
||||
<img src="https://raw.githubusercontent.com/play-with-docker/stacks/master/assets/images/button.png" alt="Try in PWD" height="37"/>
|
||||
</a>
|
||||
By creating a web framework that allowed for easy definition of metadata, it made building complex applications easy. Applications usually designed around how users interact with a system, but not based on semantics of the underlying system. Applications built on semantics end up being much more consistent and extensible. The first application built on Framework was ERPNext, a beast with more than 700 object types. Framework is not for the light hearted - it is not the first thing you might want to learn if you are beginning to learn web programming, but if you are ready to do real work, then Framework is the right tool for the job.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Full-Stack Framework**: Frappe covers both front-end and back-end development, allowing developers to build complete applications using a single framework.
|
||||
|
||||
- **Built-in Admin Interface**: Provides a pre-built, customizable admin dashboard for managing application data, reducing development time and effort.
|
||||
|
||||
- **Role-Based Permissions**: Comprehensive user and role management system to control access and permissions within the application.
|
||||
|
||||
- **REST API**: Automatically generated RESTful API for all models, enabling easy integration with other systems and services.
|
||||
|
||||
- **Customizable Forms and Views**: Flexible form and view customization using server-side scripting and client-side JavaScript.
|
||||
|
||||
- **Report Builder**: Powerful reporting tool that allows users to create custom reports without writing any code.
|
||||
|
||||
<details>
|
||||
<summary>Screenshots</summary>
|
||||
|
||||

|
||||

|
||||

|
||||
</details>
|
||||
|
||||
## Production Setup
|
||||
|
||||
### Managed Hosting
|
||||
|
||||
You can try [Frappe Cloud](https://frappecloud.com), a simple, user-friendly and sophisticated [open-source](https://github.com/frappe/press) platform to host Frappe applications with peace of mind.
|
||||
|
||||
It takes care of installation, setup, upgrades, monitoring, maintenance and support of your Frappe deployments. It is a fully featured developer platform with an ability to manage and control multiple Frappe deployments.
|
||||
|
||||
<div>
|
||||
<a href="https://frappecloud.com/" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/try-on-fc-white.png">
|
||||
<img src="https://frappe.io/files/try-on-fc-black.png" alt="Try on Frappe Cloud" height="28" />
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
> Login for the PWD site: (username: Administrator, password: admin)
|
||||
### Self Hosting
|
||||
|
||||
## Table of Contents
|
||||
* [Installation](#installation)
|
||||
* [Contributing](#contributing)
|
||||
* [Resources](#resources)
|
||||
* [License](#license)
|
||||
### Docker
|
||||
Prerequisites: docker, docker-compose, git. Refer [Docker Documentation](https://docs.docker.com) for more details on Docker setup.
|
||||
|
||||
## Installation
|
||||
Run following commands:
|
||||
|
||||
### Production
|
||||
* [Managed Hosting on Frappe Cloud](https://frappecloud.com/)
|
||||
* [Easy install script using Docker images](https://github.com/frappe/bench/tree/develop#easy-install-script)
|
||||
* [Manual install using Docker images](https://github.com/frappe/frappe_docker)
|
||||
```
|
||||
git clone https://github.com/frappe/frappe_docker
|
||||
cd frappe_docker
|
||||
docker compose -f pwd.yml up -d
|
||||
```
|
||||
|
||||
### Development
|
||||
* [Easy install script using Docker images](https://github.com/frappe/bench/tree/develop#easy-install-script)
|
||||
* [Development installation on bare metal](https://frappeframework.com/docs/user/en/installation)
|
||||
After a couple of minutes, site should be accessible on your localhost port: 8080. Use below default login credentials to access the site.
|
||||
- Username: Administrator
|
||||
- Password: admin
|
||||
|
||||
See [Frappe Docker](https://github.com/frappe/frappe_docker?tab=readme-ov-file#to-run-on-arm64-architecture-follow-this-instructions) for ARM based docker setup.
|
||||
|
||||
## Development Setup
|
||||
### Manual Install
|
||||
|
||||
The Easy Way: our install script for bench will install all dependencies (e.g. MariaDB). See https://github.com/frappe/bench for more details.
|
||||
|
||||
New passwords will be created for the Frappe "Administrator" user, the MariaDB root user, and the frappe user (the script displays the passwords and saves them to ~/frappe_passwords.txt).
|
||||
|
||||
### Local
|
||||
|
||||
To setup the repository locally follow the steps mentioned below:
|
||||
|
||||
1. Setup bench by following the [Installation Steps](https://docs.frappe.io/framework/user/en/installation) and start the server
|
||||
```
|
||||
bench start
|
||||
```
|
||||
|
||||
2. In a separate terminal window, run the following commands:
|
||||
```
|
||||
# Create a new site
|
||||
bench new-site frappe.dev
|
||||
|
||||
# Map your site to localhost
|
||||
bench --site frappe.dev add-to-hosts
|
||||
```
|
||||
|
||||
3. Open the URL `http://frappe.dev:8000/app` in your browser, you should see the app running
|
||||
|
||||
## Learning and community
|
||||
|
||||
1. [Frappe School](https://frappe.school) - Learn Frappe Framework and ERPNext from the various courses by the maintainers or from the community.
|
||||
2. [Official documentation](https://docs.frappe.io/framework) - Extensive documentation for Frappe Framework.
|
||||
3. [Discussion Forum](https://discuss.frappe.io/) - Engage with community of Frappe Framework users and service providers.
|
||||
4. [buildwithhussain.dev](https://buildwithhussain.dev) - Watch Frappe Framework being used in the wild to build world-class web apps.
|
||||
|
||||
## Contributing
|
||||
|
||||
1. [Code of Conduct](CODE_OF_CONDUCT.md)
|
||||
1. [Contribution Guidelines](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
|
||||
1. [Security Policy](SECURITY.md)
|
||||
1. [Issue Guidelines](https://github.com/frappe/erpnext/wiki/Issue-Guidelines)
|
||||
1. [Report Security Vulnerabilities](https://frappe.io/security)
|
||||
1. [Pull Request Requirements](https://github.com/frappe/erpnext/wiki/Contribution-Guidelines)
|
||||
|
||||
## Resources
|
||||
|
||||
1. [frappeframework.com](https://frappeframework.com) - Official documentation of the Frappe Framework.
|
||||
1. [frappe.school](https://frappe.school) - Pick from the various courses by the maintainers or from the community.
|
||||
1. [buildwithhussain.dev](https://buildwithhussain.dev) - Watch Frappe Framework being used in the wild to build world-class web apps.
|
||||
|
||||
## License
|
||||
This repository has been released under the [MIT License](LICENSE).
|
||||
|
||||
By contributing to Frappe, you agree that your contributions will be licensed under its MIT License.
|
||||
<br>
|
||||
<br>
|
||||
<div align="center">
|
||||
<a href="https://frappe.io" target="_blank">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://frappe.io/files/Frappe-white.png">
|
||||
<img src="https://frappe.io/files/Frappe-black.png" alt="Frappe Technologies" height="28"/>
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
hooks.py,frappe.gettext.extractors.navbar.extract
|
||||
**/hooks.py,frappe.gettext.extractors.navbar.extract
|
||||
**/doctype/*/*.json,frappe.gettext.extractors.doctype.extract
|
||||
**/workspace/*/*.json,frappe.gettext.extractors.workspace.extract
|
||||
**/onboarding_step/*/*.json,frappe.gettext.extractors.onboarding_step.extract
|
||||
**/module_onboarding/*/*.json,frappe.gettext.extractors.module_onboarding.extract
|
||||
**/report/*/*.json,frappe.gettext.extractors.report.extract
|
||||
**.py,frappe.gettext.extractors.python.extract
|
||||
**/templates/**.js,frappe.gettext.extractors.html_template.extract
|
||||
**.js,frappe.gettext.extractors.javascript.extract
|
||||
**.html,frappe.gettext.extractors.html_template.extract
|
||||
**.html,frappe.gettext.extractors.html_template.extract
|
||||
**.vue,frappe.gettext.extractors.html_template.extract
|
||||
**/custom/*.json,frappe.gettext.extractors.customization.extract
|
||||
**/fixtures/custom_field.json,frappe.gettext.extractors.custom_field.extract
|
||||
|
|
|
|||
|
5
billing/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
4
billing/.prettierrc.json
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true
|
||||
}
|
||||
27
billing/README.md
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Frappe Cloud Billing
|
||||
|
||||
Billing page is built to manage the billing of both desk based (ERPNext, HRMS) and portal based apps (Frappe CRM, Insights, Gameplan, Builder etc.)
|
||||
|
||||
> Billing page is only built for sites hosted on Frappe Cloud.
|
||||
|
||||
## Features
|
||||
|
||||
- **Current Plan**: View the current plan of the site.
|
||||
- **Manage Subscription**: Upgrade, downgrade your subscription plan.
|
||||
- **Plans & Pricing**: View and compare all the available plans and decide which one is best for you.
|
||||
- **Billing History**: View your billing history and download invoices.
|
||||
- **Prepaid Credits**: Add prepaid credits to your account to pay for your subscription.
|
||||
- **Payment Methods**: Add, remove your payment methods (Credit Card, Debit Card) and set a default payment method.
|
||||
- **Billing Address**: Add/Update your billing address.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Go to Billing Overview page typing `/billing` in the URL. You can also access it from your app installed on the site.
|
||||
2. Billing Overview page will open with the current plan details and other options.
|
||||
3. Click on the `Upgrade plan` button to upgrade or downgrade your subscription plan.
|
||||
4. It will redirect you to the `Plans` page where you can select the plan you want to subscribe to.
|
||||
5. Click on the `Upgrade` or `Downgrade` button to confirm the subscription.
|
||||
6. Before confirming the subscription, you should add prepaid credits to your account to pay for the subscription or you can add a payment method. Check `Payment details` section in the `Billing Overview` page to add prepaid credits or select payment method.
|
||||
7. Once you have added prepaid credits or payment method, you can confirm the subscription.
|
||||
8. You can view your billing history and download invoices from the `Invoices` page.
|
||||
9. You can add, remove payment methods and set a default payment method from the `Cards` page.
|
||||
18
billing/index.html
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<!doctype html>
|
||||
<html class="h-full" lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<title>Billing</title>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-title" content="Billing" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="white" />
|
||||
</head>
|
||||
<body class="sm:overscroll-y-none no-scrollbar">
|
||||
<div id="app" class="h-full"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
billing/package.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"name": "billing-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build --base=/assets/frappe/billing/ && yarn copy-html-entry",
|
||||
"copy-html-entry": "cp ../frappe/public/billing/index.html ../frappe/www/billing.html",
|
||||
"serve": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-vue": "^4.2.3",
|
||||
"@stripe/stripe-js": "^1.3.0",
|
||||
"@vueuse/core": "^11.2.0",
|
||||
"frappe-ui": "^v0.1.72",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"vite": "^4.4.9",
|
||||
"vue": "^3.4.12",
|
||||
"vue-router": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.5"
|
||||
}
|
||||
}
|
||||
6
billing/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
BIN
billing/public/favicon.png
Normal file
|
After Width: | Height: | Size: 440 B |
40
billing/src/App.vue
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<div v-if="isFCSite.data && user.name" class="flex h-screen w-screen">
|
||||
<div class="h-full border-r bg-gray-50">
|
||||
<AppSidebar />
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col h-full overflow-x-hidden">
|
||||
<router-view />
|
||||
</div>
|
||||
<Dialogs />
|
||||
<Toasts />
|
||||
</div>
|
||||
<PageNotFound v-else />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import PageNotFound from './pages/PageNotFound.vue'
|
||||
import AppSidebar from '@/components/AppSidebar.vue'
|
||||
import { Dialogs } from '@/dialogs.js'
|
||||
import { getSession } from '@/session.js'
|
||||
import { Toasts, createResource } from 'frappe-ui'
|
||||
import { provide } from 'vue'
|
||||
|
||||
const { isFCSite, user } = getSession()
|
||||
|
||||
const team = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'team.info' },
|
||||
cache: 'team',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const currentSiteInfo = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.current_site_info',
|
||||
auto: true,
|
||||
cache: 'currentSiteInfo',
|
||||
})
|
||||
|
||||
provide('team', team)
|
||||
provide('currentSiteInfo', currentSiteInfo)
|
||||
</script>
|
||||
BIN
billing/src/assets/Inter/Inter-Black.woff
Normal file
BIN
billing/src/assets/Inter/Inter-Black.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-BlackItalic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-BlackItalic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-Bold.woff
Normal file
BIN
billing/src/assets/Inter/Inter-Bold.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-BoldItalic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-BoldItalic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-ExtraBold.woff
Normal file
BIN
billing/src/assets/Inter/Inter-ExtraBold.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-ExtraBoldItalic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-ExtraBoldItalic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-ExtraLight.woff
Normal file
BIN
billing/src/assets/Inter/Inter-ExtraLight.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-ExtraLightItalic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-ExtraLightItalic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-Italic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-Italic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-Light.woff
Normal file
BIN
billing/src/assets/Inter/Inter-Light.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-LightItalic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-LightItalic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-Medium.woff
Normal file
BIN
billing/src/assets/Inter/Inter-Medium.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-MediumItalic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-MediumItalic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-Regular.woff
Normal file
BIN
billing/src/assets/Inter/Inter-Regular.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-SemiBold.woff
Normal file
BIN
billing/src/assets/Inter/Inter-SemiBold.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-SemiBoldItalic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-SemiBoldItalic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-Thin.woff
Normal file
BIN
billing/src/assets/Inter/Inter-Thin.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-ThinItalic.woff
Normal file
BIN
billing/src/assets/Inter/Inter-ThinItalic.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-italic.var.woff2
Normal file
BIN
billing/src/assets/Inter/Inter-roman.var.woff2
Normal file
BIN
billing/src/assets/Inter/Inter.var.woff2
Normal file
152
billing/src/assets/Inter/inter.css
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-Thin.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Thin.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 100;
|
||||
font-display: swap;
|
||||
src: url("Inter-ThinItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ThinItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLight.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLight.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 200;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraLightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraLightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-Light.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Light.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 300;
|
||||
font-display: swap;
|
||||
src: url("Inter-LightItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-LightItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Regular.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Regular.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("Inter-Italic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Italic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-Medium.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Medium.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("Inter-MediumItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-MediumItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 600;
|
||||
font-display: swap;
|
||||
src: url("Inter-SemiBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-SemiBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-Bold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Bold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("Inter-BoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBold.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBold.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 800;
|
||||
font-display: swap;
|
||||
src: url("Inter-ExtraBoldItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-ExtraBoldItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-Black.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-Black.woff?v=3.12") format("woff");
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
font-style: italic;
|
||||
font-weight: 900;
|
||||
font-display: swap;
|
||||
src: url("Inter-BlackItalic.woff2?v=3.12") format("woff2"),
|
||||
url("Inter-BlackItalic.woff?v=3.12") format("woff");
|
||||
}
|
||||
32
billing/src/components/AddCardModal.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<Dialog v-model="show" :options="{ title: 'Add new card' }">
|
||||
<template #body-content>
|
||||
<div v-if="showMessage" class="inline-flex gap-1.5 text-base mb-5 text-gray-700">
|
||||
<FeatherIcon class="h-4" name="info" />
|
||||
<span> Add at least one card before changing the payment mode. </span>
|
||||
</div>
|
||||
<CardForm
|
||||
@success="
|
||||
() => {
|
||||
show = false
|
||||
emit('success')
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import CardForm from './CardForm.vue'
|
||||
import { Dialog, FeatherIcon } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
showMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const show = defineModel()
|
||||
</script>
|
||||
32
billing/src/components/AddPrepaidCreditsModal.vue
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<template>
|
||||
<Dialog v-model="show" :options="{ title: 'Add Credit Balance' }">
|
||||
<template #body-content>
|
||||
<div v-if="showMessage" class="inline-flex gap-1.5 text-base mb-5 text-gray-700">
|
||||
<FeatherIcon class="h-4" name="info" />
|
||||
<span> Add credits to your account before changing the payment mode. </span>
|
||||
</div>
|
||||
<PrepaidCreditsForm
|
||||
@success="
|
||||
() => {
|
||||
show = false
|
||||
emit('success')
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import PrepaidCreditsForm from './PrepaidCreditsForm.vue'
|
||||
import { Dialog, FeatherIcon } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
showMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const show = defineModel()
|
||||
</script>
|
||||
246
billing/src/components/AddressForm.vue
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-5">
|
||||
<div
|
||||
v-for="section in sections"
|
||||
:key="section.name"
|
||||
class="grid gap-4"
|
||||
:class="'grid-cols-' + section.columns"
|
||||
>
|
||||
<div v-for="field in section.fields" :key="field.name">
|
||||
<FormControl
|
||||
v-model="billingInformation[field.fieldname]"
|
||||
:label="field.label || field.fieldname"
|
||||
:type="getInputType(field)"
|
||||
:name="field.fieldname"
|
||||
:options="field.options"
|
||||
:required="field.required"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="billingInformation.country == 'India'">
|
||||
<FormControl label="I have GSTIN" type="checkbox" v-model="gstApplicable" />
|
||||
<FormControl
|
||||
v-if="gstApplicable"
|
||||
class="mt-5"
|
||||
label="GSTIN"
|
||||
type="text"
|
||||
v-model="billingInformation.gstin"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ErrorMessage class="mt-2" :message="updateBillingInformation.error" />
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { FormControl, ErrorMessage, createResource, toast } from 'frappe-ui'
|
||||
import { ref, computed, inject, watch } from 'vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const team = inject('team')
|
||||
|
||||
const billingInformation = defineModel()
|
||||
|
||||
const updateBillingInformation = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
makeParams: () => {
|
||||
return {
|
||||
method: 'billing.update_information',
|
||||
data: { billing_details: billingInformation.value },
|
||||
}
|
||||
},
|
||||
validate: async () => {
|
||||
let error = await validate()
|
||||
if (error) return error
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: 'Billing Information Updated',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600',
|
||||
position: 'bottom-right',
|
||||
})
|
||||
emit('success')
|
||||
},
|
||||
})
|
||||
|
||||
const gstApplicable = ref(false)
|
||||
|
||||
watch(
|
||||
() => billingInformation.value.gstin,
|
||||
(gstin) => {
|
||||
gstApplicable.value = gstin && gstin !== 'Not Applicable'
|
||||
}
|
||||
)
|
||||
|
||||
async function validate() {
|
||||
// validate mandatory fields
|
||||
for (let field of sections.value.flatMap((s) => s.fields)) {
|
||||
if (field.required && !billingInformation.value[field.fieldname]) {
|
||||
return `${field.label} is required`
|
||||
}
|
||||
}
|
||||
|
||||
if (!gstApplicable.value) {
|
||||
billingInformation.value.gstin = 'Not Applicable'
|
||||
}
|
||||
|
||||
// validate gstin
|
||||
return await validateGST()
|
||||
}
|
||||
|
||||
const _indianStates = [
|
||||
'Andaman and Nicobar Islands',
|
||||
'Andhra Pradesh',
|
||||
'Arunachal Pradesh',
|
||||
'Assam',
|
||||
'Bihar',
|
||||
'Chandigarh',
|
||||
'Chhattisgarh',
|
||||
'Dadra and Nagar Haveli and Daman and Diu',
|
||||
'Delhi',
|
||||
'Goa',
|
||||
'Gujarat',
|
||||
'Haryana',
|
||||
'Himachal Pradesh',
|
||||
'Jammu and Kashmir',
|
||||
'Jharkhand',
|
||||
'Karnataka',
|
||||
'Kerala',
|
||||
'Ladakh',
|
||||
'Lakshadweep Islands',
|
||||
'Madhya Pradesh',
|
||||
'Maharashtra',
|
||||
'Manipur',
|
||||
'Meghalaya',
|
||||
'Mizoram',
|
||||
'Nagaland',
|
||||
'Odisha',
|
||||
'Other Territory',
|
||||
'Puducherry',
|
||||
'Punjab',
|
||||
'Rajasthan',
|
||||
'Sikkim',
|
||||
'Tamil Nadu',
|
||||
'Telangana',
|
||||
'Tripura',
|
||||
'Uttar Pradesh',
|
||||
'Uttarakhand',
|
||||
'West Bengal',
|
||||
]
|
||||
|
||||
const _countryList = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.country_list' },
|
||||
cache: 'countryList',
|
||||
auto: true,
|
||||
onSuccess: () => {
|
||||
let userCountry = team.data?.country
|
||||
if (userCountry) {
|
||||
let country = countryList.value?.find((d) => d.label === userCountry)
|
||||
if (country) {
|
||||
billingInformation.value.country = country.value
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const countryList = computed(() => {
|
||||
return (_countryList.data || []).map((d) => ({
|
||||
label: d.name,
|
||||
value: d.name,
|
||||
}))
|
||||
})
|
||||
|
||||
const indianStates = computed(() => {
|
||||
return _indianStates.map((state) => ({
|
||||
label: state,
|
||||
value: state,
|
||||
}))
|
||||
})
|
||||
|
||||
const sections = computed(() => {
|
||||
return [
|
||||
{
|
||||
name: 'Country and City',
|
||||
columns: 2,
|
||||
fields: [
|
||||
{
|
||||
fieldtype: 'Select',
|
||||
label: 'Country',
|
||||
fieldname: 'country',
|
||||
options: countryList.value,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
fieldtype: 'Data',
|
||||
label: 'City',
|
||||
fieldname: 'city',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Address',
|
||||
columns: 1,
|
||||
fields: [
|
||||
{
|
||||
fieldtype: 'Data',
|
||||
label: 'Address',
|
||||
fieldname: 'address',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'State and Postal Code',
|
||||
columns: 2,
|
||||
fields: [
|
||||
{
|
||||
fieldtype: billingInformation.value.country === 'India' ? 'Select' : 'Data',
|
||||
label: 'State / Province / Region',
|
||||
fieldname: 'state',
|
||||
required: true,
|
||||
options:
|
||||
billingInformation.value.country === 'India' ? indianStates.value : null,
|
||||
},
|
||||
{
|
||||
fieldtype: 'Data',
|
||||
label: 'Postal Code',
|
||||
fieldname: 'postal_code',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
function getInputType(field) {
|
||||
return {
|
||||
Data: 'text',
|
||||
Int: 'number',
|
||||
Select: 'select',
|
||||
Check: 'checkbox',
|
||||
Password: 'password',
|
||||
Text: 'textarea',
|
||||
Date: 'date',
|
||||
}[field.fieldtype || 'Data']
|
||||
}
|
||||
|
||||
const _validateGST = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
makeParams() {
|
||||
return {
|
||||
method: 'billing.validate_gst',
|
||||
data: { address: billingInformation.value },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function validateGST() {
|
||||
billingInformation.value.gstin = billingInformation.value.gstin || 'Not Applicable'
|
||||
await _validateGST.submit()
|
||||
}
|
||||
|
||||
defineExpose({ updateBillingInformation, validate })
|
||||
</script>
|
||||
120
billing/src/components/AppSidebar.vue
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<div
|
||||
class="relative flex h-full flex-col justify-between transition-all duration-300 ease-in-out w-[220px]"
|
||||
>
|
||||
<div>
|
||||
<UserDropdown class="p-2" />
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<nav class="mb-3 flex flex-col">
|
||||
<SidebarLink
|
||||
class="mx-2 my-0.5"
|
||||
:label="previousRoute ? 'Back to app' : 'Back'"
|
||||
icon="arrow-left"
|
||||
:noExternalLinkIcon="true"
|
||||
@click="goBack"
|
||||
/>
|
||||
<SidebarLink
|
||||
class="mx-2 my-0.5"
|
||||
v-for="link in links"
|
||||
:icon="link.icon"
|
||||
:label="link.label"
|
||||
:to="link.to"
|
||||
@click="link.onClick"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import UserDropdown from '@/components/UserDropdown.vue'
|
||||
import BillingIcon from '@/icons/BillingIcon.vue'
|
||||
import CardIcon from '@/icons/CardIcon.vue'
|
||||
import Plans from '@/icons/PlansIcon.vue'
|
||||
import InvoiceIcon from '@/icons/InvoiceIcon.vue'
|
||||
import SidebarLink from '@/components/SidebarLink.vue'
|
||||
import { createDialog } from '../dialogs'
|
||||
import { call } from 'frappe-ui'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useStorage } from '@vueuse/core'
|
||||
import { onMounted, inject } from 'vue'
|
||||
|
||||
const router = useRouter()
|
||||
const previousRoute = useStorage('previousRoute', null)
|
||||
|
||||
const team = inject('team')
|
||||
const currentSiteInfo = inject('currentSiteInfo')
|
||||
|
||||
onMounted(() => {
|
||||
if (document.referrer) {
|
||||
previousRoute.value = document.referrer
|
||||
}
|
||||
})
|
||||
|
||||
const links = [
|
||||
{
|
||||
label: 'Overview',
|
||||
icon: BillingIcon,
|
||||
to: 'Overview',
|
||||
},
|
||||
{
|
||||
label: 'Plans',
|
||||
icon: Plans,
|
||||
to: 'Plans',
|
||||
},
|
||||
{
|
||||
label: 'Invoices',
|
||||
icon: InvoiceIcon,
|
||||
to: 'Invoices',
|
||||
},
|
||||
{
|
||||
label: 'Cards',
|
||||
icon: CardIcon,
|
||||
to: 'Cards',
|
||||
},
|
||||
{
|
||||
label: 'Support',
|
||||
icon: 'life-buoy',
|
||||
onClick: () => openSupport(),
|
||||
},
|
||||
]
|
||||
|
||||
function goBack() {
|
||||
if (previousRoute.value) {
|
||||
window.location.href = previousRoute.value
|
||||
} else {
|
||||
router.go(-1)
|
||||
}
|
||||
}
|
||||
|
||||
async function openSupport() {
|
||||
await currentSiteInfo.reload()
|
||||
if (!currentSiteInfo.data?.plan?.support_included) {
|
||||
let supportPlan = await call(
|
||||
'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
{
|
||||
method: 'site.get_first_support_plan',
|
||||
}
|
||||
)
|
||||
if (!supportPlan) return
|
||||
let currency = team.data.currency == 'INR' ? '₹' : '$'
|
||||
let price = currency === '₹' ? supportPlan.price_inr : supportPlan.price_usd
|
||||
createDialog({
|
||||
title: `Upgrade to ${currency}${price}/mo Plan`,
|
||||
message: `Please upgrade to the ${currency}${price}/mo plan to get support`,
|
||||
actions: [
|
||||
{
|
||||
label: 'Upgrade',
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
router.push({ name: 'Plans' })
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
return
|
||||
}
|
||||
window.open('https://support.frappe.io/help', '_blank')
|
||||
}
|
||||
</script>
|
||||
72
billing/src/components/Apps.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<Popover placement="right-start" class="flex w-full">
|
||||
<template #target="{ togglePopover }">
|
||||
<button
|
||||
:class="[
|
||||
active ? 'bg-gray-100' : 'text-gray-800',
|
||||
'group w-full flex h-7 items-center justify-between rounded px-2 text-base hover:bg-gray-100',
|
||||
]"
|
||||
@click.prevent="togglePopover()"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<AppsIcon class="size-4" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{ 'Apps' }}
|
||||
</span>
|
||||
</div>
|
||||
<FeatherIcon name="chevron-right" class="size-4 text-gray-600" />
|
||||
</button>
|
||||
</template>
|
||||
<template #body>
|
||||
<div
|
||||
class="grid grid-cols-3 justify-between mx-3 p-2 rounded-lg border border-gray-100 bg-white shadow-xl"
|
||||
>
|
||||
<div v-for="app in apps.data" :key="app.name">
|
||||
<a
|
||||
:href="app.route"
|
||||
class="flex flex-col gap-1.5 rounded justify-center items-center py-2 px-1 hover:bg-gray-100"
|
||||
>
|
||||
<img class="size-8" :src="app.logo" />
|
||||
<div class="text-sm text-gray-700" @click="app.onClick">
|
||||
{{ app.title }}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</template>
|
||||
<script setup>
|
||||
import AppsIcon from '@/icons/AppsIcon.vue'
|
||||
import { FeatherIcon, Popover, createResource } from 'frappe-ui'
|
||||
|
||||
const props = defineProps({
|
||||
active: Boolean,
|
||||
})
|
||||
|
||||
const apps = createResource({
|
||||
url: 'frappe.apps.get_apps',
|
||||
cache: 'apps',
|
||||
auto: true,
|
||||
transform: (data) => {
|
||||
let _apps = [
|
||||
{
|
||||
name: 'frappe',
|
||||
logo: '/assets/frappe/images/framework.png',
|
||||
title: 'Desk',
|
||||
route: '/app',
|
||||
},
|
||||
]
|
||||
data.map((app) => {
|
||||
_apps.push({
|
||||
name: app.name,
|
||||
logo: app.logo,
|
||||
title: app.title,
|
||||
route: app.route,
|
||||
})
|
||||
})
|
||||
|
||||
return _apps
|
||||
},
|
||||
})
|
||||
</script>
|
||||
72
billing/src/components/BillingDetails.vue
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-5">
|
||||
<FormControl
|
||||
v-model="billingInformation.billing_name"
|
||||
type="text"
|
||||
name="billing_name"
|
||||
label="Billing Name"
|
||||
:required="true"
|
||||
/>
|
||||
<AddressForm
|
||||
ref="addressFormRef"
|
||||
v-model="billingInformation"
|
||||
@success="() => emit('success')"
|
||||
/>
|
||||
<ErrorMessage class="mt-2" :message="errorMessage" />
|
||||
</div>
|
||||
<div v-if="addressFormRef" class="mt-6">
|
||||
<Button
|
||||
class="w-full"
|
||||
variant="solid"
|
||||
label="Update billing details"
|
||||
:loading="addressFormRef.updateBillingInformation.loading"
|
||||
@click="updateBillingInformation"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import AddressForm from './AddressForm.vue'
|
||||
import { FormControl, ErrorMessage, Button, createResource } from 'frappe-ui'
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const addressFormRef = ref(null)
|
||||
|
||||
const billingInformation = reactive({
|
||||
billing_name: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postal_code: '',
|
||||
country: '',
|
||||
gstin: '',
|
||||
})
|
||||
|
||||
createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.get_information' },
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
Object.assign(billingInformation, {
|
||||
address: data.address_line1,
|
||||
city: data.city,
|
||||
state: data.state,
|
||||
postal_code: data.pincode,
|
||||
country: data.country,
|
||||
gstin: data.gstin == 'Not Applicable' ? '' : data.gstin,
|
||||
billing_name: data.billing_name,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const errorMessage = ref('')
|
||||
|
||||
function updateBillingInformation() {
|
||||
if (!billingInformation.billing_name) {
|
||||
errorMessage.value = 'Billing Name is required'
|
||||
return
|
||||
}
|
||||
addressFormRef.value.updateBillingInformation.submit()
|
||||
}
|
||||
</script>
|
||||
34
billing/src/components/BillingDetailsModal.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<template>
|
||||
<Dialog v-model="show" :options="{ title: 'Billing Details' }">
|
||||
<template #body-content>
|
||||
<div v-if="showMessage" class="inline-flex gap-1.5 text-base mb-5 text-gray-700">
|
||||
<FeatherIcon class="h-4" name="info" />
|
||||
<span> Add billing details to your account before proceeding.</span>
|
||||
</div>
|
||||
<BillingDetails
|
||||
ref="billingRef"
|
||||
@success="
|
||||
() => {
|
||||
show = false
|
||||
emit('success')
|
||||
}
|
||||
"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup>
|
||||
import BillingDetails from './BillingDetails.vue'
|
||||
import { FeatherIcon, Dialog } from 'frappe-ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
showMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
const emit = defineEmits(['success'])
|
||||
const show = defineModel()
|
||||
const billingRef = ref(null)
|
||||
</script>
|
||||
133
billing/src/components/BuyCreditsRazorpay.vue
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
<template>
|
||||
<div>
|
||||
<span
|
||||
v-if="team.data.currency === 'INR'"
|
||||
class="mt-2.5 inline-flex gap-2 text-base text-gray-700"
|
||||
>
|
||||
<FeatherIcon name="info" class="h-4 my-1" />
|
||||
<span class="leading-5">
|
||||
If you select Razorpay, you can pay using Credit Card, Debit Card, Net Banking,
|
||||
UPI, Wallets, etc. If you are using Net Banking, it may take upto 5 days for
|
||||
balance to reflect.
|
||||
</span>
|
||||
</span>
|
||||
<ErrorMessage class="mt-3" :message="createRazorpayOrder.error" />
|
||||
<div class="mt-8">
|
||||
<Button
|
||||
v-if="!isPaymentComplete"
|
||||
class="w-full"
|
||||
size="md"
|
||||
variant="solid"
|
||||
label="Proceed to payment using Razorpay"
|
||||
:loading="createRazorpayOrder.loading"
|
||||
@click="createRazorpayOrder.submit()"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="w-full"
|
||||
size="md"
|
||||
label="Confirming payment"
|
||||
variant="solid"
|
||||
:loading="isVerifyingPayment"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, ErrorMessage, FeatherIcon, createResource, toast } from 'frappe-ui'
|
||||
import { ref, onMounted, onBeforeUnmount, inject } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
amount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
minimumAmount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
const team = inject('team')
|
||||
|
||||
const isPaymentComplete = ref(false)
|
||||
const isVerifyingPayment = ref(false)
|
||||
|
||||
const razorpayCheckoutJS = ref(null)
|
||||
|
||||
onMounted(() => {
|
||||
razorpayCheckoutJS.value = document.createElement('script')
|
||||
razorpayCheckoutJS.value.setAttribute('src', 'https://checkout.razorpay.com/v1/checkout.js')
|
||||
razorpayCheckoutJS.value.async = true
|
||||
document.head.appendChild(razorpayCheckoutJS.value)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
razorpayCheckoutJS.value?.remove()
|
||||
})
|
||||
|
||||
const createRazorpayOrder = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: {
|
||||
method: 'billing.create_razorpay_order',
|
||||
data: { amount: props.amount },
|
||||
},
|
||||
onSuccess: (data) => processOrder(data),
|
||||
validate: () => {
|
||||
if (props.amount < props.minimumAmount) {
|
||||
return 'Amount less than minimum amount required'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const handlePaymentFailed = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.handle_razorpay_payment_failed' },
|
||||
onSuccess: () => {
|
||||
console.log('Payment Failed.')
|
||||
},
|
||||
})
|
||||
|
||||
function processOrder(data) {
|
||||
const options = {
|
||||
key: data.key_id,
|
||||
order_id: data.order_id,
|
||||
name: 'Frappe Cloud',
|
||||
image: 'https://frappe.io/files/cloud.png',
|
||||
prefill: { email: team.data?.user },
|
||||
handler: handlePaymentSuccess,
|
||||
theme: { color: '#171717' },
|
||||
}
|
||||
|
||||
const rzp = new Razorpay(options)
|
||||
|
||||
// Opens the payment checkout frame
|
||||
rzp.open()
|
||||
|
||||
// Attach failure handler
|
||||
rzp.on('payment.failed', handlePaymentFailure)
|
||||
// rzp.on('payment.success', this.handlePaymentSuccess);
|
||||
}
|
||||
|
||||
function handlePaymentFailure(response) {
|
||||
handlePaymentFailed.submit({ response })
|
||||
toast({
|
||||
title: 'Payment failed',
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600',
|
||||
position: 'bottom-right',
|
||||
})
|
||||
}
|
||||
|
||||
function handlePaymentSuccess() {
|
||||
isPaymentComplete.value = true
|
||||
emit('success')
|
||||
toast({
|
||||
title: 'Payment successful',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600',
|
||||
position: 'bottom-right',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
167
billing/src/components/BuyCreditsStripe.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<template>
|
||||
<div>
|
||||
<label
|
||||
class="block"
|
||||
:class="{
|
||||
'pointer-events-none h-0.5 opacity-0': step != 'Add Card Details',
|
||||
'mt-4': step == 'Add Card Details',
|
||||
}"
|
||||
>
|
||||
<span class="text-sm leading-4 text-gray-700"> Credit or Debit Card </span>
|
||||
<div class="form-input mt-2 block w-full pl-3" ref="cardElementRef"></div>
|
||||
<ErrorMessage class="mt-1" :message="cardErrorMessage" />
|
||||
</label>
|
||||
|
||||
<div v-if="step == 'Setting up Stripe'" class="mt-8 flex justify-center">
|
||||
<Spinner class="h-4 w-4 text-gray-700" />
|
||||
</div>
|
||||
<ErrorMessage class="mt-2" :message="createPaymentIntent.error || errorMessage" />
|
||||
<div class="mt-8">
|
||||
<Button
|
||||
v-if="step == 'Get Amount'"
|
||||
class="w-full"
|
||||
size="md"
|
||||
variant="solid"
|
||||
label="Proceed to payment using Stripe"
|
||||
:loading="createPaymentIntent.loading"
|
||||
@click="createPaymentIntent.submit()"
|
||||
/>
|
||||
<Button
|
||||
v-else-if="step == 'Add Card Details'"
|
||||
class="w-full"
|
||||
size="md"
|
||||
variant="solid"
|
||||
label="Make payment via Stripe"
|
||||
:loading="paymentInProgress"
|
||||
@click="onBuyClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, ErrorMessage, Spinner, createResource, toast } from 'frappe-ui'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { ref, nextTick, inject } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
amount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
minimumAmount: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
})
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const team = inject('team')
|
||||
|
||||
const step = ref('Get Amount')
|
||||
const clientSecret = ref(null)
|
||||
const cardErrorMessage = ref(null)
|
||||
const errorMessage = ref(null)
|
||||
const paymentInProgress = ref(false)
|
||||
|
||||
const stripe = ref(null)
|
||||
const card = ref(null)
|
||||
const elements = ref(null)
|
||||
const ready = ref(false)
|
||||
|
||||
const cardElementRef = ref(null)
|
||||
|
||||
const createPaymentIntent = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: {
|
||||
method: 'billing.create_payment_intent_for_buying_credits',
|
||||
data: { amount: props.amount },
|
||||
},
|
||||
validate() {
|
||||
if (props.amount < props.minimumAmount && !team.data.erpnext_partner) {
|
||||
return `Amount must be greater than or equal to ${props.minimumAmount}`
|
||||
}
|
||||
},
|
||||
async onSuccess(data) {
|
||||
step.value = 'Setting up Stripe'
|
||||
let { publishable_key, client_secret } = data
|
||||
clientSecret.value = client_secret
|
||||
stripe.value = await loadStripe(publishable_key)
|
||||
elements.value = stripe.value.elements()
|
||||
const style = {
|
||||
base: {
|
||||
color: '#171717',
|
||||
fontFamily: [
|
||||
'ui-sans-serif',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'"Noto Sans"',
|
||||
'sans-serif',
|
||||
'"Apple Color Emoji"',
|
||||
'"Segoe UI Emoji"',
|
||||
'"Segoe UI Symbol"',
|
||||
'"Noto Color Emoji"',
|
||||
].join(', '),
|
||||
fontSmoothing: 'antialiased',
|
||||
fontSize: '13px',
|
||||
'::placeholder': {
|
||||
color: '#C7C7C7',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#7C7C7C',
|
||||
iconColor: '#7C7C7C',
|
||||
},
|
||||
}
|
||||
card.value = elements.value.create('card', {
|
||||
hidePostalCode: true,
|
||||
style: style,
|
||||
classes: {
|
||||
complete: '',
|
||||
focus: 'bg-gray-100',
|
||||
},
|
||||
})
|
||||
|
||||
step.value = 'Add Card Details'
|
||||
nextTick(() => {
|
||||
card.value.mount(cardElementRef.value)
|
||||
})
|
||||
|
||||
card.value.addEventListener('change', (event) => {
|
||||
cardErrorMessage.value = event.error?.message || null
|
||||
})
|
||||
card.value.addEventListener('ready', () => {
|
||||
ready.value = true
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
async function onBuyClick() {
|
||||
paymentInProgress.value = true
|
||||
let payload = await stripe.value.confirmCardPayment(clientSecret.value, {
|
||||
payment_method: { card: card.value },
|
||||
})
|
||||
|
||||
if (payload.error) {
|
||||
errorMessage.value = payload.error.message
|
||||
paymentInProgress.value = false
|
||||
} else {
|
||||
toast({
|
||||
position: 'bottom-right',
|
||||
title: 'Payment successful',
|
||||
text: 'Payment processed successfully, we will update your account shortly on confirmation from Stripe',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600',
|
||||
timeout: 10,
|
||||
})
|
||||
paymentInProgress.value = false
|
||||
emit('success')
|
||||
errorMessage.value = null
|
||||
}
|
||||
}
|
||||
</script>
|
||||
362
billing/src/components/CardForm.vue
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
<template>
|
||||
<div class="relative">
|
||||
<div
|
||||
v-if="!ready"
|
||||
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-8 transform"
|
||||
>
|
||||
<Spinner class="h-5 w-5 text-gray-700" />
|
||||
</div>
|
||||
<div :class="{ 'opacity-0': !ready }">
|
||||
<div v-show="!tryingMicroCharge">
|
||||
<label class="block">
|
||||
<span class="block text-xs text-gray-600"> Credit or Debit Card </span>
|
||||
<div
|
||||
class="form-input mt-2 block h-[unset] w-full py-2 pl-3"
|
||||
ref="cardElementRef"
|
||||
></div>
|
||||
<ErrorMessage class="mt-1" :message="cardErrorMessage" />
|
||||
</label>
|
||||
<FormControl
|
||||
class="mt-4"
|
||||
label="Name on Card"
|
||||
type="text"
|
||||
v-model="billingInformation.cardHolderName"
|
||||
/>
|
||||
<AddressForm
|
||||
ref="addressFormRef"
|
||||
class="mt-5"
|
||||
v-model="billingInformation"
|
||||
@success="console.log('Address form submitted')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 space-y-4" v-show="tryingMicroCharge">
|
||||
<p class="text-base text-gray-700">
|
||||
We are attempting to charge your card with
|
||||
<strong>{{ formattedMicroChargeAmount }}</strong> to make sure the card works.
|
||||
This amount will be <strong>refunded</strong> back to your account.
|
||||
</p>
|
||||
|
||||
<Button :loading="!microChargeCompleted" :loadingText="'Verifying Card'">
|
||||
Card Verified
|
||||
<template #prefix>
|
||||
<GreenCheckIcon class="h-4 w-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ErrorMessage class="mt-2" :message="errorMessage" />
|
||||
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<PoweredByStripeLogo />
|
||||
<Button
|
||||
v-if="showAddAnotherCardButton"
|
||||
label="Add Another Card"
|
||||
@click="clearForm"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon class="h-4" name="plus" />
|
||||
</template>
|
||||
</Button>
|
||||
<Button
|
||||
v-else-if="!tryingMicroCharge"
|
||||
variant="solid"
|
||||
label="Verify & Save Card"
|
||||
:loading="addingCard"
|
||||
@click="submit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import AddressForm from './AddressForm.vue'
|
||||
import PoweredByStripeLogo from '../logo/PoweredByStripeLogo.vue'
|
||||
import GreenCheckIcon from '../icons/GreenCheckIcon.vue'
|
||||
import {
|
||||
FeatherIcon,
|
||||
Button,
|
||||
FormControl,
|
||||
Spinner,
|
||||
ErrorMessage,
|
||||
createResource,
|
||||
toast,
|
||||
} from 'frappe-ui'
|
||||
import { currency } from '../utils.js'
|
||||
import { loadStripe } from '@stripe/stripe-js'
|
||||
import { ref, reactive, computed, inject, onMounted } from 'vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const team = inject('team')
|
||||
|
||||
const stripe = ref(null)
|
||||
const elements = ref(null)
|
||||
const card = ref(null)
|
||||
const ready = ref(false)
|
||||
const _setupIntent = ref(null)
|
||||
const errorMessage = ref(null)
|
||||
const cardErrorMessage = ref(null)
|
||||
const addingCard = ref(false)
|
||||
const tryingMicroCharge = ref(false)
|
||||
const showAddAnotherCardButton = ref(false)
|
||||
const microChargeCompleted = ref(false)
|
||||
|
||||
onMounted(() => setupStripeIntent())
|
||||
|
||||
const cardElementRef = ref(null)
|
||||
|
||||
const getPublishedKeyAndSetupIntent = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.get_publishable_key_and_setup_intent' },
|
||||
onSuccess: async (data) => {
|
||||
const { publishable_key, setup_intent } = data
|
||||
_setupIntent.value = setup_intent
|
||||
stripe.value = await loadStripe(publishable_key)
|
||||
elements.value = stripe.value.elements()
|
||||
const style = {
|
||||
base: {
|
||||
color: '#171717',
|
||||
fontFamily: [
|
||||
'ui-sans-serif',
|
||||
'system-ui',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'"Noto Sans"',
|
||||
'sans-serif',
|
||||
'"Apple Color Emoji"',
|
||||
'"Segoe UI Emoji"',
|
||||
'"Segoe UI Symbol"',
|
||||
'"Noto Color Emoji"',
|
||||
].join(', '),
|
||||
fontSmoothing: 'antialiased',
|
||||
fontSize: '13px',
|
||||
'::placeholder': {
|
||||
color: '#C7C7C7',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#C7C7C7',
|
||||
iconColor: '#C7C7C7',
|
||||
},
|
||||
}
|
||||
card.value = elements.value.create('card', {
|
||||
hidePostalCode: true,
|
||||
style: style,
|
||||
classes: {
|
||||
complete: '',
|
||||
focus: 'bg-gray-100',
|
||||
},
|
||||
})
|
||||
card.value.mount(cardElementRef.value)
|
||||
card.value.addEventListener('change', (event) => {
|
||||
cardErrorMessage.value = event.error?.message || null
|
||||
})
|
||||
card.value.addEventListener('ready', () => {
|
||||
ready.value = true
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const countryList = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.country_list' },
|
||||
cache: 'countryList',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const browserTimezone = computed(() => {
|
||||
if (!window.Intl) {
|
||||
return null
|
||||
}
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
})
|
||||
|
||||
const billingInformation = reactive({
|
||||
cardHolderName: '',
|
||||
country: '',
|
||||
gstin: '',
|
||||
})
|
||||
|
||||
createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: {
|
||||
method: 'billing.get_information',
|
||||
data: { timezone: browserTimezone.value },
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (data) => {
|
||||
billingInformation.country = data?.country
|
||||
billingInformation.address = data?.address_line1
|
||||
billingInformation.city = data?.city
|
||||
billingInformation.state = data?.state
|
||||
billingInformation.postal_code = data?.pincode
|
||||
billingInformation.gstin = data?.gstin == 'Not Applicable' ? '' : data?.gstin
|
||||
},
|
||||
})
|
||||
|
||||
const setupIntentSuccess = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
makeParams: ({ setupIntent }) => {
|
||||
return {
|
||||
method: 'billing.setup_intent_success',
|
||||
data: {
|
||||
setup_intent: setupIntent,
|
||||
address: billingInformation,
|
||||
},
|
||||
}
|
||||
},
|
||||
onSuccess: async ({ payment_method_name }) => {
|
||||
await verifyWithMicroChargeIfApplicable(payment_method_name)
|
||||
addingCard.value = false
|
||||
toast({
|
||||
title: 'Card added successfully',
|
||||
icon: 'check',
|
||||
iconClasses: 'text-green-600',
|
||||
position: 'bottom-right',
|
||||
})
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error)
|
||||
addingCard.value = false
|
||||
errorMessage.value = error.messages.join('\n')
|
||||
toast({
|
||||
title: errorMessage.value,
|
||||
icon: 'x',
|
||||
iconClasses: 'text-red-600',
|
||||
position: 'bottom-right',
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const verifyCardWithMicroCharge = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
makeParams: ({ paymentMethodName }) => {
|
||||
return {
|
||||
method: 'billing.create_payment_intent_for_micro_debit',
|
||||
data: { payment_method_name: paymentMethodName },
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
async function setupStripeIntent() {
|
||||
await getPublishedKeyAndSetupIntent.submit()
|
||||
const { first_name, last_name = '' } = team.data?.user_info
|
||||
const fullname = first_name + ' ' + last_name
|
||||
billingInformation.cardHolderName = fullname.trimEnd()
|
||||
}
|
||||
|
||||
const addressFormRef = ref(null)
|
||||
|
||||
async function submit() {
|
||||
addingCard.value = true
|
||||
let message = await addressFormRef.value.validate()
|
||||
|
||||
if (message) {
|
||||
errorMessage.value = message
|
||||
addingCard.value = false
|
||||
return
|
||||
} else {
|
||||
errorMessage.value = null
|
||||
}
|
||||
|
||||
const { setupIntent, error } = await stripe.value.confirmCardSetup(
|
||||
_setupIntent.value.client_secret,
|
||||
{
|
||||
payment_method: {
|
||||
card: card.value,
|
||||
billing_details: {
|
||||
name: billingInformation.cardHolderName,
|
||||
address: {
|
||||
line1: billingInformation.address,
|
||||
city: billingInformation.city,
|
||||
state: billingInformation.state,
|
||||
postal_code: billingInformation.postal_code,
|
||||
country: getCountryCode(team.data?.country),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
if (error) {
|
||||
addingCard.value = false
|
||||
let declineCode = error.decline_code
|
||||
let _errorMessage = error.message
|
||||
if (declineCode === 'do_not_honor') {
|
||||
errorMessage.value =
|
||||
"Your card was declined. It might be due to insufficient funds or you might've exceeded your daily limit. Please try with another card or contact your bank."
|
||||
showAddAnotherCardButton.value = true
|
||||
} else if (declineCode === 'transaction_not_allowed') {
|
||||
errorMessage.value =
|
||||
'Your card was declined. It might be due to restrictions on your card, like international transactions or online payments. Please try with another card or contact your bank.'
|
||||
showAddAnotherCardButton.value = true
|
||||
} else if (_errorMessage != 'Your card number is incomplete.') {
|
||||
errorMessage.value = _errorMessage
|
||||
}
|
||||
} else {
|
||||
if (setupIntent?.status === 'succeeded') {
|
||||
setupIntentSuccess.submit({ setupIntent })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function verifyWithMicroChargeIfApplicable(paymentMethodName) {
|
||||
const teamCurrency = team.data?.currency
|
||||
const verifyCardsWithMicroCharge = window.verify_cards_with_micro_charge
|
||||
const isMicroChargeApplicable =
|
||||
verifyCardsWithMicroCharge === 'Both INR and USD' ||
|
||||
(verifyCardsWithMicroCharge == 'Only INR' && teamCurrency === 'INR') ||
|
||||
(verifyCardsWithMicroCharge === 'Only USD' && teamCurrency === 'USD')
|
||||
if (isMicroChargeApplicable) {
|
||||
await _verifyWithMicroCharge(paymentMethodName)
|
||||
} else {
|
||||
emit('success')
|
||||
}
|
||||
}
|
||||
|
||||
async function _verifyWithMicroCharge(paymentMethodName) {
|
||||
tryingMicroCharge.value = true
|
||||
return verifyCardWithMicroCharge.submit({
|
||||
paymentMethodName,
|
||||
onSuccess: async (paymentIntent) => {
|
||||
let { client_secret } = paymentIntent
|
||||
let payload = await stripe.value.confirmCardPayment(client_secret, {
|
||||
payment_method: { card: card.value },
|
||||
})
|
||||
if (payload.paymentIntent?.status === 'succeeded') {
|
||||
microChargeCompleted.value = true
|
||||
emit('success')
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error(error)
|
||||
tryingMicroCharge.value = false
|
||||
errorMessage.value = error.messages.join('\n')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function getCountryCode(country) {
|
||||
let code = countryList.data.find((d) => d.name === country).code
|
||||
return code.toUpperCase()
|
||||
}
|
||||
|
||||
async function clearForm() {
|
||||
ready.value = false
|
||||
errorMessage.value = null
|
||||
showAddAnotherCardButton.value = false
|
||||
card.value = null
|
||||
setupStripeIntent()
|
||||
}
|
||||
|
||||
const formattedMicroChargeAmount = computed(() => {
|
||||
if (!team.data?.currency) {
|
||||
return 0
|
||||
}
|
||||
return currency(team.data?.billing_info?.micro_debit_charge_amount, team.data?.currency)
|
||||
})
|
||||
</script>
|
||||
135
billing/src/components/ChangeCardModal.vue
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
<template>
|
||||
<div>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{ title: 'Choose active card' }"
|
||||
:disableOutsideClickToClose="confirmDialogOpened"
|
||||
>
|
||||
<template #body-content>
|
||||
<div v-if="cards.data?.length" class="flex flex-col gap-2.5">
|
||||
<div
|
||||
v-for="card in cards.data"
|
||||
:key="card.name"
|
||||
class="flex gap-2 justify-between text-base text-gray-900 p-2.5 rounded hover:bg-gray-100"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<component :is="cardBrandIcon(card.brand)" class="size-7" />
|
||||
<div>
|
||||
<div class="flex items-center gap-1 h-7 font-medium">
|
||||
<div>{{ card.name_on_card }}</div>
|
||||
<div>·</div>
|
||||
<div>Card ending in ••••</div>
|
||||
<div>{{ card.last_4 }}</div>
|
||||
<Badge
|
||||
v-if="card.is_default"
|
||||
class="ml-1.5"
|
||||
label="Default"
|
||||
variant="outline"
|
||||
theme="green"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-gray-600">
|
||||
Expiry
|
||||
{{
|
||||
card.expiry_month < 10
|
||||
? `0${card.expiry_month}`
|
||||
: card.expiry_month
|
||||
}}/{{ card.expiry_year }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="cards.data.length > 1 && !card.is_default">
|
||||
<Dropdown
|
||||
:options="[
|
||||
{
|
||||
label: 'Set as default',
|
||||
onClick: () => setAsDefault(card.name),
|
||||
condition: () => !card.is_default,
|
||||
},
|
||||
{ label: 'Remove', onClick: () => removeCard(card.name) },
|
||||
]"
|
||||
>
|
||||
<Button icon="more-horizontal" variant="ghost" />
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #actions>
|
||||
<Button
|
||||
label="Add new card"
|
||||
class="w-full"
|
||||
variant="solid"
|
||||
@click="emit('addCard')"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon name="plus" class="h-4" />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { createDialog } from '../dialogs.js'
|
||||
import { Dropdown, Badge, Dialog, Button, FeatherIcon, createResource } from 'frappe-ui'
|
||||
import { cardBrandIcon } from '../utils.js'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const emit = defineEmits(['success', 'addCard'])
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
const cards = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.get_payment_methods' },
|
||||
cache: 'cards',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const setAsDefault = (card) => {
|
||||
createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.set_as_default', data: { name: card } },
|
||||
auto: true,
|
||||
onSuccess: () => {
|
||||
cards.reload()
|
||||
emit('success')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const confirmDialogOpened = ref(false)
|
||||
const removeCard = (card) => {
|
||||
confirmDialogOpened.value = true
|
||||
createDialog({
|
||||
title: 'Remove Card',
|
||||
message: 'Are you sure you want to remove this card?',
|
||||
actions: [
|
||||
{
|
||||
label: 'Remove',
|
||||
variant: 'solid',
|
||||
theme: 'red',
|
||||
onClick: (close) => {
|
||||
createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: {
|
||||
method: 'billing.remove_payment_method',
|
||||
data: { name: card },
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: () => {
|
||||
cards.reload()
|
||||
confirmDialogOpened.value = false
|
||||
close()
|
||||
},
|
||||
})
|
||||
},
|
||||
},
|
||||
],
|
||||
onClose: () => {
|
||||
confirmDialogOpened.value = false
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
214
billing/src/components/CurrentPlan.vue
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lg font-semibold text-gray-900">
|
||||
{{ 'Current plan' }}
|
||||
</div>
|
||||
<div
|
||||
v-if="currentPlan?.is_trial_plan"
|
||||
class="flex justify-between shadow rounded-lg py-3 px-4 text-base"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="font-semibold text-gray-900 text-lg">
|
||||
{{ currentPlan.is_trial_plan ? 'Trial plan' : currentPlan.name }}
|
||||
</div>
|
||||
<div v-if="currentPlan.is_trial_plan" class="text-gray-700">
|
||||
{{ trialDescription }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="currentPlan.is_trial_plan && currentPlan.support_included"
|
||||
class="text-gray-700 inline-flex items-center gap-1.5"
|
||||
>
|
||||
<FeatherIcon class="h-4" name="info" />
|
||||
<span> Support Included </span>
|
||||
</div>
|
||||
<div v-else class="text-gray-700">
|
||||
<span>{{ currency }}{{ price.value }}</span>
|
||||
<span class="font-normal">{{ ' / month · See plan details' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="solid"
|
||||
:label="currentPlan.is_trial_plan ? 'Upgrade now' : 'Change plan'"
|
||||
@click="emit('changePlan')"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="currentPlan"
|
||||
class="flex flex-col shadow rounded-lg text-base text-gray-900"
|
||||
>
|
||||
<div class="flex flex-col gap-2.5 py-3 px-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="font-semibold text-lg">Recurring Charges</div>
|
||||
<div class="text-gray-700">
|
||||
<span>Next charge date — </span>
|
||||
<span>{{ currentMonthEnd() }}</span>
|
||||
<span> · </span>
|
||||
<Tooltip>
|
||||
<template #body>
|
||||
<PlanDetails :plan="currentPlan" />
|
||||
</template>
|
||||
<span class="hover:underline cursor-pointer">
|
||||
See plan details
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5 text-end">
|
||||
<div>
|
||||
<span class="font-semibold text-xl"> {{ currency }}{{ price }} </span>
|
||||
<span>/mo</span>
|
||||
</div>
|
||||
<div class="text-gray-600">
|
||||
<span>{{ currency }}{{ (price / 30).toFixed(2) }}</span>
|
||||
<span>/day</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<div class="text-gray-700 flex gap-2">
|
||||
<CardIcon class="h-4 w-4" />
|
||||
<div>
|
||||
<span>Current billing amount so far </span>
|
||||
<span class="text-gray-900 font-medium">
|
||||
{{ currency }} {{ currentBillingAmount?.toFixed(2) || '0.00' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="solid" label="Upgrade plan" @click="emit('changePlan')" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="unpaidAmount.data"
|
||||
class="flex justify-between items-center rounded-lg py-2 px-2.5 m-1.5 bg-gray-50"
|
||||
>
|
||||
<div class="text-gray-800 flex items-center gap-2 h-7">
|
||||
<UnPaidBillIcon class="h-4 w-4" />
|
||||
<div>
|
||||
<span>Unpaid amount is </span>
|
||||
<span>{{ currency }} {{ unpaidAmount.data?.toFixed(2) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Button variant="outline" label="Pay now" @click="payNow" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="flex items-start justify-center">
|
||||
<Spinner class="h-4 w-4 text-gray-700" />
|
||||
</div>
|
||||
<AddPrepaidCreditsModal
|
||||
v-if="showAddPrepaidCreditsModal"
|
||||
v-model="showAddPrepaidCreditsModal"
|
||||
@success="upcomingInvoice.reload()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import CardIcon from '../icons/CardIcon.vue'
|
||||
import UnPaidBillIcon from '../icons/UnPaidBillIcon.vue'
|
||||
import PlanDetails from './PlanDetails.vue'
|
||||
import AddPrepaidCreditsModal from './AddPrepaidCreditsModal.vue'
|
||||
import { Button, Tooltip, Spinner, FeatherIcon, createResource } from 'frappe-ui'
|
||||
import { calculateTrialEndDays } from '../utils.js'
|
||||
import { ref, computed, inject } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { createDialog } from '../dialogs.js'
|
||||
|
||||
const emit = defineEmits(['changePlan'])
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const team = inject('team')
|
||||
const currentSiteInfo = inject('currentSiteInfo')
|
||||
const { currentBillingAmount, upcomingInvoice } = inject('billing')
|
||||
|
||||
const showAddPrepaidCreditsModal = ref(false)
|
||||
|
||||
const trialEndDays = ref(0)
|
||||
const trialDescription = computed(() => {
|
||||
return trialEndDays.value > 1
|
||||
? 'Your trial plan ends in ' + trialEndDays.value + ' days'
|
||||
: 'Your trial plan will end tomorrow'
|
||||
})
|
||||
|
||||
const price = ref(null)
|
||||
const currency = computed(() => (team.data.currency == 'INR' ? '₹' : '$'))
|
||||
|
||||
const currentPlan = computed(() => {
|
||||
if (!currentSiteInfo.data) return null
|
||||
trialEndDays.value = calculateTrialEndDays(currentSiteInfo.data.trial_end_date)
|
||||
let _currentPlan = currentSiteInfo.data.plan
|
||||
price.value = currency.value === '₹' ? _currentPlan.price_inr : _currentPlan.price_usd
|
||||
return _currentPlan
|
||||
})
|
||||
|
||||
const unpaidAmount = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.total_unpaid_amount' },
|
||||
cache: 'unpaidAmount',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const currentMonthEnd = () => {
|
||||
const date = new Date()
|
||||
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0)
|
||||
return lastDay.toLocaleDateString('en-US', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const unpaidInvoices = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.get_unpaid_invoices' },
|
||||
})
|
||||
|
||||
function payNow() {
|
||||
team.data.payment_mode == 'Prepaid Credits'
|
||||
? (showAddPrepaidCreditsModal.value = true)
|
||||
: payUnpaidInvoices()
|
||||
}
|
||||
|
||||
async function payUnpaidInvoices() {
|
||||
let _unpaidInvoices = await unpaidInvoices.reload()
|
||||
if (_unpaidInvoices.length > 1) {
|
||||
createDialog({
|
||||
title: 'Multiple unpaid invoices',
|
||||
message: 'You have multiple unpaid invoices. Please pay them from the invoices page',
|
||||
actions: [
|
||||
{
|
||||
label: 'Go to invoices',
|
||||
variant: 'solid',
|
||||
onClick: (close) => {
|
||||
router.push({ name: 'Invoices' })
|
||||
close()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
} else {
|
||||
let invoice = _unpaidInvoices
|
||||
if (invoice.stripe_invoice_url) {
|
||||
window.open(invoice.stripe_invoice_url, '_blank')
|
||||
} else {
|
||||
createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: {
|
||||
method: 'billing.get_stripe_payment_url_for_invoice',
|
||||
data: { name: invoice.name },
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: (url) => window.open(url, '_blank'),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
22
billing/src/components/DropdownItem.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<template>
|
||||
<div>
|
||||
<Button
|
||||
class="flex justify-between w-full rounded text-base"
|
||||
variant="ghost"
|
||||
:label="label"
|
||||
@click="onClick"
|
||||
>
|
||||
<template v-if="active" #suffix>
|
||||
<FeatherIcon class="size-4" name="check" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Button, FeatherIcon } from 'frappe-ui'
|
||||
const props = defineProps({
|
||||
label: String,
|
||||
active: Boolean,
|
||||
onClick: Array,
|
||||
})
|
||||
</script>
|
||||
109
billing/src/components/FCDashboardLoginModal.vue
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<template>
|
||||
<Dialog
|
||||
v-model="show"
|
||||
:options="{
|
||||
title: loginLink ? 'Login successful!' : 'Login to Frappe Cloud Dashboard',
|
||||
}"
|
||||
>
|
||||
<template #body-content>
|
||||
<div class="text-base leading-5">
|
||||
<div v-if="verificationCodeSent" class="flex flex-col gap-4">
|
||||
<p>
|
||||
Verification code has been sent to your email id
|
||||
<b>{{ email }}</b>
|
||||
</p>
|
||||
<FormControl
|
||||
v-model="verification_code"
|
||||
type="text"
|
||||
label="Verification Code"
|
||||
placeholder="Enter the verification code"
|
||||
/>
|
||||
<ErrorMessage v-if="verifyAndLogin.error" :message="verifyAndLogin.error" />
|
||||
</div>
|
||||
<div v-else-if="loginLink">
|
||||
<p>You will be redirected to the Frappe Cloud Dashboard</p>
|
||||
<p>
|
||||
If you haven't been redirected,
|
||||
<a class="underline" :href="loginLink" target="_blank">
|
||||
Click here to login
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else>
|
||||
<p>
|
||||
Send a verification code to your email id
|
||||
<b>{{ email }}</b>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="!loginLink" #actions>
|
||||
<Button
|
||||
v-if="verificationCodeSent"
|
||||
class="w-full mb-2"
|
||||
label="Din't receive the code? Resend"
|
||||
:loading="sendVerificationCode.loading"
|
||||
@click="() => sendVerificationCode.fetch()"
|
||||
/>
|
||||
<Button
|
||||
v-if="verificationCodeSent"
|
||||
class="w-full"
|
||||
label="Verify & Login"
|
||||
variant="solid"
|
||||
:disabled="!verification_code"
|
||||
:loading="verifyAndLogin.loading"
|
||||
@click="() => verifyAndLogin.fetch()"
|
||||
/>
|
||||
<Button
|
||||
v-else
|
||||
class="w-full"
|
||||
label="Send verification code"
|
||||
variant="solid"
|
||||
:loading="sendVerificationCode.loading"
|
||||
@click="() => sendVerificationCode.fetch()"
|
||||
/>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Button, Dialog, FormControl, ErrorMessage, createResource } from 'frappe-ui'
|
||||
import { ref, computed, inject } from 'vue'
|
||||
|
||||
const show = defineModel()
|
||||
|
||||
const team = inject('team')
|
||||
|
||||
const email = computed(() => maskEmail(team.data?.user))
|
||||
|
||||
function maskEmail(emailId) {
|
||||
if (!emailId) return ''
|
||||
const [username, domain] = emailId.split('@')
|
||||
let domainParts = domain.split('.')
|
||||
return `${username.slice(0, 2)}****@${domainParts[0].slice(0, 2)}****.${domainParts[1]}`
|
||||
}
|
||||
|
||||
const verification_code = ref('')
|
||||
const verificationCodeSent = ref(false)
|
||||
|
||||
const sendVerificationCode = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.send_verification_code',
|
||||
onSuccess: () => {
|
||||
verificationCodeSent.value = true
|
||||
},
|
||||
})
|
||||
|
||||
const loginLink = ref(null)
|
||||
|
||||
const verifyAndLogin = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.verify_and_login',
|
||||
makeParams: () => {
|
||||
return { verification_code: verification_code.value }
|
||||
},
|
||||
onSuccess: ({ base_url, login_token }) => {
|
||||
loginLink.value = `${base_url}/api/method/press.api.developer.saas.login_to_fc?token=${login_token}`
|
||||
window.open(loginLink.value, '_blank')
|
||||
verificationCodeSent.value = false
|
||||
},
|
||||
})
|
||||
</script>
|
||||
263
billing/src/components/PaymentDetails.vue
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="text-lg font-semibold text-gray-900">
|
||||
{{ 'Payment details' }}
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
v-if="team.data.payment_mode == 'Card'"
|
||||
class="flex justify-between items-center text-base text-gray-900"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="font-medium">{{ 'Active card' }}</div>
|
||||
<div class="overflow-hidden text-gray-700 text-ellipsis">
|
||||
<div
|
||||
v-if="team.data.payment_method"
|
||||
class="inline-flex items-center gap-2"
|
||||
>
|
||||
<component :is="cardBrandIcon(team.data.payment_method.brand)" />
|
||||
<div class="text-gray-700">
|
||||
<span>{{ team.data.payment_method.name_on_card }}</span>
|
||||
<span> · Card ending in •••• </span>
|
||||
<span>{{ team.data.payment_method.last_4 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else class="text-gray-700">No card added</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<Button
|
||||
:label="team.data.payment_method ? 'Change card' : 'Add card'"
|
||||
@click="changeMethod"
|
||||
>
|
||||
<template v-if="!team.data.payment_method" #prefix>
|
||||
<FeatherIcon class="h-4" name="plus" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="team.data.payment_mode == 'Card'" class="bg-gray-100 h-px my-3" />
|
||||
<div class="flex justify-between items-center text-base text-gray-900">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="font-medium">{{ 'Mode of payment' }}</div>
|
||||
<div
|
||||
v-if="team.data.payment_mode"
|
||||
class="inline-flex items-center gap-2 text-gray-700"
|
||||
>
|
||||
<FeatherIcon class="h-4" name="info" />
|
||||
{{ paymentMode.description }}
|
||||
</div>
|
||||
<span v-else class="text-gray-700">Not set</span>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<Dropdown :options="paymentModeOptions">
|
||||
<template #default="{ open }">
|
||||
<Button
|
||||
:label="team.data.payment_mode ? paymentMode.label : 'Set mode'"
|
||||
>
|
||||
<template #suffix>
|
||||
<FeatherIcon
|
||||
:name="open ? 'chevron-up' : 'chevron-down'"
|
||||
class="h-4"
|
||||
/>
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 h-px my-3" />
|
||||
<div class="flex justify-between items-center text-base text-gray-900">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="font-medium">{{ 'Credit balance' }}</div>
|
||||
<div class="text-gray-700">
|
||||
{{ availableCredits || currency + ' 0.00' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<Button
|
||||
:label="'Add credit'"
|
||||
@click="
|
||||
() => {
|
||||
showMessage = false
|
||||
if (!billingDetailsSummary) {
|
||||
showMessage = true
|
||||
showBillingDetailsDialog = true
|
||||
return
|
||||
}
|
||||
showAddPrepaidCreditsModal = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template #prefix>
|
||||
<FeatherIcon class="h-4" name="plus" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-gray-100 h-px my-3" />
|
||||
<div class="flex justify-between items-center text-base text-gray-900">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<div class="font-medium">{{ 'Billing address' }}</div>
|
||||
<div v-if="billingDetailsSummary" class="text-gray-700 leading-5">
|
||||
{{ billingDetailsSummary }}
|
||||
</div>
|
||||
<div v-else class="text-gray-700">No address</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<Button
|
||||
:label="billingDetailsSummary ? 'Edit information' : 'Add billing address'"
|
||||
@click="
|
||||
() => {
|
||||
showMessage = false
|
||||
showBillingDetailsDialog = true
|
||||
}
|
||||
"
|
||||
>
|
||||
<template v-if="!billingDetailsSummary" #prefix>
|
||||
<FeatherIcon class="h-4" name="plus" />
|
||||
</template>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BillingDetailsModal
|
||||
v-if="showBillingDetailsDialog"
|
||||
v-model="showBillingDetailsDialog"
|
||||
:showMessage="showMessage"
|
||||
@success="billingDetails.reload()"
|
||||
/>
|
||||
<AddPrepaidCreditsModal
|
||||
v-if="showAddPrepaidCreditsModal"
|
||||
v-model="showAddPrepaidCreditsModal"
|
||||
:showMessage="showMessage"
|
||||
@success="upcomingInvoice.reload()"
|
||||
/>
|
||||
<AddCardModal
|
||||
v-if="showAddCardModal"
|
||||
v-model="showAddCardModal"
|
||||
:showMessage="showMessage"
|
||||
@success="
|
||||
() => {
|
||||
showMessage = false
|
||||
showAddCardModal = false
|
||||
team.reload()
|
||||
}
|
||||
"
|
||||
/>
|
||||
<ChangeCardModal
|
||||
v-if="showChangeCardModal"
|
||||
v-model="showChangeCardModal"
|
||||
@addCard="
|
||||
() => {
|
||||
showChangeCardModal = false
|
||||
showAddCardModal = true
|
||||
}
|
||||
"
|
||||
@success="() => team.reload()"
|
||||
/>
|
||||
</template>
|
||||
<script setup>
|
||||
import DropdownItem from './DropdownItem.vue'
|
||||
import BillingDetailsModal from './BillingDetailsModal.vue'
|
||||
import AddPrepaidCreditsModal from './AddPrepaidCreditsModal.vue'
|
||||
import AddCardModal from './AddCardModal.vue'
|
||||
import ChangeCardModal from './ChangeCardModal.vue'
|
||||
import { Dropdown, Button, FeatherIcon, createResource } from 'frappe-ui'
|
||||
import { cardBrandIcon } from '../utils.js'
|
||||
import { computed, ref, inject, h } from 'vue'
|
||||
|
||||
const team = inject('team')
|
||||
const { availableCredits, upcomingInvoice } = inject('billing')
|
||||
|
||||
const showBillingDetailsDialog = ref(false)
|
||||
const showAddPrepaidCreditsModal = ref(false)
|
||||
const showAddCardModal = ref(false)
|
||||
const showChangeCardModal = ref(false)
|
||||
|
||||
const currency = computed(() => (team.data.currency == 'INR' ? '₹' : '$'))
|
||||
|
||||
const billingDetails = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.get_information' },
|
||||
cache: 'billingDetails',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const billingDetailsSummary = computed(() => {
|
||||
let _billingDetails = billingDetails.data
|
||||
if (!_billingDetails) return ''
|
||||
|
||||
const { billing_name, address_line1, city, state, country, pincode, gstin } =
|
||||
_billingDetails || {}
|
||||
return [billing_name, address_line1, city, state, country, pincode, gstin]
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
})
|
||||
|
||||
const paymentModeOptions = [
|
||||
{
|
||||
label: 'Card',
|
||||
value: 'Card',
|
||||
description: 'Your card will be charged for monthly subscription',
|
||||
component: () =>
|
||||
h(DropdownItem, {
|
||||
label: 'Card',
|
||||
active: team.data.payment_mode === 'Card',
|
||||
onClick: () => updatePaymentMode('Card'),
|
||||
}),
|
||||
},
|
||||
{
|
||||
label: 'Prepaid credits',
|
||||
value: 'Prepaid Credits',
|
||||
description: 'You will be charged from your credit balance for monthly subscription',
|
||||
component: () =>
|
||||
h(DropdownItem, {
|
||||
label: 'Prepaid credits',
|
||||
active: team.data.payment_mode === 'Prepaid Credits',
|
||||
onClick: () => updatePaymentMode('Prepaid Credits'),
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const paymentMode = computed(() => {
|
||||
return paymentModeOptions.find((o) => o.value === team.data.payment_mode)
|
||||
})
|
||||
|
||||
const showMessage = ref(false)
|
||||
function updatePaymentMode(mode) {
|
||||
showMessage.value = false
|
||||
if (!billingDetailsSummary.value) {
|
||||
showMessage.value = true
|
||||
showBillingDetailsDialog.value = true
|
||||
return
|
||||
}
|
||||
if (mode === 'Prepaid Credits' && team.data.balance === 0) {
|
||||
showMessage.value = true
|
||||
showAddPrepaidCreditsModal.value = true
|
||||
return
|
||||
} else if (mode === 'Card' && !team.data.payment_method) {
|
||||
showMessage.value = true
|
||||
showAddCardModal.value = true
|
||||
}
|
||||
createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: {
|
||||
method: 'billing.change_payment_mode',
|
||||
data: { mode },
|
||||
},
|
||||
auto: true,
|
||||
onSuccess: () => team.reload(),
|
||||
})
|
||||
}
|
||||
|
||||
function changeMethod() {
|
||||
if (team.data.payment_method) {
|
||||
showChangeCardModal.value = true
|
||||
} else {
|
||||
showMessage.value = false
|
||||
showAddCardModal.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
24
billing/src/components/PlanDetails.vue
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<template>
|
||||
<ul class="rounded px-7 py-1.5 bg-gray-900 text-white text-base leading-6 list-disc">
|
||||
<li class="list-disc">{{ plan.cpu_time_per_day }} compute hours/day</li>
|
||||
<li>{{ parseSize(plan.max_database_usage) }} Database</li>
|
||||
<li>{{ parseSize(plan.max_storage_usage) }} Disk</li>
|
||||
<li>Product Waranty</li>
|
||||
<li v-if="plan.support_included">Support Included</li>
|
||||
<li v-if="plan.database_access">Database Access</li>
|
||||
<li v-if="plan.offsite_backups">Offsite Backups</li>
|
||||
<li v-if="plan.private_benches">Private Benches</li>
|
||||
<li v-if="plan.monitor_access">Advanced Monitoring</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { parseSize } from '../utils.js'
|
||||
|
||||
const props = defineProps({
|
||||
plan: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
122
billing/src/components/PrepaidCreditsForm.vue
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
<template>
|
||||
<div>
|
||||
<!-- Amount -->
|
||||
<div>
|
||||
<FormControl
|
||||
:label="`Amount (Minimum Amount: ${minimumAmount})`"
|
||||
class="mb-3"
|
||||
v-model.number="creditsToBuy"
|
||||
name="amount"
|
||||
autocomplete="off"
|
||||
type="number"
|
||||
:min="minimumAmount"
|
||||
>
|
||||
<template #prefix>
|
||||
<div class="grid w-4 place-items-center text-sm text-gray-700">
|
||||
{{ team.data.currency === 'INR' ? '₹' : '$' }}
|
||||
</div>
|
||||
</template>
|
||||
</FormControl>
|
||||
<FormControl
|
||||
v-if="team.data.currency === 'INR'"
|
||||
:label="`Total Amount + GST (${team.data?.billing_info.gst_percentage * 100}%)`"
|
||||
disabled
|
||||
:modelValue="totalAmount"
|
||||
name="total"
|
||||
autocomplete="off"
|
||||
type="number"
|
||||
>
|
||||
<template #prefix>
|
||||
<div class="grid w-4 place-items-center text-sm text-gray-700">
|
||||
{{ team.data.currency === 'INR' ? '₹' : '$' }}
|
||||
</div>
|
||||
</template>
|
||||
</FormControl>
|
||||
</div>
|
||||
|
||||
<!-- Payment Gateway -->
|
||||
<div class="mt-4">
|
||||
<div class="text-xs text-gray-600">Select Payment Gateway</div>
|
||||
<div class="mt-1.5 grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<Button
|
||||
v-if="team.data.currency === 'INR' || team.data.razorpay_enabled"
|
||||
size="lg"
|
||||
:class="{
|
||||
'border-gray-700 border-[1.5px]': paymentGateway === 'Razorpay',
|
||||
}"
|
||||
@click="paymentGateway = 'Razorpay'"
|
||||
>
|
||||
<RazorpayLogo class="w-24" />
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
:class="{
|
||||
'border-gray-700 border-[1.5px]': paymentGateway === 'Stripe',
|
||||
}"
|
||||
@click="paymentGateway = 'Stripe'"
|
||||
>
|
||||
<StripeLogo class="h-7 w-24" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Payment Button -->
|
||||
<BuyCreditsStripe
|
||||
v-if="paymentGateway === 'Stripe'"
|
||||
:amount="creditsToBuy"
|
||||
:minimumAmount="minimumAmount"
|
||||
@success="() => emit('success')"
|
||||
@cancel="show = false"
|
||||
/>
|
||||
|
||||
<BuyCreditsRazorpay
|
||||
v-if="paymentGateway === 'Razorpay'"
|
||||
:amount="creditsToBuy"
|
||||
:minimumAmount="minimumAmount"
|
||||
@success="() => emit('success')"
|
||||
@cancel="show = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<script setup>
|
||||
import BuyCreditsStripe from './BuyCreditsStripe.vue'
|
||||
import BuyCreditsRazorpay from './BuyCreditsRazorpay.vue'
|
||||
import RazorpayLogo from '../logo/RazorpayLogo.vue'
|
||||
import StripeLogo from '../logo/StripeLogo.vue'
|
||||
import { FormControl, Button, createResource } from 'frappe-ui'
|
||||
import { ref, computed, inject } from 'vue'
|
||||
|
||||
const emit = defineEmits(['success'])
|
||||
|
||||
const team = inject('team')
|
||||
|
||||
const totalUnpaidAmount = createResource({
|
||||
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
|
||||
params: { method: 'billing.total_unpaid_amount' },
|
||||
cache: 'totalUnpaidAmount',
|
||||
auto: true,
|
||||
})
|
||||
|
||||
const minimumAmount = computed(() => {
|
||||
if (!team.data) return 0
|
||||
const unpaidAmount = totalUnpaidAmount.data || 0
|
||||
const minimumDefault = team.data?.currency == 'INR' ? 410 : 5
|
||||
|
||||
return Math.ceil(unpaidAmount && unpaidAmount > 0 ? unpaidAmount : minimumDefault)
|
||||
})
|
||||
|
||||
const creditsToBuy = ref(minimumAmount.value)
|
||||
const paymentGateway = ref('')
|
||||
|
||||
const totalAmount = computed(() => {
|
||||
let _creditsToBuy = creditsToBuy.value || 0
|
||||
if (team.data?.currency === 'INR') {
|
||||
return (
|
||||
_creditsToBuy +
|
||||
_creditsToBuy * (team.data.billing_info.gst_percentage || 0)
|
||||
).toFixed(2)
|
||||
} else {
|
||||
return _creditsToBuy
|
||||
}
|
||||
})
|
||||
</script>
|
||||