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
  ...
This commit is contained in:
Akhil Narang 2024-12-24 12:36:33 +05:30
commit 7af83f6d37
No known key found for this signature in database
GPG key ID: 9DCC61E211BF645F
1076 changed files with 285226 additions and 291376 deletions

View file

@ -18,4 +18,4 @@ max_line_length = 99
[{*.json}]
insert_final_newline = false
indent_style = space
indent_size = 2
indent_size = 1

View file

@ -52,3 +52,9 @@ de9ac897482013f5464a05f3c171da0072619c3a
# some new ruff rules
48cf19d7e997896d12aee7c7d97f73c8df217204
# caching decorators docstrings indentation
e9bbe03354079cfcef65a77b0c33f57b047a7c93
# ruff update
84ef6ec677c8657c3243ac456a1ef794bfb34a50

View file

@ -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
View 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
View 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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 626 B

BIN
.github/fw-form-view.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 876 KiB

BIN
.github/fw-list-view.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 950 KiB

BIN
.github/fw-rpm.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 KiB

99
.github/helper/ci.py vendored
View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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::"

View file

@ -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::"

View file

@ -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')

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 949 KiB

4
.github/stale.yml vendored
View file

@ -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
View 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
View 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
View 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
View 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)

View file

@ -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: |

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View 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 }}'

View file

@ -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 }}"}'

View file

@ -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
View file

@ -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

View file

@ -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
View file

@ -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>
![List View](.github/fw-list-view.png)
![Form View](.github/fw-form-view.png)
![Role Permission Manager](.github/fw-rpm.png)
</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>

View file

@ -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

1 hooks.py **/hooks.py frappe.gettext.extractors.navbar.extract
2 **/doctype/*/*.json **/doctype/*/*.json frappe.gettext.extractors.doctype.extract
3 **/workspace/*/*.json **/workspace/*/*.json frappe.gettext.extractors.workspace.extract
4 **/onboarding_step/*/*.json **/onboarding_step/*/*.json frappe.gettext.extractors.onboarding_step.extract
5 **/module_onboarding/*/*.json **/module_onboarding/*/*.json frappe.gettext.extractors.module_onboarding.extract
6 **/report/*/*.json **/report/*/*.json frappe.gettext.extractors.report.extract
7 **.py **.py frappe.gettext.extractors.python.extract
8 **/templates/**.js frappe.gettext.extractors.html_template.extract
9 **.js **.js frappe.gettext.extractors.javascript.extract
10 **.html **.html frappe.gettext.extractors.html_template.extract
11 **.vue frappe.gettext.extractors.html_template.extract
12 **/custom/*.json frappe.gettext.extractors.customization.extract
13 **/fixtures/custom_field.json frappe.gettext.extractors.custom_field.extract

5
billing/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
node_modules
.DS_Store
dist
dist-ssr
*.local

4
billing/.prettierrc.json Normal file
View file

@ -0,0 +1,4 @@
{
"semi": false,
"singleQuote": true
}

27
billing/README.md Normal file
View 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
View 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
View 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"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
billing/public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 B

40
billing/src/App.vue Normal file
View 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>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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");
}

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>&middot;</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>

View 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>

View 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>

View 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>

View 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> &middot; 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>

View 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>

View 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>

Some files were not shown because too many files have changed in this diff Show more