mirror of
https://github.com/zulip/zulip.git
synced 2025-10-24 16:43:57 +00:00
Compare commits
128 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e724c1ec6 | ||
|
|
e2d303c1bb | ||
|
|
d3091a6096 | ||
|
|
313bcfd02a | ||
|
|
09bfd485e9 | ||
|
|
576ae9cc9f | ||
|
|
300447ddd9 | ||
|
|
f8149b0d5a | ||
|
|
b579dad7d9 | ||
|
|
fdfabb800d | ||
|
|
2c4156678c | ||
|
|
0a87276a27 | ||
|
|
19aed43817 | ||
|
|
d370aefe3a | ||
|
|
0f5657b0ed | ||
|
|
24277a144e | ||
|
|
df8b8b9836 | ||
|
|
64fab06adb | ||
|
|
9391840d34 | ||
|
|
658e641d12 | ||
|
|
467723145b | ||
|
|
4ce37176db | ||
|
|
82bf185b1b | ||
|
|
d81ce3ba76 | ||
|
|
aa6e70382d | ||
|
|
0147c6adce | ||
|
|
5ae8fe292d | ||
|
|
2e8d8ca044 | ||
|
|
ec0835b947 | ||
|
|
e5e7e58c99 | ||
|
|
6a6c6d469b | ||
|
|
34512727e4 | ||
|
|
da3396b4d7 | ||
|
|
3f1b444a9a | ||
|
|
d5a5d0a3e7 | ||
|
|
bac90f6a9d | ||
|
|
9fbfdb0aca | ||
|
|
7fe1e55483 | ||
|
|
cb0d29d845 | ||
|
|
1c83ebfc71 | ||
|
|
8d040d36ed | ||
|
|
f4b955f2ee | ||
|
|
aa3f9004ba | ||
|
|
90bf44bde0 | ||
|
|
dbb7bc824c | ||
|
|
3d4071fea7 | ||
|
|
eb7464c68d | ||
|
|
1c2deb0cd3 | ||
|
|
26f4ab9a9d | ||
|
|
5feba78939 | ||
|
|
04600acbbb | ||
|
|
6ffbb6081b | ||
|
|
1f2767f940 | ||
|
|
9173ed0fb9 | ||
|
|
303bde6c55 | ||
|
|
bc118496a2 | ||
|
|
f118da6b86 | ||
|
|
1ba708ca96 | ||
|
|
e156db2bc7 | ||
|
|
d0235add03 | ||
|
|
a6b06df895 | ||
|
|
2df2f7eec6 | ||
|
|
ad858d2c79 | ||
|
|
5290f17adb | ||
|
|
9824a9d7cf | ||
|
|
88a2a80d81 | ||
|
|
5b16ee0c08 | ||
|
|
17dced26ff | ||
|
|
fc9c5b1f43 | ||
|
|
564873a207 | ||
|
|
c692263255 | ||
|
|
bfe428f608 | ||
|
|
d200e3547f | ||
|
|
b6afa4a82b | ||
|
|
4db187856d | ||
|
|
36638c95b9 | ||
|
|
85f14eb4f7 | ||
|
|
0fab79c027 | ||
|
|
7d46bed507 | ||
|
|
a89ba9c7d6 | ||
|
|
8f735f4683 | ||
|
|
e7cfd30d53 | ||
|
|
10c8c0e071 | ||
|
|
9f8b5e225d | ||
|
|
62194eb20f | ||
|
|
2492f4b60e | ||
|
|
1b2967ddb5 | ||
|
|
42774b101f | ||
|
|
716cba04de | ||
|
|
332add3bb6 | ||
|
|
b596cd7607 | ||
|
|
21cedabbdf | ||
|
|
f910d5b8a9 | ||
|
|
daf185705d | ||
|
|
1fa7081a4c | ||
|
|
0d17a5e76d | ||
|
|
9815581957 | ||
|
|
33d7aa9d47 | ||
|
|
6c3a6ef6c1 | ||
|
|
a63150ca35 | ||
|
|
7ab8455596 | ||
|
|
43be62c7ef | ||
|
|
7b15ce71c2 | ||
|
|
96c5a9e303 | ||
|
|
0b337e0819 | ||
|
|
d4b3c20e48 | ||
|
|
31be0f04b9 | ||
|
|
6af0e28e5d | ||
|
|
9cb538b08f | ||
|
|
bf49f962c0 | ||
|
|
2a69b4f3b7 | ||
|
|
540904aa9d | ||
|
|
26bdf79642 | ||
|
|
2c1ffaceca | ||
|
|
dffff73654 | ||
|
|
2f9d4f5a96 | ||
|
|
ce96018af4 | ||
|
|
a025fab082 | ||
|
|
812ad52007 | ||
|
|
9066fcac9a | ||
|
|
a70ebdb005 | ||
|
|
956d4b2568 | ||
|
|
ea2256da29 | ||
|
|
d1bd8f3637 | ||
|
|
22d486bbf7 | ||
|
|
977ff62fe8 | ||
|
|
5bfc162df9 | ||
|
|
2aa643502a |
@@ -2,4 +2,4 @@
|
||||
> 0.15% in US
|
||||
last 2 versions
|
||||
Firefox ESR
|
||||
not dead and supports async-functions
|
||||
not dead
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
te
|
||||
ans
|
||||
pullrequest
|
||||
ist
|
||||
cros
|
||||
wit
|
||||
nwe
|
||||
circularly
|
||||
ned
|
||||
ba
|
||||
ressemble
|
||||
ser
|
||||
sur
|
||||
hel
|
||||
fpr
|
||||
alls
|
||||
nd
|
||||
ot
|
||||
@@ -7,8 +7,6 @@
|
||||
"eslint:recommended",
|
||||
"plugin:import/errors",
|
||||
"plugin:import/warnings",
|
||||
"plugin:no-jquery/recommended",
|
||||
"plugin:no-jquery/deprecated",
|
||||
"plugin:unicorn/recommended",
|
||||
"prettier"
|
||||
],
|
||||
@@ -17,16 +15,6 @@
|
||||
"warnOnUnsupportedTypeScriptVersion": false,
|
||||
"sourceType": "unambiguous"
|
||||
},
|
||||
"plugins": ["formatjs", "no-jquery"],
|
||||
"settings": {
|
||||
"additionalFunctionNames": ["$t", "$t_html"],
|
||||
"no-jquery": {
|
||||
"collectionReturningPlugins": {
|
||||
"expectOne": "always"
|
||||
},
|
||||
"variablePattern": "^\\$(?!t$|t_html$)."
|
||||
}
|
||||
},
|
||||
"reportUnusedDisableDirectives": true,
|
||||
"rules": {
|
||||
"array-callback-return": "error",
|
||||
@@ -36,17 +24,10 @@
|
||||
"curly": "error",
|
||||
"dot-notation": "error",
|
||||
"eqeqeq": "error",
|
||||
"formatjs/enforce-default-message": ["error", "literal"],
|
||||
"formatjs/enforce-placeholders": [
|
||||
"error",
|
||||
{"ignoreList": ["b", "code", "em", "i", "kbd", "p", "strong"]}
|
||||
],
|
||||
"formatjs/no-id": "error",
|
||||
"guard-for-in": "error",
|
||||
"import/extensions": "error",
|
||||
"import/first": "error",
|
||||
"import/newline-after-import": "error",
|
||||
"import/no-self-import": "error",
|
||||
"import/no-useless-path-segments": "error",
|
||||
"import/order": [
|
||||
"error",
|
||||
@@ -73,7 +54,6 @@
|
||||
"no-implied-eval": "error",
|
||||
"no-inner-declarations": "off",
|
||||
"no-iterator": "error",
|
||||
"no-jquery/no-parse-html-literal": "error",
|
||||
"no-label-var": "error",
|
||||
"no-labels": "error",
|
||||
"no-loop-func": "error",
|
||||
@@ -113,7 +93,6 @@
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/explicit-length-check": "off",
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/no-await-expression-member": "off",
|
||||
"unicorn/no-nested-ternary": "off",
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/no-process-exit": "off",
|
||||
@@ -129,12 +108,6 @@
|
||||
"yoda": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["frontend_tests/node_tests/**", "frontend_tests/zjsunit/**"],
|
||||
"rules": {
|
||||
"no-jquery/no-selector-prop": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["frontend_tests/puppeteer_lib/**", "frontend_tests/puppeteer_tests/**"],
|
||||
"globals": {
|
||||
@@ -150,31 +123,18 @@
|
||||
},
|
||||
{
|
||||
"files": ["**/*.ts"],
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||
"plugin:import/typescript"
|
||||
],
|
||||
"extends": ["plugin:@typescript-eslint/recommended", "plugin:import/typescript"],
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
},
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [".ts", ".d.ts", ".js"] // https://github.com/import-js/eslint-plugin-import/issues/2267
|
||||
}
|
||||
}
|
||||
},
|
||||
"globals": {
|
||||
"JQuery": false
|
||||
},
|
||||
"rules": {
|
||||
// Disable base rule to avoid conflict
|
||||
"no-duplicate-imports": "off",
|
||||
"no-unused-vars": "off",
|
||||
"no-useless-constructor": "off",
|
||||
"no-use-before-define": "off",
|
||||
|
||||
"@typescript-eslint/array-type": "error",
|
||||
"@typescript-eslint/await-thenable": "error",
|
||||
"@typescript-eslint/consistent-type-assertions": "error",
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"@typescript-eslint/explicit-function-return-type": [
|
||||
@@ -188,15 +148,12 @@
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-parameter-properties": "error",
|
||||
"@typescript-eslint/no-unnecessary-qualifier": "error",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
||||
"@typescript-eslint/no-unused-vars": ["error", {"varsIgnorePattern": "^_"}],
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-use-before-define": "error",
|
||||
"@typescript-eslint/no-useless-constructor": "error",
|
||||
"@typescript-eslint/prefer-includes": "error",
|
||||
"@typescript-eslint/prefer-regexp-exec": "error",
|
||||
"@typescript-eslint/prefer-string-starts-ends-with": "error",
|
||||
"@typescript-eslint/promise-function-async": "error",
|
||||
"@typescript-eslint/unified-signatures": "error",
|
||||
@@ -218,10 +175,7 @@
|
||||
"window": false
|
||||
},
|
||||
"rules": {
|
||||
"formatjs/no-id": "off",
|
||||
"new-cap": "off",
|
||||
"no-sync": "off",
|
||||
"unicorn/prefer-prototype-methods": "off"
|
||||
"no-sync": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
19
.gitattributes
vendored
19
.gitattributes
vendored
@@ -1,19 +1,4 @@
|
||||
# DIFFS: Noise suppression.
|
||||
#
|
||||
# Suppress noisy generated files in diffs.
|
||||
# (When you actually want to see these diffs, use `git diff -a`.)
|
||||
|
||||
# Large test fixtures:
|
||||
corporate/tests/stripe_fixtures/*.json -diff
|
||||
|
||||
|
||||
# FORMATTING
|
||||
|
||||
# Maintain LF (Unix-style) newlines in text files.
|
||||
* text=auto eol=lf
|
||||
|
||||
# Make sure various media files never get somehow auto-detected as text
|
||||
# and then newline-converted.
|
||||
*.gif binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
@@ -26,7 +11,3 @@ corporate/tests/stripe_fixtures/*.json -diff
|
||||
*.otf binary
|
||||
*.tif binary
|
||||
*.ogg binary
|
||||
*.bson binary
|
||||
*.bmp binary
|
||||
*.mp3 binary
|
||||
*.pdf binary
|
||||
|
||||
2
.github/workflows/cancel-previous-runs.yml
vendored
2
.github/workflows/cancel-previous-runs.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
workflow_api_url=https://api.github.com/repos/$REPOSITORY/actions/workflows
|
||||
curl -fL $workflow_api_url -o workflows.json
|
||||
curl $workflow_api_url -o workflows.json
|
||||
|
||||
script="const {workflows} = require('./workflows'); \
|
||||
const ids = workflows.map(workflow => workflow.id); \
|
||||
|
||||
16
.github/workflows/codeql-analysis.yml
vendored
16
.github/workflows/codeql-analysis.yml
vendored
@@ -1,10 +1,6 @@
|
||||
name: "Code scanning"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches-ignore:
|
||||
- dependabot/** # https://github.com/github/codeql-action/pull/435
|
||||
pull_request: {}
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
CodeQL:
|
||||
@@ -14,6 +10,15 @@ jobs:
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
# We must fetch at least the immediate parents so that if this is
|
||||
# a pull request then we can check out the head.
|
||||
fetch-depth: 2
|
||||
|
||||
# If this run was triggered by a pull request event, then check out
|
||||
# the head of the pull request instead of the merge commit.
|
||||
- run: git checkout HEAD^2
|
||||
if: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
@@ -22,6 +27,5 @@ jobs:
|
||||
# Override language selection by uncommenting this and choosing your languages
|
||||
# with:
|
||||
# languages: go, javascript, csharp, python, cpp, java
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v1
|
||||
|
||||
128
.github/workflows/production-suite.yml
vendored
128
.github/workflows/production-suite.yml
vendored
@@ -1,26 +1,28 @@
|
||||
name: Zulip production suite
|
||||
|
||||
on:
|
||||
push: {}
|
||||
pull_request:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/production-suite.yml
|
||||
- "**/migrations/**"
|
||||
- babel.config.js
|
||||
- manage.py
|
||||
- postcss.config.js
|
||||
- puppet/**
|
||||
- requirements/**
|
||||
- scripts/**
|
||||
- static/assets/**
|
||||
- static/third/**
|
||||
- static/**
|
||||
- tools/**
|
||||
- webpack.config.ts
|
||||
- yarn.lock
|
||||
- zerver/worker/queue_processors.py
|
||||
- zerver/lib/push_notifications.py
|
||||
- zerver/decorator.py
|
||||
- zproject/**
|
||||
- yarn.lock
|
||||
- .github/workflows/production-suite.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- "**/migrations/**"
|
||||
- puppet/**
|
||||
- requirements/**
|
||||
- scripts/**
|
||||
- static/**
|
||||
- tools/**
|
||||
- zproject/**
|
||||
- yarn.lock
|
||||
- .github/workflows/production-suite.yml
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -30,13 +32,13 @@ jobs:
|
||||
production_build:
|
||||
# This job builds a release tarball from the current commit, which
|
||||
# will be used for all of the following install/upgrade tests.
|
||||
name: Debian 10 production build
|
||||
name: Bionic production build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Docker images are built from 'tools/ci/Dockerfile'; the comments at
|
||||
# the top explain how to build and upload these images.
|
||||
# Debian 10 ships with Python 3.7.3.
|
||||
container: zulip/ci:buster
|
||||
# This docker image was created by a generated Dockerfile at:
|
||||
# tools/ci/images/bionic/Dockerfile
|
||||
# Bionic ships with Python 3.6.
|
||||
container: zulip/ci:bionic
|
||||
steps:
|
||||
- name: Add required permissions
|
||||
run: |
|
||||
@@ -66,22 +68,28 @@ jobs:
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /srv/zulip-npm-cache
|
||||
key: v1-yarn-deps-buster-${{ hashFiles('package.json') }}-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: v1-yarn-deps-buster
|
||||
key: v1-yarn-deps-${{ github.job }}-${{ hashFiles('package.json') }}-${{ hashFiles('yarn.lock') }}
|
||||
restore-keys: v1-yarn-deps-${{ github.job }}
|
||||
|
||||
- name: Restore python cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /srv/zulip-venv-cache
|
||||
key: v1-venv-buster-${{ hashFiles('requirements/dev.txt') }}
|
||||
restore-keys: v1-venv-buster
|
||||
key: v1-venv-${{ github.job }}-${{ hashFiles('requirements/dev.txt') }}
|
||||
restore-keys: v1-venv-${{ github.job }}
|
||||
|
||||
- name: Restore emoji cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: /srv/zulip-emoji-cache
|
||||
key: v1-emoji-buster-${{ hashFiles('tools/setup/emoji/emoji_map.json') }}-${{ hashFiles('tools/setup/emoji/build_emoji') }}-${{ hashFiles('tools/setup/emoji/emoji_setup_utils.py') }}-${{ hashFiles('tools/setup/emoji/emoji_names.py') }}-${{ hashFiles('package.json') }}
|
||||
restore-keys: v1-emoji-buster
|
||||
key: v1-emoji-${{ github.job }}-${{ hashFiles('tools/setup/emoji/emoji_map.json') }}-${{ hashFiles('tools/setup/emoji/build_emoji') }}-${{ hashFiles('tools/setup/emoji/emoji_setup_utils.py') }}-${{ hashFiles('tools/setup/emoji/emoji_names.py') }}-${{ hashFiles('package.json') }}
|
||||
restore-keys: v1-emoji-${{ github.job }}
|
||||
|
||||
- name: Do Bionic hack
|
||||
run: |
|
||||
# Temporary hack till `sudo service redis-server start` gets fixes in Bionic. See
|
||||
# https://chat.zulip.org/#narrow/stream/3-backend/topic/Ubuntu.20bionic.20CircleCI
|
||||
sudo sed -i '/^bind/s/bind.*/bind 0.0.0.0/' /etc/redis/redis.conf
|
||||
|
||||
- name: Build production tarball
|
||||
run: ./tools/ci/production-build
|
||||
@@ -106,25 +114,27 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
extra_args: [""]
|
||||
include:
|
||||
# Docker images are built from 'tools/ci/Dockerfile'; the comments at
|
||||
# the top explain how to build and upload these images.
|
||||
# Base images are built using `tools/ci/Dockerfile.template`.
|
||||
# The comments at the top explain how to build and upload these images.
|
||||
- docker_image: zulip/ci:bionic
|
||||
name: Bionic production install
|
||||
is_bionic: true
|
||||
os: bionic
|
||||
|
||||
- docker_image: zulip/ci:focal
|
||||
name: Ubuntu 20.04 production install
|
||||
name: Focal production install
|
||||
is_focal: true
|
||||
os: focal
|
||||
|
||||
- docker_image: zulip/ci:jammy
|
||||
name: Ubuntu 22.04 production install
|
||||
os: jammy
|
||||
|
||||
- docker_image: zulip/ci:buster
|
||||
name: Debian 10 production install with custom db name and user
|
||||
name: Buster production install
|
||||
is_buster: true
|
||||
os: buster
|
||||
extra_args: --test-custom-db
|
||||
|
||||
- docker_image: zulip/ci:bullseye
|
||||
name: Debian 11 production install
|
||||
name: Bullseye production install
|
||||
is_bullseye: true
|
||||
os: bullseye
|
||||
|
||||
name: ${{ matrix.name }}
|
||||
@@ -148,10 +158,13 @@ jobs:
|
||||
# cache action to work. It is owned by root currently.
|
||||
sudo chmod -R 0777 /__w/_temp/
|
||||
|
||||
# Create the zulip directory that the tools/ci/ scripts needs
|
||||
mkdir -p /home/github/zulip
|
||||
|
||||
# Since actions/download-artifact@v2 loses all the permissions
|
||||
# of the tarball uploaded by the upload artifact fix those.
|
||||
chmod +x /tmp/production-extract-tarball
|
||||
chmod +x /tmp/production-upgrade-pg
|
||||
chmod +x /tmp/production-pgroonga
|
||||
chmod +x /tmp/production-install
|
||||
chmod +x /tmp/production-verify
|
||||
chmod +x /tmp/send-failure-message
|
||||
@@ -169,29 +182,31 @@ jobs:
|
||||
key: v1-yarn-deps-${{ matrix.os }}-${{ hashFiles('/tmp/package.json') }}-${{ hashFiles('/tmp/yarn.lock') }}
|
||||
restore-keys: v1-yarn-deps-${{ matrix.os }}
|
||||
|
||||
- name: Do Bionic hack
|
||||
if: ${{ matrix.is_bionic }}
|
||||
run: |
|
||||
# Temporary hack till `sudo service redis-server start` gets fixes in Bionic. See
|
||||
# https://chat.zulip.org/#narrow/stream/3-backend/topic/Ubuntu.20bionic.20CircleCI
|
||||
sudo sed -i '/^bind/s/bind.*/bind 0.0.0.0/' /etc/redis/redis.conf
|
||||
|
||||
- name: Production extract tarball
|
||||
run: /tmp/production-extract-tarball
|
||||
|
||||
- name: Install production
|
||||
run: |
|
||||
sudo service rabbitmq-server restart
|
||||
sudo /tmp/production-install ${{ matrix.extra-args }}
|
||||
sudo /tmp/production-install
|
||||
|
||||
- name: Verify install
|
||||
run: sudo /tmp/production-verify ${{ matrix.extra-args }}
|
||||
|
||||
- name: Install pgroonga
|
||||
if: ${{ matrix.os == 'focal' }}
|
||||
run: sudo /tmp/production-pgroonga
|
||||
|
||||
- name: Verify install after installing pgroonga
|
||||
if: ${{ matrix.os == 'focal' }}
|
||||
run: sudo /tmp/production-verify ${{ matrix.extra-args }}
|
||||
run: sudo /tmp/production-verify
|
||||
|
||||
- name: Upgrade postgresql
|
||||
if: ${{ matrix.os == 'focal' }}
|
||||
if: ${{ matrix.is_bionic }}
|
||||
run: sudo /tmp/production-upgrade-pg
|
||||
|
||||
- name: Verify install after upgrading postgresql
|
||||
if: ${{ matrix.os == 'focal' }}
|
||||
run: sudo /tmp/production-verify ${{ matrix.extra-args }}
|
||||
if: ${{ matrix.is_bionic }}
|
||||
run: sudo /tmp/production-verify
|
||||
|
||||
- name: Report status
|
||||
if: failure()
|
||||
@@ -210,16 +225,13 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
# Docker images are built from 'tools/ci/Dockerfile'; the comments at
|
||||
# the top explain how to build and upload these images.
|
||||
# Base images are built using `tools/ci/Dockerfile.prod.template`.
|
||||
# The comments at the top explain how to build and upload these images.
|
||||
- docker_image: zulip/ci:buster-3.4
|
||||
name: 3.4 Version Upgrade
|
||||
is_focal: true
|
||||
os: buster
|
||||
|
||||
- docker_image: zulip/ci:bullseye-4.11
|
||||
name: 4.11 Version Upgrade
|
||||
os: bullseye
|
||||
|
||||
name: ${{ matrix.name }}
|
||||
container:
|
||||
image: ${{ matrix.docker_image }}
|
||||
@@ -247,12 +259,6 @@ jobs:
|
||||
chmod +x /tmp/production-verify
|
||||
chmod +x /tmp/send-failure-message
|
||||
|
||||
- name: Create cache directories
|
||||
run: |
|
||||
dirs=(/srv/zulip-{npm,venv,emoji}-cache)
|
||||
sudo mkdir -p "${dirs[@]}"
|
||||
sudo chown -R github "${dirs[@]}"
|
||||
|
||||
- name: Upgrade production
|
||||
run: sudo /tmp/production-upgrade
|
||||
|
||||
|
||||
95
.github/workflows/zulip-ci.yml
vendored
95
.github/workflows/zulip-ci.yml
vendored
@@ -15,34 +15,40 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include_frontend_tests: [false]
|
||||
include:
|
||||
# Base images are built using `tools/ci/Dockerfile.prod.template`.
|
||||
# The comments at the top explain how to build and upload these images.
|
||||
# Debian 10 ships with Python 3.7.3.
|
||||
- docker_image: zulip/ci:buster
|
||||
name: Debian 10 (Python 3.7, backend + frontend)
|
||||
os: buster
|
||||
# This docker image was created by a generated Dockerfile at:
|
||||
# tools/ci/images/bionic/Dockerfile
|
||||
# Bionic ships with Python 3.6.
|
||||
- docker_image: zulip/ci:bionic
|
||||
name: Ubuntu 18.04 Bionic (Python 3.6, backend + frontend)
|
||||
os: bionic
|
||||
is_bionic: true
|
||||
include_frontend_tests: true
|
||||
# Ubuntu 20.04 ships with Python 3.8.2.
|
||||
|
||||
# This docker image was created by a generated Dockerfile at:
|
||||
# tools/ci/images/focal/Dockerfile
|
||||
# Focal ships with Python 3.8.2.
|
||||
- docker_image: zulip/ci:focal
|
||||
name: Ubuntu 20.04 (Python 3.8, backend)
|
||||
name: Ubuntu 20.04 Focal (Python 3.8, backend)
|
||||
os: focal
|
||||
# Debian 11 ships with Python 3.9.2.
|
||||
is_focal: true
|
||||
include_frontend_tests: false
|
||||
|
||||
# This docker image was created by a generated Dockerfile at:
|
||||
# tools/ci/images/focal/Dockerfile
|
||||
# Bullseye ships with Python 3.9.2.
|
||||
- docker_image: zulip/ci:bullseye
|
||||
name: Debian 11 (Python 3.9, backend)
|
||||
name: Debian 11 Bullseye (Python 3.9, backend)
|
||||
os: bullseye
|
||||
# Ubuntu 22.04 ships with Python 3.10.4.
|
||||
- docker_image: zulip/ci:jammy
|
||||
name: Ubuntu 22.04 (Python 3.10, backend)
|
||||
os: jammy
|
||||
is_bullseye: true
|
||||
include_frontend_tests: false
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
name: ${{ matrix.name }}
|
||||
container: ${{ matrix.docker_image }}
|
||||
env:
|
||||
# GitHub Actions sets HOME to /github/home which causes
|
||||
# problem later in provision and frontend test that runs
|
||||
# problem later in provison and frontend test that runs
|
||||
# tools/setup/postgresql-init-dev-db because of the .pgpass
|
||||
# location. PostgreSQL (psql) expects .pgpass to be at
|
||||
# /home/github/.pgpass and setting home to `/home/github/`
|
||||
@@ -50,6 +56,22 @@ jobs:
|
||||
HOME: /home/github/
|
||||
|
||||
steps:
|
||||
- name: Add required permissions
|
||||
run: |
|
||||
# The checkout actions doesn't clone to ~/zulip or allow
|
||||
# us to use the path option to clone outside the current
|
||||
# /__w/zulip/zulip directory. Since this directory is owned
|
||||
# by root we need to change it's ownership to allow the
|
||||
# github user to clone the code here.
|
||||
# Note: /__w/ is a docker volume mounted to $GITHUB_WORKSPACE
|
||||
# which is /home/runner/work/.
|
||||
sudo chown -R github .
|
||||
|
||||
# This is the GitHub Actions specific cache directory the
|
||||
# the current github user must be able to access for the
|
||||
# cache action to work. It is owned by root currently.
|
||||
sudo chmod -R 0777 /__w/_temp/
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Create cache directories
|
||||
@@ -79,6 +101,13 @@ jobs:
|
||||
key: v1-emoji-${{ matrix.os }}-${{ hashFiles('tools/setup/emoji/emoji_map.json') }}-${{ hashFiles('tools/setup/emoji/build_emoji') }}-${{ hashFiles('tools/setup/emoji/emoji_setup_utils.py') }}-${{ hashFiles('tools/setup/emoji/emoji_names.py') }}-${{ hashFiles('package.json') }}
|
||||
restore-keys: v1-emoji-${{ matrix.os }}
|
||||
|
||||
- name: Do Bionic hack
|
||||
if: ${{ matrix.is_bionic }}
|
||||
run: |
|
||||
# Temporary hack till `sudo service redis-server start` gets fixes in Bionic. See
|
||||
# https://chat.zulip.org/#narrow/stream/3-backend/topic/Ubuntu.20bionic.20CircleCI
|
||||
sudo sed -i '/^bind/s/bind.*/bind 0.0.0.0/' /etc/redis/redis.conf
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
# This is the main setup job for the test suite
|
||||
@@ -86,18 +115,13 @@ jobs:
|
||||
|
||||
# Cleaning caches is mostly unnecessary in GitHub Actions, because
|
||||
# most builds don't get to write to the cache.
|
||||
# scripts/lib/clean_unused_caches.py --verbose --threshold 0
|
||||
# scripts/lib/clean-unused-caches --verbose --threshold 0
|
||||
|
||||
- name: Run tools test
|
||||
run: |
|
||||
source tools/ci/activate-venv
|
||||
./tools/test-tools
|
||||
|
||||
- name: Run Codespell lint
|
||||
run: |
|
||||
source tools/ci/activate-venv
|
||||
./tools/run-codespell
|
||||
|
||||
- name: Run backend lint
|
||||
run: |
|
||||
source tools/ci/activate-venv
|
||||
@@ -128,7 +152,7 @@ jobs:
|
||||
source tools/ci/activate-venv
|
||||
|
||||
# Currently our compiled requirements files will differ for different python versions
|
||||
# so we will run test-locked-requirements only for Debian 10.
|
||||
# so we will run test-locked-requirements only for Bionic.
|
||||
# ./tools/test-locked-requirements
|
||||
# ./tools/test-run-dev # https://github.com/zulip/zulip/pull/14233
|
||||
#
|
||||
@@ -140,13 +164,6 @@ jobs:
|
||||
./tools/setup/optimize-svg --check
|
||||
./tools/setup/generate_integration_bots_avatars.py --check-missing
|
||||
|
||||
# Ban check-database-compatibility.py from transitively
|
||||
# relying on static/generated, because it might not be
|
||||
# up-to-date at that point in upgrade-zulip-stage-2.
|
||||
chmod 000 static/generated
|
||||
./scripts/lib/check-database-compatibility.py
|
||||
chmod 755 static/generated
|
||||
|
||||
- name: Run documentation and api tests
|
||||
run: |
|
||||
source tools/ci/activate-venv
|
||||
@@ -160,7 +177,7 @@ jobs:
|
||||
run: |
|
||||
source tools/ci/activate-venv
|
||||
# Run the node tests first, since they're fast and deterministic
|
||||
./tools/test-js-with-node --coverage --parallel=1
|
||||
./tools/test-js-with-node --coverage
|
||||
|
||||
- name: Check schemas
|
||||
if: ${{ matrix.include_frontend_tests }}
|
||||
@@ -195,7 +212,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Test locked requirements
|
||||
if: ${{ matrix.os == 'buster' }}
|
||||
if: ${{ matrix.is_bionic }}
|
||||
run: |
|
||||
. /srv/zulip-py3-venv/bin/activate && \
|
||||
./tools/test-locked-requirements
|
||||
@@ -203,11 +220,15 @@ jobs:
|
||||
- name: Upload coverage reports
|
||||
|
||||
# Only upload coverage when both frontend and backend
|
||||
# tests are run.
|
||||
# tests are ran.
|
||||
if: ${{ matrix.include_frontend_tests }}
|
||||
uses: codecov/codecov-action@v2
|
||||
with:
|
||||
files: var/coverage.xml,var/node-coverage/lcov.info
|
||||
run: |
|
||||
# Codcov requires `.coverage` file to be stored in the
|
||||
# current working directory.
|
||||
mv ./var/.coverage ./.coverage
|
||||
. /srv/zulip-py3-venv/bin/activate || true
|
||||
|
||||
pip install codecov && codecov || echo "Error in uploading coverage reports to codecov.io."
|
||||
|
||||
- name: Store Puppeteer artifacts
|
||||
# Upload these on failure, as well
|
||||
@@ -219,7 +240,7 @@ jobs:
|
||||
retention-days: 60
|
||||
|
||||
- name: Check development database build
|
||||
if: ${{ matrix.os == 'focal' || matrix.os == 'bullseye' || matrix.os == 'jammy' }}
|
||||
if: ${{ matrix.is_focal || matrix.is_bullseye }}
|
||||
run: ./tools/ci/setup-backend
|
||||
|
||||
- name: Report status
|
||||
|
||||
10
.gitignore
vendored
10
.gitignore
vendored
@@ -34,6 +34,9 @@ package-lock.json
|
||||
|
||||
/.dmypy.json
|
||||
|
||||
# Dockerfiles generated for continuous integration
|
||||
/tools/ci/images
|
||||
|
||||
# Generated i18n data
|
||||
/locale/en
|
||||
/locale/language_options.json
|
||||
@@ -70,12 +73,9 @@ zulip.kdev4
|
||||
*.kate-swp
|
||||
*.sublime-project
|
||||
*.sublime-workspace
|
||||
.vscode/
|
||||
*.DS_Store
|
||||
# VS Code. Avoid checking in .vscode in general, while still specifying
|
||||
# recommended extensions for working with this repository.
|
||||
/.vscode/**/*
|
||||
!/.vscode/extensions.json
|
||||
# .cache/ is generated by VS Code test runner
|
||||
# .cache/ is generated by Visual Studio Code's test runner
|
||||
.cache/
|
||||
.eslintcache
|
||||
|
||||
|
||||
33
.mailmap
33
.mailmap
@@ -1,65 +1,36 @@
|
||||
# This file teaches `git log` and friends the canonical names
|
||||
# and email addresses to use for our contributors.
|
||||
#
|
||||
# For details on the format, see:
|
||||
# https://git.github.io/htmldocs/gitmailmap.html
|
||||
#
|
||||
# Handy commands for examining or adding to this file:
|
||||
#
|
||||
# # shows all names/emails after mapping, sorted:
|
||||
# $ git shortlog -es | sort -k2
|
||||
#
|
||||
# # shows raw names/emails, filtered by mapped name:
|
||||
# $ git log --format='%an %ae' --author=$NAME | uniq -c
|
||||
|
||||
Alex Vandiver <alexmv@zulip.com> <alex@chmrr.net>
|
||||
Alex Vandiver <alexmv@zulip.com> <github@chmrr.net>
|
||||
Allen Rabinovich <allenrabinovich@yahoo.com> <allenr@humbughq.com>
|
||||
Allen Rabinovich <allenrabinovich@yahoo.com> <allenr@zulip.com>
|
||||
Alya Abbott <alya@zulip.com> <2090066+alya@users.noreply.github.com>
|
||||
Aman Agrawal <amanagr@zulip.com> <f2016561@pilani.bits-pilani.ac.in>
|
||||
Anders Kaseorg <anders@zulip.com> <anders@zulipchat.com>
|
||||
Anders Kaseorg <anders@zulip.com> <andersk@mit.edu>
|
||||
Austin Riba <austin@zulip.com> <austin@m51.io>
|
||||
BIKI DAS <bikid475@gmail.com>
|
||||
Brock Whittaker <brock@zulipchat.com> <bjwhitta@asu.edu>
|
||||
Brock Whittaker <brock@zulipchat.com> <brockwhittaker@Brocks-MacBook.local>
|
||||
Brock Whittaker <brock@zulipchat.com> <brock@zulipchat.org>
|
||||
Chris Bobbe <cbobbe@zulip.com> <cbobbe@zulipchat.com>
|
||||
Chris Bobbe <cbobbe@zulip.com> <csbobbe@gmail.com>
|
||||
Eeshan Garg <eeshan@zulip.com> <jerryguitarist@gmail.com>
|
||||
Greg Price <greg@zulip.com> <gnprice@gmail.com>
|
||||
Greg Price <greg@zulip.com> <greg@zulipchat.com>
|
||||
Greg Price <greg@zulip.com> <price@mit.edu>
|
||||
Jai soni <jai_s@me.iitr.ac.in>
|
||||
Jai soni <jai_s@me.iitr.ac.in> <76561593+jai2201@users.noreply.github.com>
|
||||
Jeff Arnold <jbarnold@gmail.com> <jbarnold@humbughq.com>
|
||||
Jeff Arnold <jbarnold@gmail.com> <jbarnold@zulip.com>
|
||||
Jessica McKellar <jesstess@mit.edu> <jesstess@humbughq.com>
|
||||
Jessica McKellar <jesstess@mit.edu> <jesstess@zulip.com>
|
||||
Kevin Mehall <km@kevinmehall.net> <kevin@humbughq.com>
|
||||
Kevin Mehall <km@kevinmehall.net> <kevin@zulip.com>
|
||||
Kevin Scott <kevin.scott.98@gmail.com>
|
||||
Lauryn Menard <lauryn@zulip.com> <lauryn.menard@gmail.com>
|
||||
Mateusz Mandera <mateusz.mandera@zulip.com> <mateusz.mandera@protonmail.com>
|
||||
m-e-l-u-h-a-n <purushottam.tiwari.cd.cse19@itbhu.ac.in>
|
||||
Palash Raghuwanshi <singhpalash0@gmail.com>
|
||||
Parth <mittalparth22@gmail.com>
|
||||
Ray Kraesig <rkraesig@zulip.com> <rkraesig@zulipchat.com>
|
||||
Rishi Gupta <rishig@zulipchat.com> <rishig+git@mit.edu>
|
||||
Rishi Gupta <rishig@zulipchat.com> <rishig@kandralabs.com>
|
||||
Rishi Gupta <rishig@zulipchat.com> <rishig@users.noreply.github.com>
|
||||
Reid Barton <rwbarton@gmail.com> <rwbarton@humbughq.com>
|
||||
Sayam Samal <samal.sayam@gmail.com>
|
||||
Scott Feeney <scott@oceanbase.org> <scott@humbughq.com>
|
||||
Scott Feeney <scott@oceanbase.org> <scott@zulip.com>
|
||||
Shlok Patel <shlokcpatel2001@gmail.com>
|
||||
Steve Howell <showell@zulip.com> <showell30@yahoo.com>
|
||||
Steve Howell <showell@zulip.com> <showell@yahoo.com>
|
||||
Steve Howell <showell@zulip.com> <showell@zulipchat.com>
|
||||
Steve Howell <showell@zulip.com> <steve@humbughq.com>
|
||||
Steve Howell <showell@zulip.com> <steve@zulip.com>
|
||||
strifel <info@strifel.de>
|
||||
Tim Abbott <tabbott@zulip.com> <tabbott@dropbox.com>
|
||||
Tim Abbott <tabbott@zulip.com> <tabbott@humbughq.com>
|
||||
Tim Abbott <tabbott@zulip.com> <tabbott@mit.edu>
|
||||
@@ -67,7 +38,3 @@ Tim Abbott <tabbott@zulip.com> <tabbott@zulipchat.com>
|
||||
Vishnu KS <vishnu@zulip.com> <hackerkid@vishnuks.com>
|
||||
Vishnu KS <vishnu@zulip.com> <yo@vishnuks.com>
|
||||
Alya Abbott <alya@zulip.com> <alyaabbott@elance-odesk.com>
|
||||
Sahil Batra <sahil@zulip.com> <sahilbatra839@gmail.com>
|
||||
Yash RE <33805964+YashRE42@users.noreply.github.com> <YashRE42@github.com>
|
||||
Yash RE <33805964+YashRE42@users.noreply.github.com>
|
||||
Yogesh Sirsat <yogeshsirsat56@gmail.com>
|
||||
|
||||
23
.vscode/extensions.json
vendored
23
.vscode/extensions.json
vendored
@@ -1,23 +0,0 @@
|
||||
{
|
||||
// Recommended VS Code extensions for zulip/zulip.
|
||||
//
|
||||
// VS Code prompts a user to install the recommended extensions
|
||||
// when a workspace is opened for the first time. The user can
|
||||
// also review the list with the 'Extensions: Show Recommended
|
||||
// Extensions' command. See
|
||||
// https://code.visualstudio.com/docs/editor/extension-marketplace#_workspace-recommended-extensions
|
||||
// for more information.
|
||||
//
|
||||
// Extension identifier format: ${publisher}.${name}.
|
||||
// Example: vscode.csharp
|
||||
|
||||
"recommendations": [
|
||||
"42crunch.vscode-openapi",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode",
|
||||
"ms-vscode-remote.vscode-remote-extensionpack"
|
||||
],
|
||||
|
||||
// Extensions recommended by VS Code which are not recommended for users of zulip/zulip.
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
466
CONTRIBUTING.md
466
CONTRIBUTING.md
@@ -5,14 +5,21 @@ Welcome to the Zulip community!
|
||||
## Community
|
||||
|
||||
The
|
||||
[Zulip community server](https://zulip.com/development-community/)
|
||||
[Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html)
|
||||
is the primary communication forum for the Zulip community. It is a good
|
||||
place to start whether you have a question, are a new contributor, are a new
|
||||
user, or anything else. Please review our
|
||||
[community norms](https://zulip.com/development-community/#community-norms)
|
||||
user, or anything else. Make sure to read the
|
||||
[community norms](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html#community-norms)
|
||||
before posting. The Zulip community is also governed by a
|
||||
[code of conduct](https://zulip.readthedocs.io/en/latest/code-of-conduct.html).
|
||||
|
||||
You can subscribe to
|
||||
[zulip-devel-announce@googlegroups.com](https://groups.google.com/g/zulip-devel-announce)
|
||||
or our [Twitter](https://twitter.com/zulip) account for a very low
|
||||
traffic (<1 email/month) way to hear about things like mentorship
|
||||
opportunities with Google Summer of Code, in-person sprints at
|
||||
conferences, and other opportunities to contribute.
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
To make a code or documentation contribution, read our
|
||||
@@ -34,18 +41,18 @@ needs doing:
|
||||
and manually testing pull requests.
|
||||
|
||||
**Non-code contributions**: Some of the most valuable ways to contribute
|
||||
don't require touching the codebase at all. For example, you can:
|
||||
don't require touching the codebase at all. We list a few of them below:
|
||||
|
||||
- [Report issues](#reporting-issues), including both feature requests and
|
||||
- [Reporting issues](#reporting-issues), including both feature requests and
|
||||
bug reports.
|
||||
- [Give feedback](#user-feedback) if you are evaluating or using Zulip.
|
||||
- [Giving feedback](#user-feedback) if you are evaluating or using Zulip.
|
||||
- [Sponsor Zulip](https://github.com/sponsors/zulip) through the GitHub sponsors program.
|
||||
- [Translate](https://zulip.readthedocs.io/en/latest/translating/translating.html)
|
||||
Zulip into your language.
|
||||
- [Stay connected](#stay-connected) with Zulip, and [help others
|
||||
find us](#help-others-find-zulip).
|
||||
- [Translating](https://zulip.readthedocs.io/en/latest/translating/translating.html)
|
||||
Zulip.
|
||||
- [Outreach](#zulip-outreach): Star us on GitHub, upvote us
|
||||
on product comparison sites, or write for [the Zulip blog](https://blog.zulip.org/).
|
||||
|
||||
## Your first codebase contribution
|
||||
## Your first (codebase) contribution
|
||||
|
||||
This section has a step by step guide to starting as a Zulip codebase
|
||||
contributor. It's long, but don't worry about doing all the steps perfectly;
|
||||
@@ -53,7 +60,7 @@ no one gets it right the first time, and there are a lot of people available
|
||||
to help.
|
||||
|
||||
- First, make an account on the
|
||||
[Zulip community server](https://zulip.com/development-community/),
|
||||
[Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html),
|
||||
paying special attention to the community norms. If you'd like, introduce
|
||||
yourself in
|
||||
[#new members](https://chat.zulip.org/#narrow/stream/95-new-members), using
|
||||
@@ -63,298 +70,131 @@ to help.
|
||||
- Read [What makes a great Zulip contributor](#what-makes-a-great-zulip-contributor).
|
||||
- [Install the development environment](https://zulip.readthedocs.io/en/latest/development/overview.html),
|
||||
getting help in
|
||||
[#provision help](https://chat.zulip.org/#narrow/stream/21-provision-help)
|
||||
[#development help](https://chat.zulip.org/#narrow/stream/49-development-help)
|
||||
if you run into any troubles.
|
||||
- Familiarize yourself with [using the development environment](https://zulip.readthedocs.io/en/latest/development/using.html).
|
||||
- Go through the [new application feature
|
||||
tutorial](https://zulip.readthedocs.io/en/latest/tutorials/new-feature-tutorial.html) to get familiar with
|
||||
how the Zulip codebase is organized and how to find code in it.
|
||||
- Read the [Zulip guide to
|
||||
Git](https://zulip.readthedocs.io/en/latest/git/index.html) if you
|
||||
are unfamiliar with Git or Zulip's rebase-based Git workflow,
|
||||
getting help in [#git
|
||||
help](https://chat.zulip.org/#narrow/stream/44-git-help) if you run
|
||||
into any troubles. Even Git experts should read the [Zulip-specific
|
||||
Git tools
|
||||
page](https://zulip.readthedocs.io/en/latest/git/zulip-tools.html).
|
||||
- Read the
|
||||
[Zulip guide to Git](https://zulip.readthedocs.io/en/latest/git/index.html)
|
||||
and do the Git tutorial (coming soon) if you are unfamiliar with
|
||||
Git, getting help in
|
||||
[#git help](https://chat.zulip.org/#narrow/stream/44-git-help) if
|
||||
you run into any troubles. Be sure to check out the
|
||||
[extremely useful Zulip-specific tools page](https://zulip.readthedocs.io/en/latest/git/zulip-tools.html).
|
||||
|
||||
### Where to look for an issue
|
||||
### Picking an issue
|
||||
|
||||
Now you're ready to pick your first issue! Zulip has several repositories you
|
||||
can check out, depending on your interests. There are hundreds of open issues in
|
||||
the [main Zulip server and web app
|
||||
repository](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
|
||||
alone.
|
||||
Now, you're ready to pick your first issue! There are hundreds of open issues
|
||||
in the main codebase alone. This section will help you find an issue to work
|
||||
on.
|
||||
|
||||
You can look through issues tagged with the "help wanted" label, which is used
|
||||
to indicate the issues that are ready for contributions. Some repositories also
|
||||
use the "good first issue" label to tag issues that are especially approachable
|
||||
for new contributors.
|
||||
|
||||
- [Server and web app](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
|
||||
- [Mobile apps](https://github.com/zulip/zulip-mobile/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
|
||||
- [Desktop app](https://github.com/zulip/zulip-desktop/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
|
||||
- [Terminal app](https://github.com/zulip/zulip-terminal/issues?q=is%3Aopen+is%3Aissue+label%3A"help+wanted")
|
||||
- [Python API bindings and bots](https://github.com/zulip/python-zulip-api/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
|
||||
|
||||
### Picking an issue to work on
|
||||
|
||||
There's a lot to learn while making your first pull request, so start small!
|
||||
Many first contributions have fewer than 10 lines of changes (not counting
|
||||
changes to tests).
|
||||
|
||||
We recommend the following process for finding an issue to work on:
|
||||
|
||||
1. Read the description of an issue tagged with the "help wanted" label and make
|
||||
sure you understand it.
|
||||
2. If it seems promising, poke around the product
|
||||
(on [chat.zulip.org](https://chat.zulip.org) or in the development
|
||||
environment) until you know how the piece being
|
||||
described fits into the bigger picture. If after some exploration the
|
||||
description seems confusing or ambiguous, post a question on the GitHub
|
||||
issue, as others may benefit from the clarification as well.
|
||||
3. When you find an issue you like, try to get started working on it. See if you
|
||||
can find the part of the code you'll need to modify (`git grep` is your
|
||||
friend!) and get some idea of how you'll approach the problem.
|
||||
4. If you feel lost, that's OK! Go through these steps again with another issue.
|
||||
There's plenty to work on, and the exploration you do will help you learn
|
||||
more about the project.
|
||||
|
||||
Note that you are _not_ claiming an issue while you are iterating through steps
|
||||
1-4. _Before you claim an issue_, you should be confident that you will be able to
|
||||
tackle it effectively.
|
||||
|
||||
If the lists of issues are overwhelming, you can post in
|
||||
[#new members](https://chat.zulip.org/#narrow/stream/95-new-members) with a
|
||||
bit about your background and interests, and we'll help you out. The most
|
||||
important thing to say is whether you're looking for a backend (Python),
|
||||
frontend (JavaScript and TypeScript), mobile (React Native), desktop (Electron),
|
||||
documentation (English) or visual design (JavaScript/TypeScript + CSS) issue, and a
|
||||
bit about your programming experience and available time.
|
||||
|
||||
Additional tips for the [main server and web app
|
||||
repository](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22):
|
||||
|
||||
- We especially recommend browsing recently opened issues, as there are more
|
||||
likely to be easy ones for you to find.
|
||||
- All issues are partitioned into areas like
|
||||
- If you're interested in
|
||||
[mobile](https://github.com/zulip/zulip-mobile/issues?q=is%3Aopen+is%3Aissue),
|
||||
[desktop](https://github.com/zulip/zulip-desktop/issues?q=is%3Aopen+is%3Aissue),
|
||||
or
|
||||
[bots](https://github.com/zulip/python-zulip-api/issues?q=is%3Aopen+is%3Aissue)
|
||||
development, check the respective links for open issues, or post in
|
||||
[#mobile](https://chat.zulip.org/#narrow/stream/48-mobile),
|
||||
[#desktop](https://chat.zulip.org/#narrow/stream/16-desktop), or
|
||||
[#integration](https://chat.zulip.org/#narrow/stream/127-integrations).
|
||||
- For the main server and web repository, we recommend browsing
|
||||
recently opened issues to look for issues you are confident you can
|
||||
fix correctly in a way that clearly communicates why your changes
|
||||
are the correct fix. Our GitHub workflow bot, zulipbot, limits
|
||||
users who have 0 commits merged to claiming a single issue labeled
|
||||
with "good first issue" or "help wanted".
|
||||
- We also partition all of our issues in the main repo into areas like
|
||||
admin, compose, emoji, hotkeys, i18n, onboarding, search, etc. Look
|
||||
through our [list of labels](https://github.com/zulip/zulip/labels), and
|
||||
click on some of the `area:` labels to see all the issues related to your
|
||||
areas of interest.
|
||||
- Avoid issues with the "difficult" label unless you
|
||||
understand why it is difficult and are highly confident you can resolve the
|
||||
issue correctly and completely.
|
||||
- If the lists of issues are overwhelming, post in
|
||||
[#new members](https://chat.zulip.org/#narrow/stream/95-new-members) with a
|
||||
bit about your background and interests, and we'll help you out. The most
|
||||
important thing to say is whether you're looking for a backend (Python),
|
||||
frontend (JavaScript and TypeScript), mobile (React Native), desktop (Electron),
|
||||
documentation (English) or visual design (JavaScript/TypeScript + CSS) issue, and a
|
||||
bit about your programming experience and available time.
|
||||
|
||||
### Claiming an issue
|
||||
We also welcome suggestions of features that you feel would be valuable or
|
||||
changes that you feel would make Zulip a better open source project. If you
|
||||
have a new feature you'd like to add, we recommend you start by posting in
|
||||
[#new members](https://chat.zulip.org/#narrow/stream/95-new-members) with the
|
||||
feature idea and the problem that you're hoping to solve.
|
||||
|
||||
#### In the main server and web app repository
|
||||
Other notes:
|
||||
|
||||
After making sure the issue is tagged with a [help
|
||||
wanted](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
|
||||
label, post a comment with `@zulipbot claim` to the issue thread.
|
||||
[Zulipbot](https://github.com/zulip/zulipbot) is a GitHub workflow bot; it will
|
||||
assign you to the issue and label the issue as "in progress".
|
||||
|
||||
New contributors can only claim one issue until their first pull request is
|
||||
merged. This is to encourage folks to finish ongoing work before starting
|
||||
something new. If you would like to pick up a new issue while waiting for review
|
||||
on an almost-ready pull request, you can post a comment to this effect on the
|
||||
issue you're interested in.
|
||||
|
||||
#### In other Zulip repositories
|
||||
|
||||
There is no bot for other repositories, so you can simply post a comment saying
|
||||
that you'd like to work on the issue.
|
||||
|
||||
Please follow the same guidelines as described above: find an issue labeled
|
||||
"help wanted", and only pick up one issue at a time to start with.
|
||||
- For a first pull request, it's better to aim for a smaller contribution
|
||||
than a bigger one. Many first contributions have fewer than 10 lines of
|
||||
changes (not counting changes to tests).
|
||||
- The full list of issues explicitly looking for a contributor can be
|
||||
found with the
|
||||
[good first issue](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
|
||||
and
|
||||
[help wanted](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
|
||||
labels. Avoid issues with the "difficult" label unless you
|
||||
understand why it is difficult and are confident you can resolve the
|
||||
issue correctly and completely. Issues without one of these labels
|
||||
are fair game if Tim has written a clear technical design proposal
|
||||
in the issue, or it is a bug that you can reproduce and you are
|
||||
confident you can fix the issue correctly.
|
||||
- For most new contributors, there's a lot to learn while making your first
|
||||
pull request. It's OK if it takes you a while; that's normal! You'll be
|
||||
able to work a lot faster as you build experience.
|
||||
|
||||
### Working on an issue
|
||||
|
||||
You're encouraged to ask questions on how to best implement or debug your
|
||||
changes -- the Zulip maintainers are excited to answer questions to help you
|
||||
stay unblocked and working efficiently. You can ask questions in the [Zulip
|
||||
development community](https://zulip.com/development-community/), or on the
|
||||
GitHub issue or pull request.
|
||||
To work on an issue, claim it by adding a comment with `@zulipbot claim` to
|
||||
the issue thread. [Zulipbot](https://github.com/zulip/zulipbot) is a GitHub
|
||||
workflow bot; it will assign you to the issue and label the issue as "in
|
||||
progress". Some additional notes:
|
||||
|
||||
To get early feedback on any UI changes, we encourage you to post screenshots of
|
||||
your work in the [#design
|
||||
stream](https://chat.zulip.org/#narrow/stream/101-design) in the [Zulip
|
||||
development community](https://zulip.com/development-community/)
|
||||
- You can only claim issues with the
|
||||
[good first issue](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22)
|
||||
or
|
||||
[help wanted](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22)
|
||||
labels. Zulipbot will give you an error if you try to claim an issue
|
||||
without one of those labels.
|
||||
- You're encouraged to ask questions on how to best implement or debug your
|
||||
changes -- the Zulip maintainers are excited to answer questions to help
|
||||
you stay unblocked and working efficiently. You can ask questions on
|
||||
chat.zulip.org, or on the GitHub issue or pull request.
|
||||
- We encourage early pull requests for work in progress. Prefix the title of
|
||||
work in progress pull requests with `[WIP]`, and remove the prefix when
|
||||
you think it might be mergeable and want it to be reviewed.
|
||||
- After updating a PR, add a comment to the GitHub thread mentioning that it
|
||||
is ready for another review. GitHub only notifies maintainers of the
|
||||
changes when you post a comment, so if you don't, your PR will likely be
|
||||
neglected by accident!
|
||||
|
||||
For more advice, see [What makes a great Zulip
|
||||
contributor?](https://zulip.readthedocs.io/en/latest/overview/contributing.html#what-makes-a-great-zulip-contributor)
|
||||
below.
|
||||
### And beyond
|
||||
|
||||
### Submitting a pull request
|
||||
|
||||
When you believe your code is ready, follow the [guide on how to review
|
||||
code](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code)
|
||||
to review your own work. You can often find things you missed by taking a step
|
||||
back to look over your work before asking others to do so. Catching mistakes
|
||||
yourself will help your PRs be merged faster, and folks will appreciate the
|
||||
quality and professionalism of your work.
|
||||
|
||||
Then, submit your changes. Carefully reading our [Git guide][git-guide], and in
|
||||
particular the section on [making a pull request][git-guide-make-pr],
|
||||
will help avoid many common mistakes.
|
||||
|
||||
Once you are satisfied with the quality of your PR, follow the
|
||||
[guidelines on asking for a code
|
||||
review](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#asking-for-a-code-review)
|
||||
to request a review. If you are not sure what's best, simply post a
|
||||
comment on the main GitHub thread for your PR clearly indicating that
|
||||
it is ready for review, and the project maintainers will take a look
|
||||
and follow up with next steps.
|
||||
|
||||
It's OK if your first issue takes you a while; that's normal! You'll be
|
||||
able to work a lot faster as you build experience.
|
||||
|
||||
If it helps your workflow, you can submit a work-in-progress pull
|
||||
request before your work is ready for review. Simply prefix the title
|
||||
of work in progress pull requests with `[WIP]`, and then remove the
|
||||
prefix when you think it's time for someone else to review your work.
|
||||
|
||||
[git-guide]: https://zulip.readthedocs.io/en/latest/git/
|
||||
[git-guide-make-pr]: https://zulip.readthedocs.io/en/latest/git/pull-requests.html
|
||||
|
||||
### Stages of a pull request
|
||||
|
||||
Your pull request will likely go through several stages of review.
|
||||
|
||||
1. If your PR makes user-facing changes, the UI and user experience may be
|
||||
reviewed early on, without reference to the code. You will get feedback on
|
||||
any user-facing bugs in the implementation. To minimize the number of review
|
||||
round-trips, make sure to [thoroughly
|
||||
test](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#manual-testing)
|
||||
your own PR prior to asking for review.
|
||||
2. There may be choices made in the implementation that the reviewer
|
||||
will ask you to revisit. This process will go more smoothly if you
|
||||
specifically call attention to the decisions you made while
|
||||
drafting the PR and any points about which you are uncertain. The
|
||||
PR description and comments on your own PR are good ways to do this.
|
||||
3. Oftentimes, seeing an initial implementation will make it clear that the
|
||||
product design for a feature needs to be revised, or that additional changes
|
||||
are needed. The reviewer may therefore ask you to amend or change the
|
||||
implementation. Some changes may be blockers for getting the PR merged, while
|
||||
others may be improvements that can happen afterwards. Feel free to ask if
|
||||
it's unclear which type of feedback you're getting. (Follow-ups can be a
|
||||
great next issue to work on!)
|
||||
4. In addition to any UI/user experience review, all PRs will go through one or
|
||||
more rounds of code review. Your code may initially be [reviewed by other
|
||||
contributors](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html).
|
||||
This helps us make good use of project maintainers' time, and helps you make
|
||||
progress on the PR by getting more frequent feedback. A project maintainer
|
||||
may leave a comment asking someone with expertise in the area you're working
|
||||
on to review your work.
|
||||
5. Final code review and integration for server and webapp PRs is generally done
|
||||
by `@timabbott`.
|
||||
|
||||
#### How to help move the review process forward
|
||||
|
||||
The key to keeping your review moving through the review process is to:
|
||||
|
||||
- Address _all_ the feedback to the best of your ability.
|
||||
- Make it clear when the requested changes have been made
|
||||
and you believe it's time for another look.
|
||||
- Make it as easy as possible to review the changes you made.
|
||||
|
||||
In order to do this, when you believe you have addressed the previous round of
|
||||
feedback on your PR as best you can, post an comment asking reviewers to take
|
||||
another look. Your comment should make it easy to understand what has been done
|
||||
and what remains by:
|
||||
|
||||
- Summarizing the changes made since the last review you received.
|
||||
- Highlighting remaining questions or decisions, with links to any relevant
|
||||
chat.zulip.org threads.
|
||||
- Providing updated screenshots and information on manual testing if
|
||||
appropriate.
|
||||
|
||||
The easier it is to review your work, the more likely you are to receive quick
|
||||
feedback.
|
||||
|
||||
### Beyond the first issue
|
||||
|
||||
To find a second issue to work on, we recommend looking through issues with the same
|
||||
A great place to look for a second issue is to look for issues with the same
|
||||
`area:` label as the last issue you resolved. You'll be able to reuse the
|
||||
work you did learning how that part of the codebase works. Also, the path to
|
||||
becoming a core developer often involves taking ownership of one of these area
|
||||
labels.
|
||||
|
||||
### Common questions
|
||||
|
||||
- **What if somebody is already working on the issue I want do claim?** There
|
||||
are lots of issue to work on! If somebody else is actively working on the
|
||||
issue, you can find a different one, or help with
|
||||
reviewing their work.
|
||||
- **What if somebody else claims an issue while I'm figuring out whether or not to
|
||||
work on it?** No worries! You can contribute by providing feedback on
|
||||
their pull request. If you've made good progress in understanding part of the
|
||||
codebase, you can also find another "help wanted" issue in the same area to
|
||||
work on.
|
||||
- **What if there is already a pull request for the issue I want to work on?**
|
||||
Start by reviewing the existing work. If you agree with the approach, you can
|
||||
use the existing pull request (PR) as a starting point for your contribution. If
|
||||
you think a different approach is needed, you can post a new PR, with a comment that clearly
|
||||
explains _why_ you decided to start from scratch.
|
||||
- **Can I come up with my own feature idea and work on it?** We welcome
|
||||
suggestions of features or other improvements that you feel would be valuable. If you
|
||||
have a new feature you'd like to add, you can start a conversation [in our
|
||||
development community](https://zulip.com/development-community/#where-do-i-send-my-message)
|
||||
explaining the feature idea and the problem that you're hoping to solve.
|
||||
- **I think my PR is done, but it hasn't been merged yet. What's going on?**
|
||||
1. **Double-check that you have addressed all the feedback**, including any comments
|
||||
on [Git commit
|
||||
discipline](https://zulip.readthedocs.io/en/latest/contributing/version-control.html#commit-discipline).
|
||||
2. If all the feedback has been addressed, did you [leave a
|
||||
comment](https://zulip.readthedocs.io/en/latest/overview/contributing.html#how-to-help-move-the-review-process-forward)
|
||||
explaining that you have done so and **requesting another review**? If not,
|
||||
it may not be clear to project maintainers or reviewers that your PR is
|
||||
ready for another look.
|
||||
3. There may be a pause between initial rounds of review for your PR and final
|
||||
review by project maintainers. This is normal, and we encourage you to **work
|
||||
on other issues** while you wait.
|
||||
4. If you think the PR is ready and haven't seen any updates for a couple
|
||||
of weeks, it can be helpful to **leave another comment**. Summarize the
|
||||
overall state of the review process and your work, and indicate that you
|
||||
are waiting for a review.
|
||||
5. Finally, **Zulip project maintainers are people too**! They may be busy
|
||||
with other work, and sometimes they might even take a vacation. ;) It can
|
||||
occasionally take a few weeks for a PR in the final stages of the review
|
||||
process to be merged.
|
||||
|
||||
## What makes a great Zulip contributor?
|
||||
|
||||
Zulip has a lot of experience working with new contributors. In our
|
||||
experience, these are the best predictors of success:
|
||||
|
||||
- Posting good questions. It's very hard to answer a general question like, "How
|
||||
do I do this issue?" When asking for help, explain
|
||||
your current understanding, including what you've done or tried so far and where
|
||||
you got stuck. Post tracebacks or other error messages if appropriate. For
|
||||
more information, check out the ["Getting help" section of our community
|
||||
guidelines](https://zulip.com/development-community/#getting-help) and
|
||||
[this essay][good-questions-blog] for some good advice.
|
||||
- Posting good questions. This generally means explaining your current
|
||||
understanding, saying what you've done or tried so far, and including
|
||||
tracebacks or other error messages if appropriate.
|
||||
- Learning and practicing
|
||||
[Git commit discipline](https://zulip.readthedocs.io/en/latest/contributing/version-control.html#commit-discipline).
|
||||
- Submitting carefully tested code. See our [detailed guide on how to review
|
||||
code](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#how-to-review-code)
|
||||
(yours or someone else's).
|
||||
- Submitting carefully tested code. This generally means checking your work
|
||||
through a combination of automated tests and manually clicking around the
|
||||
UI trying to find bugs in your work. See
|
||||
[things to look for](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html#things-to-look-for)
|
||||
for additional ideas.
|
||||
- Posting
|
||||
[screenshots or GIFs](https://zulip.readthedocs.io/en/latest/tutorials/screenshot-and-gif-software.html)
|
||||
for frontend changes.
|
||||
- Clearly describing what you have implemented and why. For example, if your
|
||||
implementation differs from the issue description in some way or is a partial
|
||||
step towards the requirements described in the issue, be sure to call
|
||||
out those differences.
|
||||
- Being responsive to feedback on pull requests. This means incorporating or
|
||||
responding to all suggested changes, and leaving a note if you won't be
|
||||
able to address things within a few days.
|
||||
- Being helpful and friendly on the [Zulip community
|
||||
server](https://zulip.com/development-community/).
|
||||
|
||||
[good-questions-blog]: https://jvns.ca/blog/good-questions/
|
||||
- Being helpful and friendly on chat.zulip.org.
|
||||
|
||||
These are also the main criteria we use to select candidates for all
|
||||
of our outreach programs.
|
||||
@@ -369,7 +209,7 @@ is, the best place to post issues is
|
||||
[#issues](https://chat.zulip.org/#narrow/stream/9-issues) (or
|
||||
[#mobile](https://chat.zulip.org/#narrow/stream/48-mobile) or
|
||||
[#desktop](https://chat.zulip.org/#narrow/stream/16-desktop)) on the
|
||||
[Zulip community server](https://zulip.com/development-community/).
|
||||
[Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html).
|
||||
This allows us to interactively figure out what is going on, let you know if
|
||||
a similar issue has already been opened, and collect any other information
|
||||
we need. Choose a 2-4 word topic that describes the issue, explain the issue
|
||||
@@ -379,8 +219,8 @@ if appropriate.
|
||||
|
||||
**Reporting security issues**. Please do not report security issues
|
||||
publicly, including on public streams on chat.zulip.org. You can
|
||||
email [security@zulip.com](mailto:security@zulip.com). We create a CVE for every
|
||||
security issue in our released software.
|
||||
email security@zulip.com. We create a CVE for every security
|
||||
issue in our released software.
|
||||
|
||||
## User feedback
|
||||
|
||||
@@ -403,10 +243,6 @@ to:
|
||||
- Organization: What does your organization do? How big is the organization?
|
||||
A link to your organization's website?
|
||||
|
||||
You can contact us in the [#feedback stream of the Zulip development
|
||||
community](https://chat.zulip.org/#narrow/stream/137-feedback) or
|
||||
by emailing [support@zulip.com](mailto:support@zulip.com).
|
||||
|
||||
## Outreach programs
|
||||
|
||||
Zulip participates in [Google Summer of Code
|
||||
@@ -442,62 +278,70 @@ important parts of the project. We hope you apply!
|
||||
### Google Summer of Code
|
||||
|
||||
The largest outreach program Zulip participates in is GSoC (14
|
||||
students in 2017; 11 in 2018; 17 in 2019; 18 in 2020; 18 in 2021). While we
|
||||
don't control how
|
||||
students in 2017; 11 in 2018; 17 in 2019; 18 in 2020). While we don't control how
|
||||
many slots Google allocates to Zulip, we hope to mentor a similar
|
||||
number of students in future summers. Check out our [blog
|
||||
post](https://blog.zulip.com/2021/09/30/google-summer-of-code-2021/) to learn
|
||||
about the GSoC 2021 experience and our participants' accomplishments.
|
||||
number of students in future summers.
|
||||
|
||||
If you're reading this well before the application deadline and want
|
||||
to make your application strong, we recommend getting involved in the
|
||||
community and fixing issues in Zulip now. Having good contributions
|
||||
and building a reputation for doing good work is the best way to have
|
||||
a strong application.
|
||||
|
||||
Our [GSoC program page][gsoc-guide] has lots more details on how
|
||||
Zulip does GSoC, as well as project ideas. Note, however, that the project idea
|
||||
a strong application. About half of Zulip's GSoC students for Summer
|
||||
2017 had made significant contributions to the project by February
|
||||
2017, and about half had not. Our
|
||||
[GSoC project ideas page][gsoc-guide] has lots more details on how
|
||||
Zulip does GSoC, as well as project ideas (though the project idea
|
||||
list is maintained only during the GSoC application period, so if
|
||||
you're looking at some other time of year, the project list is likely
|
||||
out-of-date.
|
||||
out-of-date).
|
||||
|
||||
In some years, we have also run a Zulip Summer of Code (ZSoC)
|
||||
program for students who we wanted to accept into GSoC but did not have an
|
||||
official slot for. Student expectations are the
|
||||
same as with GSoC, and ZSoC has no separate application process; your
|
||||
We also have in some past years run a Zulip Summer of Code (ZSoC)
|
||||
program for students who we didn't have enough slots to accept for
|
||||
GSoC but were able to find funding for. Student expectations are the
|
||||
same as with GSoC, and it has no separate application process; your
|
||||
GSoC application is your ZSoC application. If we'd like to select you
|
||||
for ZSoC, we'll contact you when the GSoC results are announced.
|
||||
|
||||
[gsoc-guide]: https://zulip.readthedocs.io/en/latest/contributing/gsoc.html
|
||||
[gsoc-guide]: https://zulip.readthedocs.io/en/latest/contributing/gsoc-ideas.html
|
||||
[gsoc-faq]: https://developers.google.com/open-source/gsoc/faq
|
||||
|
||||
## Stay connected
|
||||
## Zulip outreach
|
||||
|
||||
Even if you are not logging into the development community on a regular basis,
|
||||
you can still stay connected with the project.
|
||||
|
||||
- Follow us [on Twitter](https://twitter.com/zulip).
|
||||
- Subscribe to [our blog](https://blog.zulip.org/).
|
||||
- Join or follow the project [on LinkedIn](https://www.linkedin.com/company/zulip-project/).
|
||||
|
||||
## Help others find Zulip
|
||||
|
||||
Here are some ways you can help others find Zulip:
|
||||
**Upvoting Zulip**. Upvotes and reviews make a big difference in the public
|
||||
perception of projects like Zulip. We've collected a few sites below
|
||||
where we know Zulip has been discussed. Doing everything in the following
|
||||
list typically takes about 15 minutes.
|
||||
|
||||
- Star us on GitHub. There are four main repositories:
|
||||
[server/web](https://github.com/zulip/zulip),
|
||||
[mobile](https://github.com/zulip/zulip-mobile),
|
||||
[desktop](https://github.com/zulip/zulip-desktop), and
|
||||
[Python API](https://github.com/zulip/python-zulip-api).
|
||||
- [Follow us](https://twitter.com/zulip) on Twitter.
|
||||
|
||||
- "Like" and retweet [our tweets](https://twitter.com/zulip).
|
||||
For both of the following, you'll need to make an account on the site if you
|
||||
don't already have one.
|
||||
|
||||
- Upvote and post feedback on Zulip on comparison websites. A couple specific
|
||||
ones to highlight:
|
||||
- [Like Zulip](https://alternativeto.net/software/zulip-chat-server/) on
|
||||
AlternativeTo. We recommend upvoting a couple of other products you like
|
||||
as well, both to give back to their community, and since single-upvote
|
||||
accounts are generally given less weight. You can also
|
||||
[upvote Zulip](https://alternativeto.net/software/slack/) on their page
|
||||
for Slack.
|
||||
- [Add Zulip to your stack](https://stackshare.io/zulip) on StackShare, star
|
||||
it, and upvote the reasons why people like Zulip that you find most
|
||||
compelling. Again, we recommend adding a few other products that you like
|
||||
as well.
|
||||
|
||||
- [AlternativeTo](https://alternativeto.net/software/zulip-chat-server/). You can also
|
||||
[upvote Zulip](https://alternativeto.net/software/slack/) on their page
|
||||
for Slack.
|
||||
- [Add Zulip to your stack](https://stackshare.io/zulip) on StackShare, star
|
||||
it, and upvote the reasons why people like Zulip that you find most
|
||||
compelling.
|
||||
We have a doc with more detailed instructions and a few other sites, if you
|
||||
have been using Zulip for a while and want to contribute more.
|
||||
|
||||
**Blog posts**. Writing a blog post about your experiences with Zulip, or
|
||||
about a technical aspect of Zulip can be a great way to spread the word
|
||||
about Zulip.
|
||||
|
||||
We also occasionally [publish](https://blog.zulip.org/) long-form
|
||||
articles related to Zulip. Our posts typically get tens of thousands
|
||||
of views, and we always have good ideas for blog posts that we can
|
||||
outline but don't have time to write. If you are an experienced writer
|
||||
or copyeditor, send us a portfolio; we'd love to talk!
|
||||
|
||||
105
README.md
105
README.md
@@ -1,18 +1,12 @@
|
||||
# Zulip overview
|
||||
|
||||
[Zulip](https://zulip.com) is an open-source team collaboration tool with unique
|
||||
[topic-based threading][why-zulip] that combines the best of email and chat to
|
||||
make remote work productive and delightful. Fortune 500 companies, [leading open
|
||||
source projects][rust-case-study], and thousands of other organizations use
|
||||
Zulip every day. Zulip is the only [modern team chat app][features] that is
|
||||
designed for both live and asynchronous conversations.
|
||||
|
||||
Zulip is built by a distributed community of developers from all around the
|
||||
world, with 74+ people who have each contributed 100+ commits. With
|
||||
over 1000 contributors merging over 500 commits a month, Zulip is the
|
||||
largest and fastest growing open source team chat project.
|
||||
|
||||
Come find us on the [development community chat](https://zulip.com/development-community/)!
|
||||
Zulip is a powerful, open source group chat application that combines the
|
||||
immediacy of real-time chat with the productivity benefits of threaded
|
||||
conversations. Zulip is used by open source projects, Fortune 500 companies,
|
||||
large standards bodies, and others who need a real-time chat system that
|
||||
allows users to easily process hundreds or thousands of messages a day. With
|
||||
over 700 contributors merging over 500 commits a month, Zulip is also the
|
||||
largest and fastest growing open source group chat project.
|
||||
|
||||
[](https://github.com/zulip/zulip/actions/workflows/zulip-ci.yml?query=branch%3Amain)
|
||||
[](https://codecov.io/gh/zulip/zulip)
|
||||
@@ -26,56 +20,61 @@ Come find us on the [development community chat](https://zulip.com/development-c
|
||||
[](https://github.com/sponsors/zulip)
|
||||
|
||||
[mypy-coverage]: https://blog.zulip.org/2016/10/13/static-types-in-python-oh-mypy/
|
||||
[why-zulip]: https://zulip.com/why-zulip/
|
||||
[rust-case-study]: https://zulip.com/case-studies/rust/
|
||||
[features]: https://zulip.com/features/
|
||||
|
||||
## Getting started
|
||||
|
||||
- **Contributing code**. Check out our [guide for new
|
||||
contributors](https://zulip.readthedocs.io/en/latest/overview/contributing.html)
|
||||
to get started. We have invested into making Zulip’s code uniquely readable,
|
||||
well tested, and easy to modify. Beyond that, we have written an extraordinary
|
||||
150K words of documentation on how to contribute to Zulip.
|
||||
Click on the appropriate link below. If nothing seems to apply,
|
||||
join us on the
|
||||
[Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html)
|
||||
and tell us what's up!
|
||||
|
||||
- **Contributing non-code**. [Report an
|
||||
issue](https://zulip.readthedocs.io/en/latest/overview/contributing.html#reporting-issues),
|
||||
[translate](https://zulip.readthedocs.io/en/latest/translating/translating.html)
|
||||
Zulip into your language, or [give us
|
||||
feedback](https://zulip.readthedocs.io/en/latest/overview/contributing.html#user-feedback).
|
||||
We'd love to hear from you, whether you've been using Zulip for years, or are just
|
||||
trying it out for the first time.
|
||||
You might be interested in:
|
||||
|
||||
- **Checking Zulip out**. The best way to see Zulip in action is to drop by the
|
||||
[Zulip community server](https://zulip.com/development-community/). We also
|
||||
recommend reading about Zulip's [unique
|
||||
approach](https://zulip.com/why-zulip/) to organizing conversations.
|
||||
- **Contributing code**. Check out our
|
||||
[guide for new contributors](https://zulip.readthedocs.io/en/latest/overview/contributing.html)
|
||||
to get started. Zulip prides itself on maintaining a clean and
|
||||
well-tested codebase, and a stock of hundreds of
|
||||
[beginner-friendly issues][beginner-friendly].
|
||||
|
||||
- **Running a Zulip server**. Self host Zulip directly on Ubuntu or Debian
|
||||
Linux, in [Docker](https://github.com/zulip/docker-zulip), or with prebuilt
|
||||
images for [Digital Ocean](https://marketplace.digitalocean.com/apps/zulip) and
|
||||
[Render](https://render.com/docs/deploy-zulip).
|
||||
Learn more about [self-hosting Zulip](https://zulip.com/self-hosting/).
|
||||
- **Contributing non-code**.
|
||||
[Report an issue](https://zulip.readthedocs.io/en/latest/overview/contributing.html#reporting-issues),
|
||||
[translate](https://zulip.readthedocs.io/en/latest/translating/translating.html) Zulip
|
||||
into your language,
|
||||
[write](https://zulip.readthedocs.io/en/latest/overview/contributing.html#zulip-outreach)
|
||||
for the Zulip blog, or
|
||||
[give us feedback](https://zulip.readthedocs.io/en/latest/overview/contributing.html#user-feedback). We
|
||||
would love to hear from you, even if you're just trying the product out.
|
||||
|
||||
- **Using Zulip without setting up a server**. Learn about [Zulip
|
||||
Cloud](https://zulip.com/plans/) hosting options. Zulip sponsors free [Zulip
|
||||
Cloud Standard](https://zulip.com/plans/) for hundreds of worthy
|
||||
organizations, including [fellow open-source
|
||||
projects](https://zulip.com/for/open-source/).
|
||||
- **Supporting Zulip**. Advocate for your organization to use Zulip, become a [sponsor](https://github.com/sponsors/zulip), write a
|
||||
review in the mobile app stores, or
|
||||
[upvote Zulip](https://zulip.readthedocs.io/en/latest/overview/contributing.html#zulip-outreach) on
|
||||
product comparison sites.
|
||||
|
||||
- **Checking Zulip out**. The best way to see Zulip in action is to drop by
|
||||
the
|
||||
[Zulip community server](https://zulip.readthedocs.io/en/latest/contributing/chat-zulip-org.html). We
|
||||
also recommend reading Zulip for
|
||||
[open source](https://zulip.com/for/open-source/), Zulip for
|
||||
[companies](https://zulip.com/for/companies/), or Zulip for
|
||||
[working groups and part time communities](https://zulip.com/for/working-groups-and-communities/).
|
||||
|
||||
- **Running a Zulip server**. Use a preconfigured [DigitalOcean droplet](https://marketplace.digitalocean.com/apps/zulip),
|
||||
[install Zulip](https://zulip.readthedocs.io/en/stable/production/install.html)
|
||||
directly, or use Zulip's
|
||||
experimental [Docker image](https://zulip.readthedocs.io/en/latest/production/deployment.html#zulip-in-docker).
|
||||
Commercial support is available; see <https://zulip.com/plans> for details.
|
||||
|
||||
- **Using Zulip without setting up a server**. <https://zulip.com>
|
||||
offers free and commercial hosting, including providing our paid
|
||||
plan for free to fellow open source projects.
|
||||
|
||||
- **Participating in [outreach
|
||||
programs](https://zulip.readthedocs.io/en/latest/overview/contributing.html#outreach-programs)**
|
||||
like [Google Summer of Code](https://developers.google.com/open-source/gsoc/)
|
||||
and [Outreachy](https://www.outreachy.org/).
|
||||
|
||||
- **Supporting Zulip**. Advocate for your organization to use Zulip, become a
|
||||
[sponsor](https://github.com/sponsors/zulip), write a review in the mobile app
|
||||
stores, or [help others find
|
||||
Zulip](https://zulip.readthedocs.io/en/latest/overview/contributing.html#help-others-find-zulip).
|
||||
|
||||
You may also be interested in reading our [blog](https://blog.zulip.org/), and
|
||||
following us on [Twitter](https://twitter.com/zulip) and
|
||||
[LinkedIn](https://www.linkedin.com/company/zulip-project/).
|
||||
like Google Summer of Code.
|
||||
|
||||
You may also be interested in reading our [blog](https://blog.zulip.org/) or
|
||||
following us on [Twitter](https://twitter.com/zulip).
|
||||
Zulip is distributed under the
|
||||
[Apache 2.0](https://github.com/zulip/zulip/blob/main/LICENSE) license.
|
||||
|
||||
[beginner-friendly]: https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A%22good+first+issue%22
|
||||
|
||||
15
SECURITY.md
15
SECURITY.md
@@ -1,5 +1,8 @@
|
||||
# Security policy
|
||||
|
||||
Security announcements are sent to zulip-announce@googlegroups.com,
|
||||
so you should subscribe if you are running Zulip in production.
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
We love responsible reports of (potential) security issues in Zulip,
|
||||
@@ -14,13 +17,6 @@ in our release notes when we publish the fix.
|
||||
Our [security model][security-model] document may be a helpful
|
||||
resource.
|
||||
|
||||
## Security announcements
|
||||
|
||||
We send security announcements to our [announcement mailing
|
||||
list](https://groups.google.com/g/zulip-announce). If you are running
|
||||
Zulip in production, you should subscribe, by clicking "Join group" at
|
||||
the top of that page.
|
||||
|
||||
## Supported versions
|
||||
|
||||
Zulip provides security support for the latest major release, in the
|
||||
@@ -29,9 +25,8 @@ form of minor security/maintenance releases.
|
||||
We work hard to make [upgrades][upgrades] reliable, so that there's no
|
||||
reason to run older major releases.
|
||||
|
||||
See also our documentation on the [Zulip release
|
||||
lifecycle][release-lifecycle].
|
||||
See also our documentation on the [Zulip release lifecycle][release-lifecycle]
|
||||
|
||||
[security-model]: https://zulip.readthedocs.io/en/latest/production/security-model.html
|
||||
[upgrades]: https://zulip.readthedocs.io/en/latest/production/upgrade-or-modify.html#upgrading-to-a-release
|
||||
[release-lifecycle]: https://zulip.readthedocs.io/en/latest/overview/release-lifecycle.html
|
||||
[release-cycle]: https://zulip.readthedocs.io/en/latest/overview/release-lifecycle.html
|
||||
|
||||
120
Vagrantfile
vendored
120
Vagrantfile
vendored
@@ -1,8 +1,48 @@
|
||||
# -*- mode: ruby -*-
|
||||
|
||||
Vagrant.require_version ">= 2.2.6"
|
||||
VAGRANTFILE_API_VERSION = "2"
|
||||
|
||||
if Vagrant::VERSION == "1.8.7"
|
||||
path = `command -v curl`
|
||||
if path.include?("/opt/vagrant/embedded/bin/curl")
|
||||
puts "In Vagrant 1.8.7, curl is broken. Please use Vagrant 2.0.2 " \
|
||||
"or run 'sudo rm -f /opt/vagrant/embedded/bin/curl' to fix the " \
|
||||
"issue before provisioning. See " \
|
||||
"https://github.com/mitchellh/vagrant/issues/7997 " \
|
||||
"for reference."
|
||||
exit
|
||||
end
|
||||
end
|
||||
|
||||
# Workaround: Vagrant removed the atlas.hashicorp.com to
|
||||
# vagrantcloud.com redirect in February 2018. The value of
|
||||
# DEFAULT_SERVER_URL in Vagrant versions less than 1.9.3 is
|
||||
# atlas.hashicorp.com, which means that removal broke the fetching and
|
||||
# updating of boxes (since the old URL doesn't work). See
|
||||
# https://github.com/hashicorp/vagrant/issues/9442
|
||||
if Vagrant::DEFAULT_SERVER_URL == "atlas.hashicorp.com"
|
||||
Vagrant::DEFAULT_SERVER_URL.replace("https://vagrantcloud.com")
|
||||
end
|
||||
|
||||
# Monkey patch https://github.com/hashicorp/vagrant/pull/10879 so we
|
||||
# can fall back to another provider if docker is not installed.
|
||||
begin
|
||||
require Vagrant.source_root.join("plugins", "providers", "docker", "provider")
|
||||
rescue LoadError
|
||||
else
|
||||
VagrantPlugins::DockerProvider::Provider.class_eval do
|
||||
method(:usable?).owner == singleton_class or def self.usable?(raise_error = false)
|
||||
VagrantPlugins::DockerProvider::Driver.new.execute("docker", "version")
|
||||
true
|
||||
rescue Vagrant::Errors::CommandUnavailable, VagrantPlugins::DockerProvider::Errors::ExecuteError
|
||||
raise if raise_error
|
||||
return false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Vagrant.configure(VAGRANTFILE_API_VERSION) do |config|
|
||||
|
||||
Vagrant.configure("2") do |config|
|
||||
# The Zulip development environment runs on 9991 on the guest.
|
||||
host_port = 9991
|
||||
http_proxy = https_proxy = no_proxy = nil
|
||||
@@ -12,7 +52,7 @@ Vagrant.configure("2") do |config|
|
||||
vm_num_cpus = "2"
|
||||
vm_memory = "2048"
|
||||
|
||||
debian_mirror = ""
|
||||
ubuntu_mirror = ""
|
||||
vboxadd_version = nil
|
||||
|
||||
config.vm.synced_folder ".", "/vagrant", disabled: true
|
||||
@@ -32,7 +72,7 @@ Vagrant.configure("2") do |config|
|
||||
when "HOST_IP_ADDR"; host_ip_addr = value
|
||||
when "GUEST_CPUS"; vm_num_cpus = value
|
||||
when "GUEST_MEMORY_MB"; vm_memory = value
|
||||
when "DEBIAN_MIRROR"; debian_mirror = value
|
||||
when "UBUNTU_MIRROR"; ubuntu_mirror = value
|
||||
when "VBOXADD_VERSION"; vboxadd_version = value
|
||||
end
|
||||
end
|
||||
@@ -63,21 +103,21 @@ Vagrant.configure("2") do |config|
|
||||
config.vm.provider "docker" do |d, override|
|
||||
d.build_dir = File.join(__dir__, "tools", "setup", "dev-vagrant-docker")
|
||||
d.build_args = ["--build-arg", "VAGRANT_UID=#{Process.uid}"]
|
||||
if !debian_mirror.empty?
|
||||
d.build_args += ["--build-arg", "DEBIAN_MIRROR=#{debian_mirror}"]
|
||||
if !ubuntu_mirror.empty?
|
||||
d.build_args += ["--build-arg", "UBUNTU_MIRROR=#{ubuntu_mirror}"]
|
||||
end
|
||||
d.has_ssh = true
|
||||
d.create_args = ["--ulimit", "nofile=1024:65536"]
|
||||
end
|
||||
|
||||
config.vm.provider "virtualbox" do |vb, override|
|
||||
override.vm.box = "bento/debian-10"
|
||||
override.vm.box = "hashicorp/bionic64"
|
||||
# It's possible we can get away with just 1.5GB; more testing needed
|
||||
vb.memory = vm_memory
|
||||
vb.cpus = vm_num_cpus
|
||||
|
||||
if !vboxadd_version.nil?
|
||||
override.vbguest.installer = Class.new(VagrantVbguest::Installers::Debian) do
|
||||
override.vbguest.installer = Class.new(VagrantVbguest::Installers::Ubuntu) do
|
||||
define_method(:host_version) do |reload = false|
|
||||
VagrantVbguest::Version(vboxadd_version)
|
||||
end
|
||||
@@ -88,21 +128,77 @@ Vagrant.configure("2") do |config|
|
||||
end
|
||||
|
||||
config.vm.provider "hyperv" do |h, override|
|
||||
override.vm.box = "bento/debian-10"
|
||||
override.vm.box = "bento/ubuntu-18.04"
|
||||
h.memory = vm_memory
|
||||
h.maxmemory = vm_memory
|
||||
h.cpus = vm_num_cpus
|
||||
end
|
||||
|
||||
config.vm.provider "parallels" do |prl, override|
|
||||
override.vm.box = "bento/debian-10"
|
||||
override.vm.box = "bento/ubuntu-18.04"
|
||||
override.vm.box_version = "202005.21.0"
|
||||
prl.memory = vm_memory
|
||||
prl.cpus = vm_num_cpus
|
||||
end
|
||||
|
||||
$provision_script = <<SCRIPT
|
||||
set -x
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
# Code should go here, rather than tools/provision, only if it is
|
||||
# something that we don't want to happen when running provision in a
|
||||
# development environment not using Vagrant.
|
||||
|
||||
# Set the Ubuntu mirror
|
||||
[ ! '#{ubuntu_mirror}' ] || sudo sed -i 's|http://\\(\\w*\\.\\)*archive\\.ubuntu\\.com/ubuntu/\\? |#{ubuntu_mirror} |' /etc/apt/sources.list
|
||||
|
||||
# Set the MOTD on the system to have Zulip instructions
|
||||
sudo ln -nsf /srv/zulip/tools/setup/dev-motd /etc/update-motd.d/99-zulip-dev
|
||||
sudo rm -f /etc/update-motd.d/10-help-text
|
||||
sudo dpkg --purge landscape-client landscape-common ubuntu-release-upgrader-core update-manager-core update-notifier-common ubuntu-server
|
||||
sudo dpkg-divert --add --rename /etc/default/motd-news
|
||||
sudo sh -c 'echo ENABLED=0 > /etc/default/motd-news'
|
||||
|
||||
# Set default locale, this prevents errors if the user has another locale set.
|
||||
if ! grep -q 'LC_ALL=C.UTF-8' /etc/default/locale; then
|
||||
echo "LC_ALL=C.UTF-8" | sudo tee -a /etc/default/locale
|
||||
fi
|
||||
|
||||
# Set an environment variable, so that we won't print the virtualenv
|
||||
# shell warning (it'll be wrong, since the shell is dying anyway)
|
||||
export SKIP_VENV_SHELL_WARNING=1
|
||||
|
||||
# End `set -x`, so that the end of provision doesn't look like an error
|
||||
# message after a successful run.
|
||||
set +x
|
||||
|
||||
# Check if the zulip directory is writable
|
||||
if [ ! -w /srv/zulip ]; then
|
||||
echo "The vagrant user is unable to write to the zulip directory."
|
||||
echo "To fix this, run the following commands on the host machine:"
|
||||
# sudo is required since our uid is not 1000
|
||||
echo ' vagrant halt -f'
|
||||
echo ' rm -rf /PATH/TO/ZULIP/CLONE/.vagrant'
|
||||
echo ' sudo chown -R 1000:$(id -g) /PATH/TO/ZULIP/CLONE'
|
||||
echo "Replace /PATH/TO/ZULIP/CLONE with the path to where zulip code is cloned."
|
||||
echo "You can resume setting up your vagrant environment by running:"
|
||||
echo " vagrant up"
|
||||
exit 1
|
||||
fi
|
||||
# Provision the development environment
|
||||
ln -nsf /srv/zulip ~/zulip
|
||||
/srv/zulip/tools/provision
|
||||
|
||||
# Run any custom provision hooks the user has configured
|
||||
if [ -f /srv/zulip/tools/custom_provision ]; then
|
||||
chmod +x /srv/zulip/tools/custom_provision
|
||||
/srv/zulip/tools/custom_provision
|
||||
fi
|
||||
SCRIPT
|
||||
|
||||
config.vm.provision "shell",
|
||||
# We want provision to be run with the permissions of the vagrant user.
|
||||
privileged: false,
|
||||
path: "tools/setup/vagrant-provision",
|
||||
env: { "DEBIAN_MIRROR" => debian_mirror }
|
||||
inline: $provision_script
|
||||
end
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
|
||||
from typing import Callable, Dict, Optional, Sequence, Tuple, Type, Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection, models
|
||||
from django.db import connection
|
||||
from django.db.models import F
|
||||
from psycopg2.sql import SQL, Composable, Identifier, Literal
|
||||
|
||||
@@ -20,7 +20,15 @@ from analytics.models import (
|
||||
)
|
||||
from zerver.lib.logging_util import log_to_file
|
||||
from zerver.lib.timestamp import ceiling_to_day, ceiling_to_hour, floor_to_hour, verify_UTC
|
||||
from zerver.models import Message, Realm, RealmAuditLog, Stream, UserActivityInterval, UserProfile
|
||||
from zerver.models import (
|
||||
Message,
|
||||
Realm,
|
||||
RealmAuditLog,
|
||||
Stream,
|
||||
UserActivityInterval,
|
||||
UserProfile,
|
||||
models,
|
||||
)
|
||||
|
||||
## Logging setup ##
|
||||
|
||||
@@ -168,7 +176,7 @@ def do_update_fill_state(fill_state: FillState, end_time: datetime, state: int)
|
||||
|
||||
|
||||
# We assume end_time is valid (e.g. is on a day or hour boundary as appropriate)
|
||||
# and is time-zone-aware. It is the caller's responsibility to enforce this!
|
||||
# and is timezone aware. It is the caller's responsibility to enforce this!
|
||||
def do_fill_count_stat_at_hour(
|
||||
stat: CountStat, end_time: datetime, realm: Optional[Realm] = None
|
||||
) -> None:
|
||||
@@ -206,7 +214,7 @@ def do_aggregate_to_summary_table(
|
||||
# Aggregate into RealmCount
|
||||
output_table = stat.data_collector.output_table
|
||||
if realm is not None:
|
||||
realm_clause: Composable = SQL("AND zerver_realm.id = {}").format(Literal(realm.id))
|
||||
realm_clause = SQL("AND zerver_realm.id = {}").format(Literal(realm.id))
|
||||
else:
|
||||
realm_clause = SQL("")
|
||||
|
||||
@@ -288,7 +296,7 @@ def do_aggregate_to_summary_table(
|
||||
|
||||
## Utility functions called from outside counts.py ##
|
||||
|
||||
# called from zerver.actions; should not throw any errors
|
||||
# called from zerver/lib/actions.py; should not throw any errors
|
||||
def do_increment_logging_stat(
|
||||
zerver_object: Union[Realm, UserProfile, Stream],
|
||||
stat: CountStat,
|
||||
@@ -301,13 +309,10 @@ def do_increment_logging_stat(
|
||||
|
||||
table = stat.data_collector.output_table
|
||||
if table == RealmCount:
|
||||
assert isinstance(zerver_object, Realm)
|
||||
id_args: Dict[str, Union[Realm, UserProfile, Stream]] = {"realm": zerver_object}
|
||||
id_args = {"realm": zerver_object}
|
||||
elif table == UserCount:
|
||||
assert isinstance(zerver_object, UserProfile)
|
||||
id_args = {"realm": zerver_object.realm, "user": zerver_object}
|
||||
else: # StreamCount
|
||||
assert isinstance(zerver_object, Stream)
|
||||
id_args = {"realm": zerver_object.realm, "stream": zerver_object}
|
||||
|
||||
if stat.frequency == CountStat.DAY:
|
||||
@@ -353,11 +358,11 @@ def do_pull_by_sql_query(
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
query: QueryFn,
|
||||
group_by: Optional[Tuple[Type[models.Model], str]],
|
||||
group_by: Optional[Tuple[models.Model, str]],
|
||||
) -> int:
|
||||
if group_by is None:
|
||||
subgroup: Composable = SQL("NULL")
|
||||
group_by_clause: Composable = SQL("")
|
||||
subgroup = SQL("NULL")
|
||||
group_by_clause = SQL("")
|
||||
else:
|
||||
subgroup = Identifier(group_by[0]._meta.db_table, group_by[1])
|
||||
group_by_clause = SQL(", {}").format(subgroup)
|
||||
@@ -389,7 +394,7 @@ def do_pull_by_sql_query(
|
||||
def sql_data_collector(
|
||||
output_table: Type[BaseCount],
|
||||
query: QueryFn,
|
||||
group_by: Optional[Tuple[Type[models.Model], str]],
|
||||
group_by: Optional[Tuple[models.Model, str]],
|
||||
) -> DataCollector:
|
||||
def pull_function(
|
||||
property: str, start_time: datetime, end_time: datetime, realm: Optional[Realm] = None
|
||||
@@ -443,7 +448,7 @@ def do_pull_minutes_active(
|
||||
|
||||
def count_message_by_user_query(realm: Optional[Realm]) -> QueryFn:
|
||||
if realm is None:
|
||||
realm_clause: Composable = SQL("")
|
||||
realm_clause = SQL("")
|
||||
else:
|
||||
realm_clause = SQL("zerver_userprofile.realm_id = {} AND").format(Literal(realm.id))
|
||||
return lambda kwargs: SQL(
|
||||
@@ -470,7 +475,7 @@ def count_message_by_user_query(realm: Optional[Realm]) -> QueryFn:
|
||||
# Note: ignores the group_by / group_by_clause.
|
||||
def count_message_type_by_user_query(realm: Optional[Realm]) -> QueryFn:
|
||||
if realm is None:
|
||||
realm_clause: Composable = SQL("")
|
||||
realm_clause = SQL("")
|
||||
else:
|
||||
realm_clause = SQL("zerver_userprofile.realm_id = {} AND").format(Literal(realm.id))
|
||||
return lambda kwargs: SQL(
|
||||
@@ -519,7 +524,7 @@ def count_message_type_by_user_query(realm: Optional[Realm]) -> QueryFn:
|
||||
# table, consider writing a new query for efficiency.
|
||||
def count_message_by_stream_query(realm: Optional[Realm]) -> QueryFn:
|
||||
if realm is None:
|
||||
realm_clause: Composable = SQL("")
|
||||
realm_clause = SQL("")
|
||||
else:
|
||||
realm_clause = SQL("zerver_stream.realm_id = {} AND").format(Literal(realm.id))
|
||||
return lambda kwargs: SQL(
|
||||
@@ -553,7 +558,7 @@ def count_message_by_stream_query(realm: Optional[Realm]) -> QueryFn:
|
||||
# currently the only stat that uses this.
|
||||
def count_user_by_realm_query(realm: Optional[Realm]) -> QueryFn:
|
||||
if realm is None:
|
||||
realm_clause: Composable = SQL("")
|
||||
realm_clause = SQL("")
|
||||
else:
|
||||
realm_clause = SQL("zerver_userprofile.realm_id = {} AND").format(Literal(realm.id))
|
||||
return lambda kwargs: SQL(
|
||||
@@ -583,7 +588,7 @@ def count_user_by_realm_query(realm: Optional[Realm]) -> QueryFn:
|
||||
# In particular, it's important to ensure that migrations don't cause that to happen.
|
||||
def check_realmauditlog_by_user_query(realm: Optional[Realm]) -> QueryFn:
|
||||
if realm is None:
|
||||
realm_clause: Composable = SQL("")
|
||||
realm_clause = SQL("")
|
||||
else:
|
||||
realm_clause = SQL("realm_id = {} AND").format(Literal(realm.id))
|
||||
return lambda kwargs: SQL(
|
||||
@@ -623,7 +628,7 @@ def check_realmauditlog_by_user_query(realm: Optional[Realm]) -> QueryFn:
|
||||
|
||||
def check_useractivityinterval_by_user_query(realm: Optional[Realm]) -> QueryFn:
|
||||
if realm is None:
|
||||
realm_clause: Composable = SQL("")
|
||||
realm_clause = SQL("")
|
||||
else:
|
||||
realm_clause = SQL("zerver_userprofile.realm_id = {} AND").format(Literal(realm.id))
|
||||
return lambda kwargs: SQL(
|
||||
@@ -647,7 +652,7 @@ def check_useractivityinterval_by_user_query(realm: Optional[Realm]) -> QueryFn:
|
||||
|
||||
def count_realm_active_humans_query(realm: Optional[Realm]) -> QueryFn:
|
||||
if realm is None:
|
||||
realm_clause: Composable = SQL("")
|
||||
realm_clause = SQL("")
|
||||
else:
|
||||
realm_clause = SQL("realm_id = {} AND").format(Literal(realm.id))
|
||||
return lambda kwargs: SQL(
|
||||
|
||||
@@ -59,7 +59,7 @@ def generate_time_series_data(
|
||||
)
|
||||
growth_base = growth ** (1.0 / (length - 1))
|
||||
values_no_noise = [
|
||||
seasonality[i % len(seasonality)] * (growth_base**i) for i in range(length)
|
||||
seasonality[i % len(seasonality)] * (growth_base ** i) for i in range(length)
|
||||
]
|
||||
|
||||
seed(random_seed)
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.utils.timezone import now as timezone_now
|
||||
|
||||
from analytics.lib.counts import COUNT_STATS, CountStat
|
||||
from analytics.models import installation_epoch
|
||||
from zerver.lib.timestamp import TimeZoneNotUTCException, floor_to_day, floor_to_hour, verify_UTC
|
||||
from zerver.lib.timestamp import TimezoneNotUTCException, floor_to_day, floor_to_hour, verify_UTC
|
||||
from zerver.models import Realm
|
||||
|
||||
states = {
|
||||
@@ -48,7 +48,7 @@ class Command(BaseCommand):
|
||||
last_fill = installation_epoch()
|
||||
try:
|
||||
verify_UTC(last_fill)
|
||||
except TimeZoneNotUTCException:
|
||||
except TimezoneNotUTCException:
|
||||
return {"status": 2, "message": f"FillState not in UTC for {property}"}
|
||||
|
||||
if stat.frequency == CountStat.DAY:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from typing import Any, Dict, List, Mapping, Type, Union
|
||||
from typing import Any, Dict, List, Mapping, Optional, Type
|
||||
from unittest import mock
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
@@ -16,10 +16,8 @@ from analytics.models import (
|
||||
StreamCount,
|
||||
UserCount,
|
||||
)
|
||||
from zerver.actions.create_realm import do_create_realm
|
||||
from zerver.actions.users import do_change_user_role
|
||||
from zerver.lib.actions import STREAM_ASSIGNMENT_COLORS, do_change_user_role, do_create_realm
|
||||
from zerver.lib.create_user import create_user
|
||||
from zerver.lib.stream_color import STREAM_ASSIGNMENT_COLORS
|
||||
from zerver.lib.timestamp import floor_to_day
|
||||
from zerver.models import Client, Realm, Recipient, Stream, Subscription, UserProfile
|
||||
|
||||
@@ -105,12 +103,8 @@ class Command(BaseCommand):
|
||||
]
|
||||
Subscription.objects.bulk_create(subs)
|
||||
|
||||
FixtureData = Mapping[Union[str, int, None], List[int]]
|
||||
|
||||
def insert_fixture_data(
|
||||
stat: CountStat,
|
||||
fixture_data: FixtureData,
|
||||
table: Type[BaseCount],
|
||||
stat: CountStat, fixture_data: Mapping[Optional[str], List[int]], table: Type[BaseCount]
|
||||
) -> None:
|
||||
end_times = time_range(
|
||||
last_end_time, last_end_time, stat.frequency, len(list(fixture_data.values())[0])
|
||||
@@ -138,11 +132,11 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
stat = COUNT_STATS["1day_actives::day"]
|
||||
realm_data: FixtureData = {
|
||||
realm_data: Mapping[Optional[str], List[int]] = {
|
||||
None: self.generate_fixture_data(stat, 0.08, 0.02, 3, 0.3, 6, partial_sum=True),
|
||||
}
|
||||
insert_fixture_data(stat, realm_data, RealmCount)
|
||||
installation_data: FixtureData = {
|
||||
installation_data: Mapping[Optional[str], List[int]] = {
|
||||
None: self.generate_fixture_data(stat, 0.8, 0.2, 4, 0.3, 6, partial_sum=True),
|
||||
}
|
||||
insert_fixture_data(stat, installation_data, InstallationCount)
|
||||
@@ -192,7 +186,7 @@ class Command(BaseCommand):
|
||||
)
|
||||
|
||||
stat = COUNT_STATS["messages_sent:is_bot:hour"]
|
||||
user_data: FixtureData = {
|
||||
user_data: Mapping[Optional[str], List[int]] = {
|
||||
"false": self.generate_fixture_data(stat, 2, 1, 1.5, 0.6, 8, holiday_rate=0.1),
|
||||
}
|
||||
insert_fixture_data(stat, user_data, UserCount)
|
||||
@@ -285,7 +279,7 @@ class Command(BaseCommand):
|
||||
"true": self.generate_fixture_data(stat, 20, 2, 3, 0.2, 3),
|
||||
}
|
||||
insert_fixture_data(stat, realm_data, RealmCount)
|
||||
stream_data: Mapping[Union[int, str, None], List[int]] = {
|
||||
stream_data: Mapping[Optional[str], List[int]] = {
|
||||
"false": self.generate_fixture_data(stat, 10, 7, 5, 0.6, 4),
|
||||
"true": self.generate_fixture_data(stat, 5, 3, 2, 0.4, 2),
|
||||
}
|
||||
|
||||
61
analytics/management/commands/stream_stats.py
Normal file
61
analytics/management/commands/stream_stats.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from argparse import ArgumentParser
|
||||
from typing import Any
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.db.models import Q
|
||||
|
||||
from zerver.models import Message, Realm, Recipient, Stream, Subscription, get_realm
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Generate statistics on the streams for a realm."
|
||||
|
||||
def add_arguments(self, parser: ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"realms", metavar="<realm>", nargs="*", help="realm to generate statistics for"
|
||||
)
|
||||
|
||||
def handle(self, *args: Any, **options: str) -> None:
|
||||
if options["realms"]:
|
||||
try:
|
||||
realms = [get_realm(string_id) for string_id in options["realms"]]
|
||||
except Realm.DoesNotExist as e:
|
||||
raise CommandError(e)
|
||||
else:
|
||||
realms = Realm.objects.all()
|
||||
|
||||
for realm in realms:
|
||||
streams = Stream.objects.filter(realm=realm).exclude(Q(name__istartswith="tutorial-"))
|
||||
# private stream count
|
||||
private_count = 0
|
||||
# public stream count
|
||||
public_count = 0
|
||||
for stream in streams:
|
||||
if stream.invite_only:
|
||||
private_count += 1
|
||||
else:
|
||||
public_count += 1
|
||||
print("------------")
|
||||
print(realm.string_id, end=" ")
|
||||
print("{:>10} {} public streams and".format("(", public_count), end=" ")
|
||||
print(f"{private_count} private streams )")
|
||||
print("------------")
|
||||
print("{:>25} {:>15} {:>10} {:>12}".format("stream", "subscribers", "messages", "type"))
|
||||
|
||||
for stream in streams:
|
||||
if stream.invite_only:
|
||||
stream_type = "private"
|
||||
else:
|
||||
stream_type = "public"
|
||||
print(f"{stream.name:>25}", end=" ")
|
||||
recipient = Recipient.objects.filter(type=Recipient.STREAM, type_id=stream.id)
|
||||
print(
|
||||
"{:10}".format(
|
||||
len(Subscription.objects.filter(recipient=recipient, active=True))
|
||||
),
|
||||
end=" ",
|
||||
)
|
||||
num_messages = len(Message.objects.filter(recipient=recipient))
|
||||
print(f"{num_messages:12}", end=" ")
|
||||
print(f"{stream_type:>15}")
|
||||
print("")
|
||||
@@ -60,12 +60,11 @@ class Command(BaseCommand):
|
||||
return
|
||||
|
||||
fill_to_time = parse_datetime(options["time"])
|
||||
assert fill_to_time is not None
|
||||
if options["utc"]:
|
||||
fill_to_time = fill_to_time.replace(tzinfo=timezone.utc)
|
||||
if fill_to_time.tzinfo is None:
|
||||
raise ValueError(
|
||||
"--time must be time-zone-aware. Maybe you meant to use the --utc option?"
|
||||
"--time must be timezone aware. Maybe you meant to use the --utc option?"
|
||||
)
|
||||
|
||||
fill_to_time = floor_to_hour(fill_to_time.astimezone(timezone.utc))
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
from unittest import mock
|
||||
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.test_helpers import queries_captured
|
||||
from zerver.models import Client, UserActivity, UserProfile, flush_per_request_caches
|
||||
|
||||
|
||||
class ActivityTest(ZulipTestCase):
|
||||
@mock.patch("stripe.Customer.list", return_value=[])
|
||||
def test_activity(self, unused_mock: mock.Mock) -> None:
|
||||
self.login("hamlet")
|
||||
client, _ = Client.objects.get_or_create(name="website")
|
||||
query = "/json/messages/flags"
|
||||
last_visit = timezone_now()
|
||||
count = 150
|
||||
for activity_user_profile in UserProfile.objects.all():
|
||||
UserActivity.objects.get_or_create(
|
||||
user_profile=activity_user_profile,
|
||||
client=client,
|
||||
query=query,
|
||||
count=count,
|
||||
last_visit=last_visit,
|
||||
)
|
||||
|
||||
# Fails when not staff
|
||||
result = self.client_get("/activity")
|
||||
self.assertEqual(result.status_code, 302)
|
||||
|
||||
user_profile = self.example_user("hamlet")
|
||||
user_profile.is_staff = True
|
||||
user_profile.save(update_fields=["is_staff"])
|
||||
|
||||
flush_per_request_caches()
|
||||
with queries_captured() as queries:
|
||||
result = self.client_get("/activity")
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
self.assert_length(queries, 19)
|
||||
|
||||
flush_per_request_caches()
|
||||
with queries_captured() as queries:
|
||||
result = self.client_get("/realm_activity/zulip/")
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
self.assert_length(queries, 8)
|
||||
|
||||
iago = self.example_user("iago")
|
||||
flush_per_request_caches()
|
||||
with queries_captured() as queries:
|
||||
result = self.client_get(f"/user_activity/{iago.id}/")
|
||||
self.assertEqual(result.status_code, 200)
|
||||
|
||||
self.assert_length(queries, 5)
|
||||
@@ -32,30 +32,25 @@ from analytics.models import (
|
||||
UserCount,
|
||||
installation_epoch,
|
||||
)
|
||||
from zerver.actions.create_realm import do_create_realm
|
||||
from zerver.actions.create_user import (
|
||||
do_activate_mirror_dummy_user,
|
||||
from zerver.lib.actions import (
|
||||
InvitationError,
|
||||
do_activate_user,
|
||||
do_create_realm,
|
||||
do_create_user,
|
||||
do_reactivate_user,
|
||||
)
|
||||
from zerver.actions.invites import (
|
||||
do_deactivate_user,
|
||||
do_invite_users,
|
||||
do_resend_user_invite_email,
|
||||
do_revoke_user_invite,
|
||||
)
|
||||
from zerver.actions.message_flags import (
|
||||
do_mark_all_as_read,
|
||||
do_mark_stream_messages_as_read,
|
||||
do_reactivate_user,
|
||||
do_resend_user_invite_email,
|
||||
do_revoke_user_invite,
|
||||
do_update_message_flags,
|
||||
update_user_activity_interval,
|
||||
)
|
||||
from zerver.actions.user_activity import update_user_activity_interval
|
||||
from zerver.actions.users import do_deactivate_user
|
||||
from zerver.lib.create_user import create_user
|
||||
from zerver.lib.exceptions import InvitationError
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.timestamp import TimeZoneNotUTCException, floor_to_day
|
||||
from zerver.lib.timestamp import TimezoneNotUTCException, floor_to_day
|
||||
from zerver.lib.topic import DB_TOPIC_NAME
|
||||
from zerver.lib.utils import assert_is_not_none
|
||||
from zerver.models import (
|
||||
Client,
|
||||
Huddle,
|
||||
@@ -225,7 +220,7 @@ class AnalyticsTestCase(ZulipTestCase):
|
||||
else:
|
||||
kwargs["realm"] = self.default_realm
|
||||
self.assertEqual(table.objects.filter(**kwargs).count(), 1)
|
||||
self.assert_length(arg_values, table.objects.count())
|
||||
self.assertEqual(table.objects.count(), len(arg_values))
|
||||
|
||||
|
||||
class TestProcessCountStat(AnalyticsTestCase):
|
||||
@@ -245,7 +240,6 @@ class TestProcessCountStat(AnalyticsTestCase):
|
||||
self, stat: CountStat, end_time: datetime, state: int = FillState.DONE
|
||||
) -> None:
|
||||
fill_state = FillState.objects.filter(property=stat.property).first()
|
||||
assert fill_state is not None
|
||||
self.assertEqual(fill_state.end_time, end_time)
|
||||
self.assertEqual(fill_state.state, state)
|
||||
|
||||
@@ -279,7 +273,7 @@ class TestProcessCountStat(AnalyticsTestCase):
|
||||
stat = self.make_dummy_count_stat("test stat")
|
||||
with self.assertRaises(ValueError):
|
||||
process_count_stat(stat, installation_epoch() + 65 * self.MINUTE)
|
||||
with self.assertRaises(TimeZoneNotUTCException):
|
||||
with self.assertRaises(TimezoneNotUTCException):
|
||||
process_count_stat(stat, installation_epoch().replace(tzinfo=None))
|
||||
|
||||
# This tests the LoggingCountStat branch of the code in do_delete_counts_at_hour.
|
||||
@@ -1335,7 +1329,7 @@ class TestLoggingCountStats(AnalyticsTestCase):
|
||||
"value__sum"
|
||||
],
|
||||
)
|
||||
do_activate_mirror_dummy_user(user, acting_user=None)
|
||||
do_activate_user(user, acting_user=None)
|
||||
self.assertEqual(
|
||||
1,
|
||||
RealmCount.objects.filter(property=property, subgroup=False).aggregate(Sum("value"))[
|
||||
@@ -1370,58 +1364,34 @@ class TestLoggingCountStats(AnalyticsTestCase):
|
||||
|
||||
user = self.create_user(email="first@domain.tld")
|
||||
stream, _ = self.create_stream_with_recipient()
|
||||
|
||||
invite_expires_in_days = 2
|
||||
do_invite_users(
|
||||
user,
|
||||
["user1@domain.tld", "user2@domain.tld"],
|
||||
[stream],
|
||||
invite_expires_in_days=invite_expires_in_days,
|
||||
)
|
||||
do_invite_users(user, ["user1@domain.tld", "user2@domain.tld"], [stream])
|
||||
assertInviteCountEquals(2)
|
||||
|
||||
# We currently send emails when re-inviting users that haven't
|
||||
# turned into accounts, so count them towards the total
|
||||
do_invite_users(
|
||||
user,
|
||||
["user1@domain.tld", "user2@domain.tld"],
|
||||
[stream],
|
||||
invite_expires_in_days=invite_expires_in_days,
|
||||
)
|
||||
do_invite_users(user, ["user1@domain.tld", "user2@domain.tld"], [stream])
|
||||
assertInviteCountEquals(4)
|
||||
|
||||
# Test mix of good and malformed invite emails
|
||||
try:
|
||||
do_invite_users(
|
||||
user,
|
||||
["user3@domain.tld", "malformed"],
|
||||
[stream],
|
||||
invite_expires_in_days=invite_expires_in_days,
|
||||
)
|
||||
do_invite_users(user, ["user3@domain.tld", "malformed"], [stream])
|
||||
except InvitationError:
|
||||
pass
|
||||
assertInviteCountEquals(4)
|
||||
|
||||
# Test inviting existing users
|
||||
try:
|
||||
do_invite_users(
|
||||
user,
|
||||
["first@domain.tld", "user4@domain.tld"],
|
||||
[stream],
|
||||
invite_expires_in_days=invite_expires_in_days,
|
||||
)
|
||||
do_invite_users(user, ["first@domain.tld", "user4@domain.tld"], [stream])
|
||||
except InvitationError:
|
||||
pass
|
||||
assertInviteCountEquals(5)
|
||||
|
||||
# Revoking invite should not give you credit
|
||||
do_revoke_user_invite(
|
||||
assert_is_not_none(PreregistrationUser.objects.filter(realm=user.realm).first())
|
||||
)
|
||||
do_revoke_user_invite(PreregistrationUser.objects.filter(realm=user.realm).first())
|
||||
assertInviteCountEquals(5)
|
||||
|
||||
# Resending invite should cost you
|
||||
do_resend_user_invite_email(assert_is_not_none(PreregistrationUser.objects.first()))
|
||||
do_resend_user_invite_email(PreregistrationUser.objects.first())
|
||||
assertInviteCountEquals(6)
|
||||
|
||||
def test_messages_read_hour(self) -> None:
|
||||
@@ -1435,7 +1405,8 @@ class TestLoggingCountStats(AnalyticsTestCase):
|
||||
self.subscribe(user2, stream.name)
|
||||
|
||||
self.send_personal_message(user1, user2)
|
||||
do_mark_all_as_read(user2)
|
||||
client = get_client("website")
|
||||
do_mark_all_as_read(user2, client)
|
||||
self.assertEqual(
|
||||
1,
|
||||
UserCount.objects.filter(property=read_count_property).aggregate(Sum("value"))[
|
||||
@@ -1451,7 +1422,7 @@ class TestLoggingCountStats(AnalyticsTestCase):
|
||||
|
||||
self.send_stream_message(user1, stream.name)
|
||||
self.send_stream_message(user1, stream.name)
|
||||
do_mark_stream_messages_as_read(user2, assert_is_not_none(stream.recipient_id))
|
||||
do_mark_stream_messages_as_read(user2, stream.recipient_id)
|
||||
self.assertEqual(
|
||||
3,
|
||||
UserCount.objects.filter(property=read_count_property).aggregate(Sum("value"))[
|
||||
@@ -1466,7 +1437,7 @@ class TestLoggingCountStats(AnalyticsTestCase):
|
||||
)
|
||||
|
||||
message = self.send_stream_message(user2, stream.name)
|
||||
do_update_message_flags(user1, "add", "read", [message])
|
||||
do_update_message_flags(user1, client, "add", "read", [message])
|
||||
self.assertEqual(
|
||||
4,
|
||||
UserCount.objects.filter(property=read_count_property).aggregate(Sum("value"))[
|
||||
@@ -1692,7 +1663,7 @@ class TestActiveUsersAudit(AnalyticsTestCase):
|
||||
"email4", "password", self.default_realm, "full_name", acting_user=None
|
||||
)
|
||||
do_deactivate_user(user2, acting_user=None)
|
||||
do_activate_mirror_dummy_user(user3, acting_user=None)
|
||||
do_activate_user(user3, acting_user=None)
|
||||
do_reactivate_user(user4, acting_user=None)
|
||||
end_time = floor_to_day(timezone_now()) + self.DAY
|
||||
do_fill_count_stat_at_hour(self.stat, end_time)
|
||||
|
||||
@@ -22,7 +22,7 @@ class TestFixtures(ZulipTestCase):
|
||||
frequency=CountStat.HOUR,
|
||||
)
|
||||
# test we get an array of the right length with frequency=CountStat.HOUR
|
||||
self.assert_length(data, 24)
|
||||
self.assertEqual(len(data), 24)
|
||||
# test that growth doesn't affect the first data point
|
||||
self.assertEqual(data[0], 2000)
|
||||
# test that the last data point is growth times what it otherwise would be
|
||||
|
||||
@@ -1,629 +0,0 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest import mock
|
||||
|
||||
import orjson
|
||||
from django.http import HttpResponse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from corporate.lib.stripe import add_months, update_sponsorship_status
|
||||
from corporate.models import Customer, CustomerPlan, LicenseLedger, get_customer_by_realm
|
||||
from zerver.actions.invites import do_create_multiuse_invite_link
|
||||
from zerver.actions.realm_settings import do_send_realm_reactivation_email, do_set_realm_property
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.test_helpers import reset_emails_in_zulip_realm
|
||||
from zerver.models import (
|
||||
MultiuseInvite,
|
||||
PreregistrationUser,
|
||||
Realm,
|
||||
UserMessage,
|
||||
UserProfile,
|
||||
get_org_type_display_name,
|
||||
get_realm,
|
||||
)
|
||||
|
||||
|
||||
class TestSupportEndpoint(ZulipTestCase):
|
||||
def test_search(self) -> None:
|
||||
reset_emails_in_zulip_realm()
|
||||
|
||||
def assert_user_details_in_html_response(
|
||||
html_response: HttpResponse, full_name: str, email: str, role: str
|
||||
) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">user</span>\n',
|
||||
f"<h3>{full_name}</h3>",
|
||||
f"<b>Email</b>: {email}",
|
||||
"<b>Is active</b>: True<br />",
|
||||
f"<b>Role</b>: {role}<br />",
|
||||
],
|
||||
html_response,
|
||||
)
|
||||
|
||||
def check_hamlet_user_query_result(result: HttpResponse) -> None:
|
||||
assert_user_details_in_html_response(
|
||||
result, "King Hamlet", self.example_email("hamlet"), "Member"
|
||||
)
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
f"<b>Admins</b>: {self.example_email('iago')}\n",
|
||||
f"<b>Owners</b>: {self.example_email('desdemona')}\n",
|
||||
'class="copy-button" data-copytext="{}">'.format(self.example_email("iago")),
|
||||
'class="copy-button" data-copytext="{}">'.format(
|
||||
self.example_email("desdemona")
|
||||
),
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_othello_user_query_result(result: HttpResponse) -> None:
|
||||
assert_user_details_in_html_response(
|
||||
result, "Othello, the Moor of Venice", self.example_email("othello"), "Member"
|
||||
)
|
||||
|
||||
def check_polonius_user_query_result(result: HttpResponse) -> None:
|
||||
assert_user_details_in_html_response(
|
||||
result, "Polonius", self.example_email("polonius"), "Guest"
|
||||
)
|
||||
|
||||
def check_zulip_realm_query_result(result: HttpResponse) -> None:
|
||||
zulip_realm = get_realm("zulip")
|
||||
first_human_user = zulip_realm.get_first_human_user()
|
||||
assert first_human_user is not None
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
f"<b>First human user</b>: {first_human_user.delivery_email}\n",
|
||||
f'<input type="hidden" name="realm_id" value="{zulip_realm.id}"',
|
||||
"Zulip Dev</h3>",
|
||||
'<option value="1" selected>Self-hosted</option>',
|
||||
'<option value="2" >Limited</option>',
|
||||
'input type="number" name="discount" value="None"',
|
||||
'<option value="active" selected>Active</option>',
|
||||
'<option value="deactivated" >Deactivated</option>',
|
||||
f'<option value="{zulip_realm.org_type}" selected>',
|
||||
'scrub-realm-button">',
|
||||
'data-string-id="zulip"',
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_lear_realm_query_result(result: HttpResponse) -> None:
|
||||
lear_realm = get_realm("lear")
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
f'<input type="hidden" name="realm_id" value="{lear_realm.id}"',
|
||||
"Lear & Co.</h3>",
|
||||
'<option value="1" selected>Self-hosted</option>',
|
||||
'<option value="2" >Limited</option>',
|
||||
'input type="number" name="discount" value="None"',
|
||||
'<option value="active" selected>Active</option>',
|
||||
'<option value="deactivated" >Deactivated</option>',
|
||||
'scrub-realm-button">',
|
||||
'data-string-id="lear"',
|
||||
"<b>Name</b>: Zulip Cloud Standard",
|
||||
"<b>Status</b>: Active",
|
||||
"<b>Billing schedule</b>: Annual",
|
||||
"<b>Licenses</b>: 2/10 (Manual)",
|
||||
"<b>Price per license</b>: $80.0",
|
||||
"<b>Next invoice date</b>: 02 January 2017",
|
||||
'<option value="send_invoice" selected>',
|
||||
'<option value="charge_automatically" >',
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_preregistration_user_query_result(
|
||||
result: HttpResponse, email: str, invite: bool = False
|
||||
) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">preregistration user</span>\n',
|
||||
f"<b>Email</b>: {email}",
|
||||
],
|
||||
result,
|
||||
)
|
||||
if invite:
|
||||
self.assert_in_success_response(['<span class="label">invite</span>'], result)
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
"<b>Expires in</b>: 1\xa0week, 3\xa0days",
|
||||
"<b>Status</b>: Link has never been clicked",
|
||||
],
|
||||
result,
|
||||
)
|
||||
self.assert_in_success_response([], result)
|
||||
else:
|
||||
self.assert_not_in_success_response(['<span class="label">invite</span>'], result)
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
"<b>Expires in</b>: 1\xa0day",
|
||||
"<b>Status</b>: Link has never been clicked",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_realm_creation_query_result(result: HttpResponse, email: str) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">preregistration user</span>\n',
|
||||
'<span class="label">realm creation</span>\n',
|
||||
"<b>Link</b>: http://testserver/accounts/do_confirm/",
|
||||
"<b>Expires in</b>: 1\xa0day",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_multiuse_invite_link_query_result(result: HttpResponse) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">multiuse invite</span>\n',
|
||||
"<b>Link</b>: http://zulip.testserver/join/",
|
||||
"<b>Expires in</b>: 1\xa0week, 3\xa0days",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_realm_reactivation_link_query_result(result: HttpResponse) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">realm reactivation</span>\n',
|
||||
"<b>Link</b>: http://zulip.testserver/reactivate/",
|
||||
"<b>Expires in</b>: 1\xa0day",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
self.login("cordelia")
|
||||
|
||||
result = self.client_get("/activity/support")
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
self.login("iago")
|
||||
|
||||
do_set_realm_property(
|
||||
get_realm("zulip"),
|
||||
"email_address_visibility",
|
||||
Realm.EMAIL_ADDRESS_VISIBILITY_NOBODY,
|
||||
acting_user=None,
|
||||
)
|
||||
|
||||
customer = Customer.objects.create(realm=get_realm("lear"), stripe_customer_id="cus_123")
|
||||
now = datetime(2016, 1, 2, tzinfo=timezone.utc)
|
||||
plan = CustomerPlan.objects.create(
|
||||
customer=customer,
|
||||
billing_cycle_anchor=now,
|
||||
billing_schedule=CustomerPlan.ANNUAL,
|
||||
tier=CustomerPlan.STANDARD,
|
||||
price_per_license=8000,
|
||||
next_invoice_date=add_months(now, 12),
|
||||
)
|
||||
LicenseLedger.objects.create(
|
||||
licenses=10,
|
||||
licenses_at_next_renewal=10,
|
||||
event_time=timezone_now(),
|
||||
is_renewal=True,
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
result = self.client_get("/activity/support")
|
||||
self.assert_in_success_response(
|
||||
['<input type="text" name="q" class="input-xxlarge search-query"'], result
|
||||
)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": self.example_email("hamlet")})
|
||||
check_hamlet_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": self.example_email("polonius")})
|
||||
check_polonius_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "lear"})
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "http://lear.testserver"})
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
with self.settings(REALM_HOSTS={"zulip": "localhost"}):
|
||||
result = self.client_get("/activity/support", {"q": "http://localhost"})
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "hamlet@zulip.com, lear"})
|
||||
check_hamlet_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "King hamlet,lear"})
|
||||
check_hamlet_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "Othello, the Moor of Venice"})
|
||||
check_othello_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "lear, Hamlet <hamlet@zulip.com>"})
|
||||
check_hamlet_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
with mock.patch(
|
||||
"analytics.views.support.timezone_now",
|
||||
return_value=timezone_now() - timedelta(minutes=50),
|
||||
):
|
||||
self.client_post("/accounts/home/", {"email": self.nonreg_email("test")})
|
||||
self.login("iago")
|
||||
result = self.client_get("/activity/support", {"q": self.nonreg_email("test")})
|
||||
check_preregistration_user_query_result(result, self.nonreg_email("test"))
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
invite_expires_in_days = 10
|
||||
stream_ids = [self.get_stream_id("Denmark")]
|
||||
invitee_emails = [self.nonreg_email("test1")]
|
||||
self.client_post(
|
||||
"/json/invites",
|
||||
{
|
||||
"invitee_emails": invitee_emails,
|
||||
"stream_ids": orjson.dumps(stream_ids).decode(),
|
||||
"invite_expires_in_days": invite_expires_in_days,
|
||||
"invite_as": PreregistrationUser.INVITE_AS["MEMBER"],
|
||||
},
|
||||
)
|
||||
result = self.client_get("/activity/support", {"q": self.nonreg_email("test1")})
|
||||
check_preregistration_user_query_result(result, self.nonreg_email("test1"), invite=True)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
email = self.nonreg_email("alice")
|
||||
self.client_post("/new/", {"email": email})
|
||||
result = self.client_get("/activity/support", {"q": email})
|
||||
check_realm_creation_query_result(result, email)
|
||||
|
||||
do_create_multiuse_invite_link(
|
||||
self.example_user("hamlet"),
|
||||
invited_as=1,
|
||||
invite_expires_in_days=invite_expires_in_days,
|
||||
)
|
||||
result = self.client_get("/activity/support", {"q": "zulip"})
|
||||
check_multiuse_invite_link_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
MultiuseInvite.objects.all().delete()
|
||||
|
||||
do_send_realm_reactivation_email(get_realm("zulip"), acting_user=None)
|
||||
result = self.client_get("/activity/support", {"q": "zulip"})
|
||||
check_realm_reactivation_link_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
def test_get_org_type_display_name(self) -> None:
|
||||
self.assertEqual(get_org_type_display_name(Realm.ORG_TYPES["business"]["id"]), "Business")
|
||||
self.assertEqual(get_org_type_display_name(883), "")
|
||||
|
||||
@mock.patch("analytics.views.support.update_billing_method_of_current_plan")
|
||||
def test_change_billing_method(self, m: mock.Mock) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{cordelia.realm_id}", "plan_type": "2"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{"realm_id": f"{iago.realm_id}", "billing_method": "charge_automatically"},
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"), charge_automatically=True, acting_user=iago)
|
||||
self.assert_in_success_response(
|
||||
["Billing method of zulip updated to charge automatically"], result
|
||||
)
|
||||
|
||||
m.reset_mock()
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{iago.realm_id}", "billing_method": "send_invoice"}
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"), charge_automatically=False, acting_user=iago)
|
||||
self.assert_in_success_response(
|
||||
["Billing method of zulip updated to pay by invoice"], result
|
||||
)
|
||||
|
||||
def test_change_realm_plan_type(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{cordelia.realm_id}", "plan_type": "2"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
with mock.patch("analytics.views.support.do_change_realm_plan_type") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{iago.realm_id}", "plan_type": "2"}
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"), 2, acting_user=iago)
|
||||
self.assert_in_success_response(
|
||||
["Plan type of zulip changed from self-hosted to limited"], result
|
||||
)
|
||||
|
||||
with mock.patch("analytics.views.support.do_change_realm_plan_type") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{iago.realm_id}", "plan_type": "10"}
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"), 10, acting_user=iago)
|
||||
self.assert_in_success_response(
|
||||
["Plan type of zulip changed from self-hosted to plus"], result
|
||||
)
|
||||
|
||||
def test_change_org_type(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{cordelia.realm_id}", "org_type": "70"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
with mock.patch("analytics.views.support.do_change_realm_org_type") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{iago.realm_id}", "org_type": "70"}
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"), 70, acting_user=iago)
|
||||
self.assert_in_success_response(
|
||||
["Org type of zulip changed from Business to Government"], result
|
||||
)
|
||||
|
||||
def test_attach_discount(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
lear_realm = get_realm("lear")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login("iago")
|
||||
|
||||
with mock.patch("analytics.views.support.attach_discount_to_realm") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
|
||||
)
|
||||
m.assert_called_once_with(get_realm("lear"), 25, acting_user=iago)
|
||||
self.assert_in_success_response(["Discount of lear changed to 25% from 0%"], result)
|
||||
|
||||
def test_change_sponsorship_status(self) -> None:
|
||||
lear_realm = get_realm("lear")
|
||||
self.assertIsNone(get_customer_by_realm(lear_realm))
|
||||
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "sponsorship_pending": "true"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "sponsorship_pending": "true"}
|
||||
)
|
||||
self.assert_in_success_response(["lear marked as pending sponsorship."], result)
|
||||
customer = get_customer_by_realm(lear_realm)
|
||||
assert customer is not None
|
||||
self.assertTrue(customer.sponsorship_pending)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "sponsorship_pending": "false"}
|
||||
)
|
||||
self.assert_in_success_response(["lear is no longer pending sponsorship."], result)
|
||||
customer = get_customer_by_realm(lear_realm)
|
||||
assert customer is not None
|
||||
self.assertFalse(customer.sponsorship_pending)
|
||||
|
||||
def test_approve_sponsorship(self) -> None:
|
||||
lear_realm = get_realm("lear")
|
||||
update_sponsorship_status(lear_realm, True, acting_user=None)
|
||||
king_user = self.lear_user("king")
|
||||
king_user.role = UserProfile.ROLE_REALM_OWNER
|
||||
king_user.save()
|
||||
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{"realm_id": f"{lear_realm.id}", "approve_sponsorship": "true"},
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{"realm_id": f"{lear_realm.id}", "approve_sponsorship": "true"},
|
||||
)
|
||||
self.assert_in_success_response(["Sponsorship approved for lear"], result)
|
||||
lear_realm.refresh_from_db()
|
||||
self.assertEqual(lear_realm.plan_type, Realm.PLAN_TYPE_STANDARD_FREE)
|
||||
customer = get_customer_by_realm(lear_realm)
|
||||
assert customer is not None
|
||||
self.assertFalse(customer.sponsorship_pending)
|
||||
messages = UserMessage.objects.filter(user_profile=king_user)
|
||||
self.assertIn(
|
||||
"request for sponsored hosting has been approved", messages[0].message.content
|
||||
)
|
||||
self.assert_length(messages, 1)
|
||||
|
||||
def test_activate_or_deactivate_realm(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
lear_realm = get_realm("lear")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "deactivated"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
self.login("iago")
|
||||
|
||||
with mock.patch("analytics.views.support.do_deactivate_realm") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "deactivated"}
|
||||
)
|
||||
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
|
||||
self.assert_in_success_response(["lear deactivated"], result)
|
||||
|
||||
with mock.patch("analytics.views.support.do_send_realm_reactivation_email") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "active"}
|
||||
)
|
||||
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
|
||||
self.assert_in_success_response(
|
||||
["Realm reactivation email sent to admins of lear"], result
|
||||
)
|
||||
|
||||
def test_change_subdomain(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
lear_realm = get_realm("lear")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "new_name"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
self.login("iago")
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "new-name"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/activity/support?q=new-name")
|
||||
realm_id = lear_realm.id
|
||||
lear_realm = get_realm("new-name")
|
||||
self.assertEqual(lear_realm.id, realm_id)
|
||||
self.assertTrue(Realm.objects.filter(string_id="lear").exists())
|
||||
self.assertTrue(Realm.objects.filter(string_id="lear")[0].deactivated)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "new-name"}
|
||||
)
|
||||
self.assert_in_success_response(
|
||||
["Subdomain unavailable. Please choose a different one."], result
|
||||
)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "zulip"}
|
||||
)
|
||||
self.assert_in_success_response(
|
||||
["Subdomain unavailable. Please choose a different one."], result
|
||||
)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "lear"}
|
||||
)
|
||||
self.assert_in_success_response(
|
||||
["Subdomain unavailable. Please choose a different one."], result
|
||||
)
|
||||
|
||||
def test_downgrade_realm(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{cordelia.realm_id}", "plan_type": "2"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
with mock.patch("analytics.views.support.downgrade_at_the_end_of_billing_cycle") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{
|
||||
"realm_id": f"{iago.realm_id}",
|
||||
"downgrade_method": "downgrade_at_billing_cycle_end",
|
||||
},
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"))
|
||||
self.assert_in_success_response(
|
||||
["zulip marked for downgrade at the end of billing cycle"], result
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"analytics.views.support.downgrade_now_without_creating_additional_invoices"
|
||||
) as m:
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{
|
||||
"realm_id": f"{iago.realm_id}",
|
||||
"downgrade_method": "downgrade_now_without_additional_licenses",
|
||||
},
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"))
|
||||
self.assert_in_success_response(
|
||||
["zulip downgraded without creating additional invoices"], result
|
||||
)
|
||||
|
||||
with mock.patch(
|
||||
"analytics.views.support.downgrade_now_without_creating_additional_invoices"
|
||||
) as m1:
|
||||
with mock.patch("analytics.views.support.void_all_open_invoices", return_value=1) as m2:
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{
|
||||
"realm_id": f"{iago.realm_id}",
|
||||
"downgrade_method": "downgrade_now_void_open_invoices",
|
||||
},
|
||||
)
|
||||
m1.assert_called_once_with(get_realm("zulip"))
|
||||
m2.assert_called_once_with(get_realm("zulip"))
|
||||
self.assert_in_success_response(
|
||||
["zulip downgraded and voided 1 open invoices"], result
|
||||
)
|
||||
|
||||
def test_scrub_realm(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
lear_realm = get_realm("lear")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
self.login("iago")
|
||||
|
||||
with mock.patch("analytics.views.support.do_scrub_realm") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "scrub_realm": "true"}
|
||||
)
|
||||
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
|
||||
self.assert_in_success_response(["lear scrubbed"], result)
|
||||
|
||||
with mock.patch("analytics.views.support.do_scrub_realm") as m:
|
||||
result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}"})
|
||||
self.assert_json_error(result, "Invalid parameters")
|
||||
m.assert_not_called()
|
||||
@@ -1,15 +1,34 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import List, Optional
|
||||
from unittest import mock
|
||||
|
||||
import orjson
|
||||
from django.http import HttpResponse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
|
||||
from analytics.lib.counts import COUNT_STATS, CountStat
|
||||
from analytics.lib.time_utils import time_range
|
||||
from analytics.models import FillState, RealmCount, UserCount
|
||||
from analytics.views.stats import rewrite_client_arrays, sort_by_totals, sort_client_labels
|
||||
from analytics.views import rewrite_client_arrays, sort_by_totals, sort_client_labels
|
||||
from corporate.lib.stripe import add_months, update_sponsorship_status
|
||||
from corporate.models import Customer, CustomerPlan, LicenseLedger, get_customer_by_realm
|
||||
from zerver.lib.actions import (
|
||||
do_create_multiuse_invite_link,
|
||||
do_send_realm_reactivation_email,
|
||||
do_set_realm_property,
|
||||
)
|
||||
from zerver.lib.test_classes import ZulipTestCase
|
||||
from zerver.lib.test_helpers import reset_emails_in_zulip_realm
|
||||
from zerver.lib.timestamp import ceiling_to_day, ceiling_to_hour, datetime_to_timestamp
|
||||
from zerver.models import Client, get_realm
|
||||
from zerver.models import (
|
||||
Client,
|
||||
MultiuseInvite,
|
||||
PreregistrationUser,
|
||||
Realm,
|
||||
UserMessage,
|
||||
UserProfile,
|
||||
get_realm,
|
||||
)
|
||||
|
||||
|
||||
class TestStatsEndpoint(ZulipTestCase):
|
||||
@@ -207,11 +226,7 @@ class TestGetChartData(ZulipTestCase):
|
||||
client2 = Client.objects.create(name="client 2")
|
||||
client3 = Client.objects.create(name="client 3")
|
||||
client4 = Client.objects.create(name="client 4")
|
||||
self.insert_data(
|
||||
stat,
|
||||
[str(client4.id), str(client3.id), str(client2.id)],
|
||||
[str(client3.id), str(client1.id)],
|
||||
)
|
||||
self.insert_data(stat, [client4.id, client3.id, client2.id], [client3.id, client1.id])
|
||||
result = self.client_get(
|
||||
"/json/analytics/chart_data", {"chart_name": "messages_sent_by_client"}
|
||||
)
|
||||
@@ -557,6 +572,566 @@ class TestGetChartData(ZulipTestCase):
|
||||
self.assert_json_success(result)
|
||||
|
||||
|
||||
class TestSupportEndpoint(ZulipTestCase):
|
||||
def test_search(self) -> None:
|
||||
reset_emails_in_zulip_realm()
|
||||
|
||||
def assert_user_details_in_html_response(
|
||||
html_response: str, full_name: str, email: str, role: str
|
||||
) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">user</span>\n',
|
||||
f"<h3>{full_name}</h3>",
|
||||
f"<b>Email</b>: {email}",
|
||||
"<b>Is active</b>: True<br />",
|
||||
f"<b>Role</b>: {role}<br />",
|
||||
],
|
||||
html_response,
|
||||
)
|
||||
|
||||
def check_hamlet_user_query_result(result: HttpResponse) -> None:
|
||||
assert_user_details_in_html_response(
|
||||
result, "King Hamlet", self.example_email("hamlet"), "Member"
|
||||
)
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
f"<b>Admins</b>: {self.example_email('iago')}\n",
|
||||
f"<b>Owners</b>: {self.example_email('desdemona')}\n",
|
||||
'class="copy-button" data-copytext="{}">'.format(self.example_email("iago")),
|
||||
'class="copy-button" data-copytext="{}">'.format(
|
||||
self.example_email("desdemona")
|
||||
),
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_othello_user_query_result(result: HttpResponse) -> None:
|
||||
assert_user_details_in_html_response(
|
||||
result, "Othello, the Moor of Venice", self.example_email("othello"), "Member"
|
||||
)
|
||||
|
||||
def check_polonius_user_query_result(result: HttpResponse) -> None:
|
||||
assert_user_details_in_html_response(
|
||||
result, "Polonius", self.example_email("polonius"), "Guest"
|
||||
)
|
||||
|
||||
def check_zulip_realm_query_result(result: HttpResponse) -> None:
|
||||
zulip_realm = get_realm("zulip")
|
||||
first_human_user = zulip_realm.get_first_human_user()
|
||||
assert first_human_user is not None
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
f"<b>First human user</b>: {first_human_user.delivery_email}\n",
|
||||
f'<input type="hidden" name="realm_id" value="{zulip_realm.id}"',
|
||||
"Zulip Dev</h3>",
|
||||
'<option value="1" selected>Self hosted</option>',
|
||||
'<option value="2" >Limited</option>',
|
||||
'input type="number" name="discount" value="None"',
|
||||
'<option value="active" selected>Active</option>',
|
||||
'<option value="deactivated" >Deactivated</option>',
|
||||
'scrub-realm-button">',
|
||||
'data-string-id="zulip"',
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_lear_realm_query_result(result: HttpResponse) -> None:
|
||||
lear_realm = get_realm("lear")
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
f'<input type="hidden" name="realm_id" value="{lear_realm.id}"',
|
||||
"Lear & Co.</h3>",
|
||||
'<option value="1" selected>Self hosted</option>',
|
||||
'<option value="2" >Limited</option>',
|
||||
'input type="number" name="discount" value="None"',
|
||||
'<option value="active" selected>Active</option>',
|
||||
'<option value="deactivated" >Deactivated</option>',
|
||||
'scrub-realm-button">',
|
||||
'data-string-id="lear"',
|
||||
"<b>Name</b>: Zulip Standard",
|
||||
"<b>Status</b>: Active",
|
||||
"<b>Billing schedule</b>: Annual",
|
||||
"<b>Licenses</b>: 2/10 (Manual)",
|
||||
"<b>Price per license</b>: $80.0",
|
||||
"<b>Next invoice date</b>: 02 January 2017",
|
||||
'<option value="send_invoice" selected>',
|
||||
'<option value="charge_automatically" >',
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_preregistration_user_query_result(
|
||||
result: HttpResponse, email: str, invite: bool = False
|
||||
) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">preregistration user</span>\n',
|
||||
f"<b>Email</b>: {email}",
|
||||
],
|
||||
result,
|
||||
)
|
||||
if invite:
|
||||
self.assert_in_success_response(['<span class="label">invite</span>'], result)
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
"<b>Expires in</b>: 1\xa0week, 3\xa0days",
|
||||
"<b>Status</b>: Link has never been clicked",
|
||||
],
|
||||
result,
|
||||
)
|
||||
self.assert_in_success_response([], result)
|
||||
else:
|
||||
self.assert_not_in_success_response(['<span class="label">invite</span>'], result)
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
"<b>Expires in</b>: 1\xa0day",
|
||||
"<b>Status</b>: Link has never been clicked",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_realm_creation_query_result(result: HttpResponse, email: str) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">preregistration user</span>\n',
|
||||
'<span class="label">realm creation</span>\n',
|
||||
"<b>Link</b>: http://testserver/accounts/do_confirm/",
|
||||
"<b>Expires in</b>: 1\xa0day",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_multiuse_invite_link_query_result(result: HttpResponse) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">multiuse invite</span>\n',
|
||||
"<b>Link</b>: http://zulip.testserver/join/",
|
||||
"<b>Expires in</b>: 1\xa0week, 3\xa0days",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
def check_realm_reactivation_link_query_result(result: HttpResponse) -> None:
|
||||
self.assert_in_success_response(
|
||||
[
|
||||
'<span class="label">realm reactivation</span>\n',
|
||||
"<b>Link</b>: http://zulip.testserver/reactivate/",
|
||||
"<b>Expires in</b>: 1\xa0day",
|
||||
],
|
||||
result,
|
||||
)
|
||||
|
||||
self.login("cordelia")
|
||||
|
||||
result = self.client_get("/activity/support")
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
self.login("iago")
|
||||
|
||||
do_set_realm_property(
|
||||
get_realm("zulip"),
|
||||
"email_address_visibility",
|
||||
Realm.EMAIL_ADDRESS_VISIBILITY_NOBODY,
|
||||
acting_user=None,
|
||||
)
|
||||
|
||||
customer = Customer.objects.create(realm=get_realm("lear"), stripe_customer_id="cus_123")
|
||||
now = datetime(2016, 1, 2, tzinfo=timezone.utc)
|
||||
plan = CustomerPlan.objects.create(
|
||||
customer=customer,
|
||||
billing_cycle_anchor=now,
|
||||
billing_schedule=CustomerPlan.ANNUAL,
|
||||
tier=CustomerPlan.STANDARD,
|
||||
price_per_license=8000,
|
||||
next_invoice_date=add_months(now, 12),
|
||||
)
|
||||
LicenseLedger.objects.create(
|
||||
licenses=10,
|
||||
licenses_at_next_renewal=10,
|
||||
event_time=timezone_now(),
|
||||
is_renewal=True,
|
||||
plan=plan,
|
||||
)
|
||||
|
||||
result = self.client_get("/activity/support")
|
||||
self.assert_in_success_response(
|
||||
['<input type="text" name="q" class="input-xxlarge search-query"'], result
|
||||
)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": self.example_email("hamlet")})
|
||||
check_hamlet_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": self.example_email("polonius")})
|
||||
check_polonius_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "lear"})
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "http://lear.testserver"})
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
with self.settings(REALM_HOSTS={"zulip": "localhost"}):
|
||||
result = self.client_get("/activity/support", {"q": "http://localhost"})
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "hamlet@zulip.com, lear"})
|
||||
check_hamlet_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "King hamlet,lear"})
|
||||
check_hamlet_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "Othello, the Moor of Venice"})
|
||||
check_othello_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
result = self.client_get("/activity/support", {"q": "lear, Hamlet <hamlet@zulip.com>"})
|
||||
check_hamlet_user_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
check_lear_realm_query_result(result)
|
||||
|
||||
with mock.patch(
|
||||
"analytics.views.timezone_now", return_value=timezone_now() - timedelta(minutes=50)
|
||||
):
|
||||
self.client_post("/accounts/home/", {"email": self.nonreg_email("test")})
|
||||
self.login("iago")
|
||||
result = self.client_get("/activity/support", {"q": self.nonreg_email("test")})
|
||||
check_preregistration_user_query_result(result, self.nonreg_email("test"))
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
stream_ids = [self.get_stream_id("Denmark")]
|
||||
invitee_emails = [self.nonreg_email("test1")]
|
||||
self.client_post(
|
||||
"/json/invites",
|
||||
{
|
||||
"invitee_emails": invitee_emails,
|
||||
"stream_ids": orjson.dumps(stream_ids).decode(),
|
||||
"invite_as": PreregistrationUser.INVITE_AS["MEMBER"],
|
||||
},
|
||||
)
|
||||
result = self.client_get("/activity/support", {"q": self.nonreg_email("test1")})
|
||||
check_preregistration_user_query_result(result, self.nonreg_email("test1"), invite=True)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
email = self.nonreg_email("alice")
|
||||
self.client_post("/new/", {"email": email})
|
||||
result = self.client_get("/activity/support", {"q": email})
|
||||
check_realm_creation_query_result(result, email)
|
||||
|
||||
do_create_multiuse_invite_link(self.example_user("hamlet"), invited_as=1)
|
||||
result = self.client_get("/activity/support", {"q": "zulip"})
|
||||
check_multiuse_invite_link_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
MultiuseInvite.objects.all().delete()
|
||||
|
||||
do_send_realm_reactivation_email(get_realm("zulip"), acting_user=None)
|
||||
result = self.client_get("/activity/support", {"q": "zulip"})
|
||||
check_realm_reactivation_link_query_result(result)
|
||||
check_zulip_realm_query_result(result)
|
||||
|
||||
@mock.patch("analytics.views.update_billing_method_of_current_plan")
|
||||
def test_change_billing_method(self, m: mock.Mock) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{cordelia.realm_id}", "plan_type": "2"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{"realm_id": f"{iago.realm_id}", "billing_method": "charge_automatically"},
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"), charge_automatically=True, acting_user=iago)
|
||||
self.assert_in_success_response(
|
||||
["Billing method of zulip updated to charge automatically"], result
|
||||
)
|
||||
|
||||
m.reset_mock()
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{iago.realm_id}", "billing_method": "send_invoice"}
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"), charge_automatically=False, acting_user=iago)
|
||||
self.assert_in_success_response(
|
||||
["Billing method of zulip updated to pay by invoice"], result
|
||||
)
|
||||
|
||||
def test_change_plan_type(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{cordelia.realm_id}", "plan_type": "2"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
with mock.patch("analytics.views.do_change_plan_type") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{iago.realm_id}", "plan_type": "2"}
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"), 2, acting_user=iago)
|
||||
self.assert_in_success_response(
|
||||
["Plan type of zulip changed from self hosted to limited"], result
|
||||
)
|
||||
|
||||
def test_attach_discount(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
lear_realm = get_realm("lear")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login("iago")
|
||||
|
||||
with mock.patch("analytics.views.attach_discount_to_realm") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
|
||||
)
|
||||
m.assert_called_once_with(get_realm("lear"), 25, acting_user=iago)
|
||||
self.assert_in_success_response(["Discount of lear changed to 25% from 0%"], result)
|
||||
|
||||
def test_change_sponsorship_status(self) -> None:
|
||||
lear_realm = get_realm("lear")
|
||||
self.assertIsNone(get_customer_by_realm(lear_realm))
|
||||
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "sponsorship_pending": "true"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "sponsorship_pending": "true"}
|
||||
)
|
||||
self.assert_in_success_response(["lear marked as pending sponsorship."], result)
|
||||
customer = get_customer_by_realm(lear_realm)
|
||||
assert customer is not None
|
||||
self.assertTrue(customer.sponsorship_pending)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "sponsorship_pending": "false"}
|
||||
)
|
||||
self.assert_in_success_response(["lear is no longer pending sponsorship."], result)
|
||||
customer = get_customer_by_realm(lear_realm)
|
||||
assert customer is not None
|
||||
self.assertFalse(customer.sponsorship_pending)
|
||||
|
||||
def test_approve_sponsorship(self) -> None:
|
||||
lear_realm = get_realm("lear")
|
||||
update_sponsorship_status(lear_realm, True, acting_user=None)
|
||||
king_user = self.lear_user("king")
|
||||
king_user.role = UserProfile.ROLE_REALM_OWNER
|
||||
king_user.save()
|
||||
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{"realm_id": f"{lear_realm.id}", "approve_sponsorship": "approve_sponsorship"},
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{"realm_id": f"{lear_realm.id}", "approve_sponsorship": "approve_sponsorship"},
|
||||
)
|
||||
self.assert_in_success_response(["Sponsorship approved for lear"], result)
|
||||
lear_realm.refresh_from_db()
|
||||
self.assertEqual(lear_realm.plan_type, Realm.STANDARD_FREE)
|
||||
customer = get_customer_by_realm(lear_realm)
|
||||
assert customer is not None
|
||||
self.assertFalse(customer.sponsorship_pending)
|
||||
messages = UserMessage.objects.filter(user_profile=king_user)
|
||||
self.assertIn(
|
||||
"request for sponsored hosting has been approved", messages[0].message.content
|
||||
)
|
||||
self.assertEqual(len(messages), 1)
|
||||
|
||||
def test_activate_or_deactivate_realm(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
lear_realm = get_realm("lear")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "deactivated"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
self.login("iago")
|
||||
|
||||
with mock.patch("analytics.views.do_deactivate_realm") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "deactivated"}
|
||||
)
|
||||
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
|
||||
self.assert_in_success_response(["lear deactivated"], result)
|
||||
|
||||
with mock.patch("analytics.views.do_send_realm_reactivation_email") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "status": "active"}
|
||||
)
|
||||
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
|
||||
self.assert_in_success_response(
|
||||
["Realm reactivation email sent to admins of lear"], result
|
||||
)
|
||||
|
||||
def test_change_subdomain(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
lear_realm = get_realm("lear")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "new_name"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
self.login("iago")
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "new-name"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/activity/support?q=new-name")
|
||||
realm_id = lear_realm.id
|
||||
lear_realm = get_realm("new-name")
|
||||
self.assertEqual(lear_realm.id, realm_id)
|
||||
self.assertTrue(Realm.objects.filter(string_id="lear").exists())
|
||||
self.assertTrue(Realm.objects.filter(string_id="lear")[0].deactivated)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "new-name"}
|
||||
)
|
||||
self.assert_in_success_response(
|
||||
["Subdomain unavailable. Please choose a different one."], result
|
||||
)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "zulip"}
|
||||
)
|
||||
self.assert_in_success_response(
|
||||
["Subdomain unavailable. Please choose a different one."], result
|
||||
)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "new_subdomain": "lear"}
|
||||
)
|
||||
self.assert_in_success_response(
|
||||
["Subdomain unavailable. Please choose a different one."], result
|
||||
)
|
||||
|
||||
def test_downgrade_realm(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
self.login_user(cordelia)
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{cordelia.realm_id}", "plan_type": "2"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
iago = self.example_user("iago")
|
||||
self.login_user(iago)
|
||||
|
||||
with mock.patch("analytics.views.downgrade_at_the_end_of_billing_cycle") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{
|
||||
"realm_id": f"{iago.realm_id}",
|
||||
"downgrade_method": "downgrade_at_billing_cycle_end",
|
||||
},
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"))
|
||||
self.assert_in_success_response(
|
||||
["zulip marked for downgrade at the end of billing cycle"], result
|
||||
)
|
||||
|
||||
with mock.patch("analytics.views.downgrade_now_without_creating_additional_invoices") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{
|
||||
"realm_id": f"{iago.realm_id}",
|
||||
"downgrade_method": "downgrade_now_without_additional_licenses",
|
||||
},
|
||||
)
|
||||
m.assert_called_once_with(get_realm("zulip"))
|
||||
self.assert_in_success_response(
|
||||
["zulip downgraded without creating additional invoices"], result
|
||||
)
|
||||
|
||||
with mock.patch("analytics.views.downgrade_now_without_creating_additional_invoices") as m1:
|
||||
with mock.patch("analytics.views.void_all_open_invoices", return_value=1) as m2:
|
||||
result = self.client_post(
|
||||
"/activity/support",
|
||||
{
|
||||
"realm_id": f"{iago.realm_id}",
|
||||
"downgrade_method": "downgrade_now_void_open_invoices",
|
||||
},
|
||||
)
|
||||
m1.assert_called_once_with(get_realm("zulip"))
|
||||
m2.assert_called_once_with(get_realm("zulip"))
|
||||
self.assert_in_success_response(
|
||||
["zulip downgraded and voided 1 open invoices"], result
|
||||
)
|
||||
|
||||
def test_scrub_realm(self) -> None:
|
||||
cordelia = self.example_user("cordelia")
|
||||
lear_realm = get_realm("lear")
|
||||
self.login_user(cordelia)
|
||||
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "discount": "25"}
|
||||
)
|
||||
self.assertEqual(result.status_code, 302)
|
||||
self.assertEqual(result["Location"], "/login/")
|
||||
|
||||
self.login("iago")
|
||||
|
||||
with mock.patch("analytics.views.do_scrub_realm") as m:
|
||||
result = self.client_post(
|
||||
"/activity/support", {"realm_id": f"{lear_realm.id}", "scrub_realm": "scrub_realm"}
|
||||
)
|
||||
m.assert_called_once_with(lear_realm, acting_user=self.example_user("iago"))
|
||||
self.assert_in_success_response(["lear scrubbed"], result)
|
||||
|
||||
with mock.patch("analytics.views.do_scrub_realm") as m:
|
||||
result = self.client_post("/activity/support", {"realm_id": f"{lear_realm.id}"})
|
||||
self.assert_json_error(result, "Invalid parameters")
|
||||
m.assert_not_called()
|
||||
|
||||
|
||||
class TestGetChartDataHelpers(ZulipTestCase):
|
||||
def test_sort_by_totals(self) -> None:
|
||||
empty: List[int] = []
|
||||
@@ -1,33 +1,30 @@
|
||||
from typing import List, Union
|
||||
|
||||
from django.conf.urls import include
|
||||
from django.urls import path
|
||||
from django.urls.resolvers import URLPattern, URLResolver
|
||||
|
||||
from analytics.views.installation_activity import get_installation_activity
|
||||
from analytics.views.realm_activity import get_realm_activity
|
||||
from analytics.views.stats import (
|
||||
from analytics.views import (
|
||||
get_activity,
|
||||
get_chart_data,
|
||||
get_chart_data_for_installation,
|
||||
get_chart_data_for_realm,
|
||||
get_chart_data_for_remote_installation,
|
||||
get_chart_data_for_remote_realm,
|
||||
get_realm_activity,
|
||||
get_user_activity,
|
||||
stats,
|
||||
stats_for_installation,
|
||||
stats_for_realm,
|
||||
stats_for_remote_installation,
|
||||
stats_for_remote_realm,
|
||||
support,
|
||||
)
|
||||
from analytics.views.support import support
|
||||
from analytics.views.user_activity import get_user_activity
|
||||
from zerver.lib.rest import rest_path
|
||||
|
||||
i18n_urlpatterns: List[Union[URLPattern, URLResolver]] = [
|
||||
i18n_urlpatterns = [
|
||||
# Server admin (user_profile.is_staff) visible stats pages
|
||||
path("activity", get_installation_activity),
|
||||
path("activity", get_activity),
|
||||
path("activity/support", support, name="support"),
|
||||
path("realm_activity/<realm_str>/", get_realm_activity),
|
||||
path("user_activity/<user_profile_id>/", get_user_activity),
|
||||
path("user_activity/<email>/", get_user_activity),
|
||||
path("stats/realm/<realm_str>/", stats_for_realm),
|
||||
path("stats/installation", stats_for_installation),
|
||||
path("stats/remote/<int:remote_server_id>/installation", stats_for_remote_installation),
|
||||
|
||||
1791
analytics/views.py
Normal file
1791
analytics/views.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,137 +0,0 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
import pytz
|
||||
from django.conf import settings
|
||||
from django.db.backends.utils import CursorWrapper
|
||||
from django.db.models.query import QuerySet
|
||||
from django.template import loader
|
||||
from django.urls import reverse
|
||||
from markupsafe import Markup as mark_safe
|
||||
|
||||
eastern_tz = pytz.timezone("US/Eastern")
|
||||
|
||||
|
||||
if settings.BILLING_ENABLED:
|
||||
pass
|
||||
|
||||
|
||||
def make_table(
|
||||
title: str, cols: Sequence[str], rows: Sequence[Any], has_row_class: bool = False
|
||||
) -> str:
|
||||
|
||||
if not has_row_class:
|
||||
|
||||
def fix_row(row: Any) -> Dict[str, Any]:
|
||||
return dict(cells=row, row_class=None)
|
||||
|
||||
rows = list(map(fix_row, rows))
|
||||
|
||||
data = dict(title=title, cols=cols, rows=rows)
|
||||
|
||||
content = loader.render_to_string(
|
||||
"analytics/ad_hoc_query.html",
|
||||
dict(data=data),
|
||||
)
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def dictfetchall(cursor: CursorWrapper) -> List[Dict[str, Any]]:
|
||||
"Returns all rows from a cursor as a dict"
|
||||
desc = cursor.description
|
||||
return [dict(zip((col[0] for col in desc), row)) for row in cursor.fetchall()]
|
||||
|
||||
|
||||
def format_date_for_activity_reports(date: Optional[datetime]) -> str:
|
||||
if date:
|
||||
return date.astimezone(eastern_tz).strftime("%Y-%m-%d %H:%M")
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
def user_activity_link(email: str, user_profile_id: int) -> mark_safe:
|
||||
from analytics.views.user_activity import get_user_activity
|
||||
|
||||
url = reverse(get_user_activity, kwargs=dict(user_profile_id=user_profile_id))
|
||||
email_link = f'<a href="{escape(url)}">{escape(email)}</a>'
|
||||
return mark_safe(email_link)
|
||||
|
||||
|
||||
def realm_activity_link(realm_str: str) -> mark_safe:
|
||||
from analytics.views.realm_activity import get_realm_activity
|
||||
|
||||
url = reverse(get_realm_activity, kwargs=dict(realm_str=realm_str))
|
||||
realm_link = f'<a href="{escape(url)}">{escape(realm_str)}</a>'
|
||||
return mark_safe(realm_link)
|
||||
|
||||
|
||||
def realm_stats_link(realm_str: str) -> mark_safe:
|
||||
from analytics.views.stats import stats_for_realm
|
||||
|
||||
url = reverse(stats_for_realm, kwargs=dict(realm_str=realm_str))
|
||||
stats_link = f'<a href="{escape(url)}"><i class="fa fa-pie-chart"></i>{escape(realm_str)}</a>'
|
||||
return mark_safe(stats_link)
|
||||
|
||||
|
||||
def remote_installation_stats_link(server_id: int, hostname: str) -> mark_safe:
|
||||
from analytics.views.stats import stats_for_remote_installation
|
||||
|
||||
url = reverse(stats_for_remote_installation, kwargs=dict(remote_server_id=server_id))
|
||||
stats_link = f'<a href="{escape(url)}"><i class="fa fa-pie-chart"></i>{escape(hostname)}</a>'
|
||||
return mark_safe(stats_link)
|
||||
|
||||
|
||||
def get_user_activity_summary(records: List[QuerySet]) -> Dict[str, Any]:
|
||||
#: The type annotation used above is clearly overly permissive.
|
||||
#: We should perhaps use TypedDict to clearly lay out the schema
|
||||
#: for the user activity summary.
|
||||
summary: Dict[str, Any] = {}
|
||||
|
||||
def update(action: str, record: QuerySet) -> None:
|
||||
if action not in summary:
|
||||
summary[action] = dict(
|
||||
count=record.count,
|
||||
last_visit=record.last_visit,
|
||||
)
|
||||
else:
|
||||
summary[action]["count"] += record.count
|
||||
summary[action]["last_visit"] = max(
|
||||
summary[action]["last_visit"],
|
||||
record.last_visit,
|
||||
)
|
||||
|
||||
if records:
|
||||
summary["name"] = records[0].user_profile.full_name
|
||||
summary["user_profile_id"] = records[0].user_profile.id
|
||||
|
||||
for record in records:
|
||||
client = record.client.name
|
||||
query = str(record.query)
|
||||
|
||||
update("use", record)
|
||||
|
||||
if client == "API":
|
||||
m = re.match("/api/.*/external/(.*)", query)
|
||||
if m:
|
||||
client = m.group(1)
|
||||
update(client, record)
|
||||
|
||||
if client.startswith("desktop"):
|
||||
update("desktop", record)
|
||||
if client == "website":
|
||||
update("website", record)
|
||||
if ("send_message" in query) or re.search("/api/.*/external/.*", query):
|
||||
update("send", record)
|
||||
if query in [
|
||||
"/json/update_pointer",
|
||||
"/json/users/me/pointer",
|
||||
"/api/v1/update_pointer",
|
||||
"update_pointer_backend",
|
||||
]:
|
||||
update("pointer", record)
|
||||
update(client, record)
|
||||
|
||||
return summary
|
||||
@@ -1,622 +0,0 @@
|
||||
import itertools
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Callable, Dict, List, Optional, Sequence, Tuple, Union
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import connection
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.template import loader
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from markupsafe import Markup as mark_safe
|
||||
from psycopg2.sql import SQL, Composable, Literal
|
||||
|
||||
from analytics.lib.counts import COUNT_STATS
|
||||
from analytics.views.activity_common import (
|
||||
dictfetchall,
|
||||
format_date_for_activity_reports,
|
||||
make_table,
|
||||
realm_activity_link,
|
||||
realm_stats_link,
|
||||
remote_installation_stats_link,
|
||||
)
|
||||
from analytics.views.support import get_plan_name
|
||||
from zerver.decorator import require_server_admin
|
||||
from zerver.lib.request import has_request_variables
|
||||
from zerver.lib.timestamp import timestamp_to_datetime
|
||||
from zerver.models import Realm, UserActivityInterval, UserProfile, get_org_type_display_name
|
||||
|
||||
if settings.BILLING_ENABLED:
|
||||
from corporate.lib.stripe import (
|
||||
estimate_annual_recurring_revenue_by_realm,
|
||||
get_realms_to_default_discount_dict,
|
||||
)
|
||||
|
||||
|
||||
def get_realm_day_counts() -> Dict[str, Dict[str, str]]:
|
||||
query = SQL(
|
||||
"""
|
||||
select
|
||||
r.string_id,
|
||||
(now()::date - date_sent::date) age,
|
||||
count(*) cnt
|
||||
from zerver_message m
|
||||
join zerver_userprofile up on up.id = m.sender_id
|
||||
join zerver_realm r on r.id = up.realm_id
|
||||
join zerver_client c on c.id = m.sending_client_id
|
||||
where
|
||||
(not up.is_bot)
|
||||
and
|
||||
date_sent > now()::date - interval '8 day'
|
||||
and
|
||||
c.name not in ('zephyr_mirror', 'ZulipMonitoring')
|
||||
group by
|
||||
r.string_id,
|
||||
age
|
||||
order by
|
||||
r.string_id,
|
||||
age
|
||||
"""
|
||||
)
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(query)
|
||||
rows = dictfetchall(cursor)
|
||||
cursor.close()
|
||||
|
||||
counts: Dict[str, Dict[int, int]] = defaultdict(dict)
|
||||
for row in rows:
|
||||
counts[row["string_id"]][row["age"]] = row["cnt"]
|
||||
|
||||
result = {}
|
||||
for string_id in counts:
|
||||
raw_cnts = [counts[string_id].get(age, 0) for age in range(8)]
|
||||
min_cnt = min(raw_cnts[1:])
|
||||
max_cnt = max(raw_cnts[1:])
|
||||
|
||||
def format_count(cnt: int, style: Optional[str] = None) -> str:
|
||||
if style is not None:
|
||||
good_bad = style
|
||||
elif cnt == min_cnt:
|
||||
good_bad = "bad"
|
||||
elif cnt == max_cnt:
|
||||
good_bad = "good"
|
||||
else:
|
||||
good_bad = "neutral"
|
||||
|
||||
return f'<td class="number {good_bad}">{cnt}</td>'
|
||||
|
||||
cnts = format_count(raw_cnts[0], "neutral") + "".join(map(format_count, raw_cnts[1:]))
|
||||
result[string_id] = dict(cnts=cnts)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def realm_summary_table(realm_minutes: Dict[str, float]) -> str:
|
||||
now = timezone_now()
|
||||
|
||||
query = SQL(
|
||||
"""
|
||||
SELECT
|
||||
realm.string_id,
|
||||
realm.date_created,
|
||||
realm.plan_type,
|
||||
realm.org_type,
|
||||
coalesce(wau_table.value, 0) wau_count,
|
||||
coalesce(dau_table.value, 0) dau_count,
|
||||
coalesce(user_count_table.value, 0) user_profile_count,
|
||||
coalesce(bot_count_table.value, 0) bot_count
|
||||
FROM
|
||||
zerver_realm as realm
|
||||
LEFT OUTER JOIN (
|
||||
SELECT
|
||||
value _14day_active_humans,
|
||||
realm_id
|
||||
from
|
||||
analytics_realmcount
|
||||
WHERE
|
||||
property = 'realm_active_humans::day'
|
||||
AND end_time = %(realm_active_humans_end_time)s
|
||||
) as _14day_active_humans_table ON realm.id = _14day_active_humans_table.realm_id
|
||||
LEFT OUTER JOIN (
|
||||
SELECT
|
||||
value,
|
||||
realm_id
|
||||
from
|
||||
analytics_realmcount
|
||||
WHERE
|
||||
property = '7day_actives::day'
|
||||
AND end_time = %(seven_day_actives_end_time)s
|
||||
) as wau_table ON realm.id = wau_table.realm_id
|
||||
LEFT OUTER JOIN (
|
||||
SELECT
|
||||
value,
|
||||
realm_id
|
||||
from
|
||||
analytics_realmcount
|
||||
WHERE
|
||||
property = '1day_actives::day'
|
||||
AND end_time = %(one_day_actives_end_time)s
|
||||
) as dau_table ON realm.id = dau_table.realm_id
|
||||
LEFT OUTER JOIN (
|
||||
SELECT
|
||||
value,
|
||||
realm_id
|
||||
from
|
||||
analytics_realmcount
|
||||
WHERE
|
||||
property = 'active_users_audit:is_bot:day'
|
||||
AND subgroup = 'false'
|
||||
AND end_time = %(active_users_audit_end_time)s
|
||||
) as user_count_table ON realm.id = user_count_table.realm_id
|
||||
LEFT OUTER JOIN (
|
||||
SELECT
|
||||
value,
|
||||
realm_id
|
||||
from
|
||||
analytics_realmcount
|
||||
WHERE
|
||||
property = 'active_users_audit:is_bot:day'
|
||||
AND subgroup = 'true'
|
||||
AND end_time = %(active_users_audit_end_time)s
|
||||
) as bot_count_table ON realm.id = bot_count_table.realm_id
|
||||
WHERE
|
||||
_14day_active_humans IS NOT NULL
|
||||
or realm.plan_type = 3
|
||||
ORDER BY
|
||||
dau_count DESC,
|
||||
string_id ASC
|
||||
"""
|
||||
)
|
||||
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(
|
||||
query,
|
||||
{
|
||||
"realm_active_humans_end_time": COUNT_STATS[
|
||||
"realm_active_humans::day"
|
||||
].last_successful_fill(),
|
||||
"seven_day_actives_end_time": COUNT_STATS["7day_actives::day"].last_successful_fill(),
|
||||
"one_day_actives_end_time": COUNT_STATS["1day_actives::day"].last_successful_fill(),
|
||||
"active_users_audit_end_time": COUNT_STATS[
|
||||
"active_users_audit:is_bot:day"
|
||||
].last_successful_fill(),
|
||||
},
|
||||
)
|
||||
rows = dictfetchall(cursor)
|
||||
cursor.close()
|
||||
|
||||
# Fetch all the realm administrator users
|
||||
realm_owners: Dict[str, List[str]] = defaultdict(list)
|
||||
for up in UserProfile.objects.select_related("realm").filter(
|
||||
role=UserProfile.ROLE_REALM_OWNER,
|
||||
is_active=True,
|
||||
):
|
||||
realm_owners[up.realm.string_id].append(up.delivery_email)
|
||||
|
||||
for row in rows:
|
||||
row["date_created_day"] = row["date_created"].strftime("%Y-%m-%d")
|
||||
row["age_days"] = int((now - row["date_created"]).total_seconds() / 86400)
|
||||
row["is_new"] = row["age_days"] < 12 * 7
|
||||
row["realm_owner_emails"] = ", ".join(realm_owners[row["string_id"]])
|
||||
|
||||
# get messages sent per day
|
||||
counts = get_realm_day_counts()
|
||||
for row in rows:
|
||||
try:
|
||||
row["history"] = counts[row["string_id"]]["cnts"]
|
||||
except Exception:
|
||||
row["history"] = ""
|
||||
|
||||
# estimate annual subscription revenue
|
||||
total_arr = 0
|
||||
if settings.BILLING_ENABLED:
|
||||
estimated_arrs = estimate_annual_recurring_revenue_by_realm()
|
||||
realms_to_default_discount = get_realms_to_default_discount_dict()
|
||||
|
||||
for row in rows:
|
||||
row["plan_type_string"] = get_plan_name(row["plan_type"])
|
||||
|
||||
string_id = row["string_id"]
|
||||
|
||||
if string_id in estimated_arrs:
|
||||
row["arr"] = estimated_arrs[string_id]
|
||||
|
||||
if row["plan_type"] in [Realm.PLAN_TYPE_STANDARD, Realm.PLAN_TYPE_PLUS]:
|
||||
row["effective_rate"] = 100 - int(realms_to_default_discount.get(string_id, 0))
|
||||
elif row["plan_type"] == Realm.PLAN_TYPE_STANDARD_FREE:
|
||||
row["effective_rate"] = 0
|
||||
elif (
|
||||
row["plan_type"] == Realm.PLAN_TYPE_LIMITED
|
||||
and string_id in realms_to_default_discount
|
||||
):
|
||||
row["effective_rate"] = 100 - int(realms_to_default_discount[string_id])
|
||||
else:
|
||||
row["effective_rate"] = ""
|
||||
|
||||
total_arr += sum(estimated_arrs.values())
|
||||
|
||||
for row in rows:
|
||||
row["org_type_string"] = get_org_type_display_name(row["org_type"])
|
||||
|
||||
# augment data with realm_minutes
|
||||
total_hours = 0.0
|
||||
for row in rows:
|
||||
string_id = row["string_id"]
|
||||
minutes = realm_minutes.get(string_id, 0.0)
|
||||
hours = minutes / 60.0
|
||||
total_hours += hours
|
||||
row["hours"] = str(int(hours))
|
||||
try:
|
||||
row["hours_per_user"] = "{:.1f}".format(hours / row["dau_count"])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# formatting
|
||||
for row in rows:
|
||||
row["stats_link"] = realm_stats_link(row["string_id"])
|
||||
row["string_id"] = realm_activity_link(row["string_id"])
|
||||
|
||||
# Count active sites
|
||||
def meets_goal(row: Dict[str, int]) -> bool:
|
||||
return row["dau_count"] >= 5
|
||||
|
||||
num_active_sites = len(list(filter(meets_goal, rows)))
|
||||
|
||||
# create totals
|
||||
total_dau_count = 0
|
||||
total_user_profile_count = 0
|
||||
total_bot_count = 0
|
||||
total_wau_count = 0
|
||||
for row in rows:
|
||||
total_dau_count += int(row["dau_count"])
|
||||
total_user_profile_count += int(row["user_profile_count"])
|
||||
total_bot_count += int(row["bot_count"])
|
||||
total_wau_count += int(row["wau_count"])
|
||||
|
||||
total_row = dict(
|
||||
string_id="Total",
|
||||
plan_type_string="",
|
||||
org_type_string="",
|
||||
effective_rate="",
|
||||
arr=total_arr,
|
||||
stats_link="",
|
||||
date_created_day="",
|
||||
realm_owner_emails="",
|
||||
dau_count=total_dau_count,
|
||||
user_profile_count=total_user_profile_count,
|
||||
bot_count=total_bot_count,
|
||||
hours=int(total_hours),
|
||||
wau_count=total_wau_count,
|
||||
)
|
||||
|
||||
rows.insert(0, total_row)
|
||||
|
||||
content = loader.render_to_string(
|
||||
"analytics/realm_summary_table.html",
|
||||
dict(
|
||||
rows=rows,
|
||||
num_active_sites=num_active_sites,
|
||||
utctime=now.strftime("%Y-%m-%d %H:%MZ"),
|
||||
billing_enabled=settings.BILLING_ENABLED,
|
||||
),
|
||||
)
|
||||
return content
|
||||
|
||||
|
||||
def user_activity_intervals() -> Tuple[mark_safe, Dict[str, float]]:
|
||||
day_end = timestamp_to_datetime(time.time())
|
||||
day_start = day_end - timedelta(hours=24)
|
||||
|
||||
output = "Per-user online duration for the last 24 hours:\n"
|
||||
total_duration = timedelta(0)
|
||||
|
||||
all_intervals = (
|
||||
UserActivityInterval.objects.filter(
|
||||
end__gte=day_start,
|
||||
start__lte=day_end,
|
||||
)
|
||||
.select_related(
|
||||
"user_profile",
|
||||
"user_profile__realm",
|
||||
)
|
||||
.only(
|
||||
"start",
|
||||
"end",
|
||||
"user_profile__delivery_email",
|
||||
"user_profile__realm__string_id",
|
||||
)
|
||||
.order_by(
|
||||
"user_profile__realm__string_id",
|
||||
"user_profile__delivery_email",
|
||||
)
|
||||
)
|
||||
|
||||
by_string_id = lambda row: row.user_profile.realm.string_id
|
||||
by_email = lambda row: row.user_profile.delivery_email
|
||||
|
||||
realm_minutes = {}
|
||||
|
||||
for string_id, realm_intervals in itertools.groupby(all_intervals, by_string_id):
|
||||
realm_duration = timedelta(0)
|
||||
output += f"<hr>{string_id}\n"
|
||||
for email, intervals in itertools.groupby(realm_intervals, by_email):
|
||||
duration = timedelta(0)
|
||||
for interval in intervals:
|
||||
start = max(day_start, interval.start)
|
||||
end = min(day_end, interval.end)
|
||||
duration += end - start
|
||||
|
||||
total_duration += duration
|
||||
realm_duration += duration
|
||||
output += f" {email:<37}{duration}\n"
|
||||
|
||||
realm_minutes[string_id] = realm_duration.total_seconds() / 60
|
||||
|
||||
output += f"\nTotal duration: {total_duration}\n"
|
||||
output += f"\nTotal duration in minutes: {total_duration.total_seconds() / 60.}\n"
|
||||
output += f"Total duration amortized to a month: {total_duration.total_seconds() * 30. / 60.}"
|
||||
content = mark_safe("<pre>" + output + "</pre>")
|
||||
return content, realm_minutes
|
||||
|
||||
|
||||
def ad_hoc_queries() -> List[Dict[str, str]]:
|
||||
def get_page(
|
||||
query: Composable, cols: Sequence[str], title: str, totals_columns: Sequence[int] = []
|
||||
) -> Dict[str, str]:
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(query)
|
||||
rows = cursor.fetchall()
|
||||
rows = list(map(list, rows))
|
||||
cursor.close()
|
||||
|
||||
def fix_rows(
|
||||
i: int, fixup_func: Union[Callable[[str], mark_safe], Callable[[datetime], str]]
|
||||
) -> None:
|
||||
for row in rows:
|
||||
row[i] = fixup_func(row[i])
|
||||
|
||||
total_row = []
|
||||
for i, col in enumerate(cols):
|
||||
if col == "Realm":
|
||||
fix_rows(i, realm_activity_link)
|
||||
elif col in ["Last time", "Last visit"]:
|
||||
fix_rows(i, format_date_for_activity_reports)
|
||||
elif col == "Hostname":
|
||||
for row in rows:
|
||||
row[i] = remote_installation_stats_link(row[0], row[i])
|
||||
if len(totals_columns) > 0:
|
||||
if i == 0:
|
||||
total_row.append("Total")
|
||||
elif i in totals_columns:
|
||||
total_row.append(str(sum(row[i] for row in rows if row[i] is not None)))
|
||||
else:
|
||||
total_row.append("")
|
||||
if len(totals_columns) > 0:
|
||||
rows.insert(0, total_row)
|
||||
|
||||
content = make_table(title, cols, rows)
|
||||
|
||||
return dict(
|
||||
content=content,
|
||||
title=title,
|
||||
)
|
||||
|
||||
pages = []
|
||||
|
||||
###
|
||||
|
||||
for mobile_type in ["Android", "ZulipiOS"]:
|
||||
title = f"{mobile_type} usage"
|
||||
|
||||
query: Composable = SQL(
|
||||
"""
|
||||
select
|
||||
realm.string_id,
|
||||
up.id user_id,
|
||||
client.name,
|
||||
sum(count) as hits,
|
||||
max(last_visit) as last_time
|
||||
from zerver_useractivity ua
|
||||
join zerver_client client on client.id = ua.client_id
|
||||
join zerver_userprofile up on up.id = ua.user_profile_id
|
||||
join zerver_realm realm on realm.id = up.realm_id
|
||||
where
|
||||
client.name like {mobile_type}
|
||||
group by string_id, up.id, client.name
|
||||
having max(last_visit) > now() - interval '2 week'
|
||||
order by string_id, up.id, client.name
|
||||
"""
|
||||
).format(
|
||||
mobile_type=Literal(mobile_type),
|
||||
)
|
||||
|
||||
cols = [
|
||||
"Realm",
|
||||
"User id",
|
||||
"Name",
|
||||
"Hits",
|
||||
"Last time",
|
||||
]
|
||||
|
||||
pages.append(get_page(query, cols, title))
|
||||
|
||||
###
|
||||
|
||||
title = "Desktop users"
|
||||
|
||||
query = SQL(
|
||||
"""
|
||||
select
|
||||
realm.string_id,
|
||||
client.name,
|
||||
sum(count) as hits,
|
||||
max(last_visit) as last_time
|
||||
from zerver_useractivity ua
|
||||
join zerver_client client on client.id = ua.client_id
|
||||
join zerver_userprofile up on up.id = ua.user_profile_id
|
||||
join zerver_realm realm on realm.id = up.realm_id
|
||||
where
|
||||
client.name like 'desktop%%'
|
||||
group by string_id, client.name
|
||||
having max(last_visit) > now() - interval '2 week'
|
||||
order by string_id, client.name
|
||||
"""
|
||||
)
|
||||
|
||||
cols = [
|
||||
"Realm",
|
||||
"Client",
|
||||
"Hits",
|
||||
"Last time",
|
||||
]
|
||||
|
||||
pages.append(get_page(query, cols, title))
|
||||
|
||||
###
|
||||
|
||||
title = "Integrations by realm"
|
||||
|
||||
query = SQL(
|
||||
"""
|
||||
select
|
||||
realm.string_id,
|
||||
case
|
||||
when query like '%%external%%' then split_part(query, '/', 5)
|
||||
else client.name
|
||||
end client_name,
|
||||
sum(count) as hits,
|
||||
max(last_visit) as last_time
|
||||
from zerver_useractivity ua
|
||||
join zerver_client client on client.id = ua.client_id
|
||||
join zerver_userprofile up on up.id = ua.user_profile_id
|
||||
join zerver_realm realm on realm.id = up.realm_id
|
||||
where
|
||||
(query in ('send_message_backend', '/api/v1/send_message')
|
||||
and client.name not in ('Android', 'ZulipiOS')
|
||||
and client.name not like 'test: Zulip%%'
|
||||
)
|
||||
or
|
||||
query like '%%external%%'
|
||||
group by string_id, client_name
|
||||
having max(last_visit) > now() - interval '2 week'
|
||||
order by string_id, client_name
|
||||
"""
|
||||
)
|
||||
|
||||
cols = [
|
||||
"Realm",
|
||||
"Client",
|
||||
"Hits",
|
||||
"Last time",
|
||||
]
|
||||
|
||||
pages.append(get_page(query, cols, title))
|
||||
|
||||
###
|
||||
|
||||
title = "Integrations by client"
|
||||
|
||||
query = SQL(
|
||||
"""
|
||||
select
|
||||
case
|
||||
when query like '%%external%%' then split_part(query, '/', 5)
|
||||
else client.name
|
||||
end client_name,
|
||||
realm.string_id,
|
||||
sum(count) as hits,
|
||||
max(last_visit) as last_time
|
||||
from zerver_useractivity ua
|
||||
join zerver_client client on client.id = ua.client_id
|
||||
join zerver_userprofile up on up.id = ua.user_profile_id
|
||||
join zerver_realm realm on realm.id = up.realm_id
|
||||
where
|
||||
(query in ('send_message_backend', '/api/v1/send_message')
|
||||
and client.name not in ('Android', 'ZulipiOS')
|
||||
and client.name not like 'test: Zulip%%'
|
||||
)
|
||||
or
|
||||
query like '%%external%%'
|
||||
group by client_name, string_id
|
||||
having max(last_visit) > now() - interval '2 week'
|
||||
order by client_name, string_id
|
||||
"""
|
||||
)
|
||||
|
||||
cols = [
|
||||
"Client",
|
||||
"Realm",
|
||||
"Hits",
|
||||
"Last time",
|
||||
]
|
||||
|
||||
pages.append(get_page(query, cols, title))
|
||||
|
||||
title = "Remote Zulip servers"
|
||||
|
||||
query = SQL(
|
||||
"""
|
||||
with icount as (
|
||||
select
|
||||
server_id,
|
||||
max(value) as max_value,
|
||||
max(end_time) as max_end_time
|
||||
from zilencer_remoteinstallationcount
|
||||
where
|
||||
property='active_users:is_bot:day'
|
||||
and subgroup='false'
|
||||
group by server_id
|
||||
),
|
||||
remote_push_devices as (
|
||||
select server_id, count(distinct(user_id)) as push_user_count from zilencer_remotepushdevicetoken
|
||||
group by server_id
|
||||
)
|
||||
select
|
||||
rserver.id,
|
||||
rserver.hostname,
|
||||
rserver.contact_email,
|
||||
max_value,
|
||||
push_user_count,
|
||||
max_end_time
|
||||
from zilencer_remotezulipserver rserver
|
||||
left join icount on icount.server_id = rserver.id
|
||||
left join remote_push_devices on remote_push_devices.server_id = rserver.id
|
||||
order by max_value DESC NULLS LAST, push_user_count DESC NULLS LAST
|
||||
"""
|
||||
)
|
||||
|
||||
cols = [
|
||||
"ID",
|
||||
"Hostname",
|
||||
"Contact email",
|
||||
"Analytics users",
|
||||
"Mobile users",
|
||||
"Last update time",
|
||||
]
|
||||
|
||||
pages.append(get_page(query, cols, title, totals_columns=[3, 4]))
|
||||
|
||||
return pages
|
||||
|
||||
|
||||
@require_server_admin
|
||||
@has_request_variables
|
||||
def get_installation_activity(request: HttpRequest) -> HttpResponse:
|
||||
duration_content, realm_minutes = user_activity_intervals()
|
||||
counts_content: str = realm_summary_table(realm_minutes)
|
||||
data = [
|
||||
("Counts", counts_content),
|
||||
("Durations", duration_content),
|
||||
]
|
||||
for page in ad_hoc_queries():
|
||||
data.append((page["title"], page["content"]))
|
||||
|
||||
title = "Activity"
|
||||
|
||||
return render(
|
||||
request,
|
||||
"analytics/activity.html",
|
||||
context=dict(data=data, title=title, is_home=True),
|
||||
)
|
||||
@@ -1,259 +0,0 @@
|
||||
import itertools
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from django.db import connection
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
|
||||
from django.shortcuts import render
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from psycopg2.sql import SQL
|
||||
|
||||
from analytics.views.activity_common import (
|
||||
format_date_for_activity_reports,
|
||||
get_user_activity_summary,
|
||||
make_table,
|
||||
user_activity_link,
|
||||
)
|
||||
from zerver.decorator import require_server_admin
|
||||
from zerver.models import Realm, UserActivity
|
||||
|
||||
|
||||
def get_user_activity_records_for_realm(realm: str, is_bot: bool) -> QuerySet:
|
||||
fields = [
|
||||
"user_profile__full_name",
|
||||
"user_profile__delivery_email",
|
||||
"query",
|
||||
"client__name",
|
||||
"count",
|
||||
"last_visit",
|
||||
]
|
||||
|
||||
records = UserActivity.objects.filter(
|
||||
user_profile__realm__string_id=realm,
|
||||
user_profile__is_active=True,
|
||||
user_profile__is_bot=is_bot,
|
||||
)
|
||||
records = records.order_by("user_profile__delivery_email", "-last_visit")
|
||||
records = records.select_related("user_profile", "client").only(*fields)
|
||||
return records
|
||||
|
||||
|
||||
def realm_user_summary_table(
|
||||
all_records: List[QuerySet], admin_emails: Set[str]
|
||||
) -> Tuple[Dict[str, Any], str]:
|
||||
user_records = {}
|
||||
|
||||
def by_email(record: QuerySet) -> str:
|
||||
return record.user_profile.delivery_email
|
||||
|
||||
for email, records in itertools.groupby(all_records, by_email):
|
||||
user_records[email] = get_user_activity_summary(list(records))
|
||||
|
||||
def get_last_visit(user_summary: Dict[str, Dict[str, datetime]], k: str) -> Optional[datetime]:
|
||||
if k in user_summary:
|
||||
return user_summary[k]["last_visit"]
|
||||
else:
|
||||
return None
|
||||
|
||||
def get_count(user_summary: Dict[str, Dict[str, str]], k: str) -> str:
|
||||
if k in user_summary:
|
||||
return user_summary[k]["count"]
|
||||
else:
|
||||
return ""
|
||||
|
||||
def is_recent(val: datetime) -> bool:
|
||||
age = timezone_now() - val
|
||||
return age.total_seconds() < 5 * 60
|
||||
|
||||
rows = []
|
||||
for email, user_summary in user_records.items():
|
||||
email_link = user_activity_link(email, user_summary["user_profile_id"])
|
||||
sent_count = get_count(user_summary, "send")
|
||||
cells = [user_summary["name"], email_link, sent_count]
|
||||
row_class = ""
|
||||
for field in ["use", "send", "pointer", "desktop", "ZulipiOS", "Android"]:
|
||||
visit = get_last_visit(user_summary, field)
|
||||
if field == "use":
|
||||
if visit and is_recent(visit):
|
||||
row_class += " recently_active"
|
||||
if email in admin_emails:
|
||||
row_class += " admin"
|
||||
val = format_date_for_activity_reports(visit)
|
||||
cells.append(val)
|
||||
row = dict(cells=cells, row_class=row_class)
|
||||
rows.append(row)
|
||||
|
||||
def by_used_time(row: Dict[str, Any]) -> str:
|
||||
return row["cells"][3]
|
||||
|
||||
rows = sorted(rows, key=by_used_time, reverse=True)
|
||||
|
||||
cols = [
|
||||
"Name",
|
||||
"Email",
|
||||
"Total sent",
|
||||
"Heard from",
|
||||
"Message sent",
|
||||
"Pointer motion",
|
||||
"Desktop",
|
||||
"ZulipiOS",
|
||||
"Android",
|
||||
]
|
||||
|
||||
title = "Summary"
|
||||
|
||||
content = make_table(title, cols, rows, has_row_class=True)
|
||||
return user_records, content
|
||||
|
||||
|
||||
def realm_client_table(user_summaries: Dict[str, Dict[str, Any]]) -> str:
|
||||
exclude_keys = [
|
||||
"internal",
|
||||
"name",
|
||||
"user_profile_id",
|
||||
"use",
|
||||
"send",
|
||||
"pointer",
|
||||
"website",
|
||||
"desktop",
|
||||
]
|
||||
|
||||
rows = []
|
||||
for email, user_summary in user_summaries.items():
|
||||
email_link = user_activity_link(email, user_summary["user_profile_id"])
|
||||
name = user_summary["name"]
|
||||
for k, v in user_summary.items():
|
||||
if k in exclude_keys:
|
||||
continue
|
||||
client = k
|
||||
count = v["count"]
|
||||
last_visit = v["last_visit"]
|
||||
row = [
|
||||
format_date_for_activity_reports(last_visit),
|
||||
client,
|
||||
name,
|
||||
email_link,
|
||||
count,
|
||||
]
|
||||
rows.append(row)
|
||||
|
||||
rows = sorted(rows, key=lambda r: r[0], reverse=True)
|
||||
|
||||
cols = [
|
||||
"Last visit",
|
||||
"Client",
|
||||
"Name",
|
||||
"Email",
|
||||
"Count",
|
||||
]
|
||||
|
||||
title = "Clients"
|
||||
|
||||
return make_table(title, cols, rows)
|
||||
|
||||
|
||||
def sent_messages_report(realm: str) -> str:
|
||||
title = "Recently sent messages for " + realm
|
||||
|
||||
cols = [
|
||||
"Date",
|
||||
"Humans",
|
||||
"Bots",
|
||||
]
|
||||
|
||||
query = SQL(
|
||||
"""
|
||||
select
|
||||
series.day::date,
|
||||
humans.cnt,
|
||||
bots.cnt
|
||||
from (
|
||||
select generate_series(
|
||||
(now()::date - interval '2 week'),
|
||||
now()::date,
|
||||
interval '1 day'
|
||||
) as day
|
||||
) as series
|
||||
left join (
|
||||
select
|
||||
date_sent::date date_sent,
|
||||
count(*) cnt
|
||||
from zerver_message m
|
||||
join zerver_userprofile up on up.id = m.sender_id
|
||||
join zerver_realm r on r.id = up.realm_id
|
||||
where
|
||||
r.string_id = %s
|
||||
and
|
||||
(not up.is_bot)
|
||||
and
|
||||
date_sent > now() - interval '2 week'
|
||||
group by
|
||||
date_sent::date
|
||||
order by
|
||||
date_sent::date
|
||||
) humans on
|
||||
series.day = humans.date_sent
|
||||
left join (
|
||||
select
|
||||
date_sent::date date_sent,
|
||||
count(*) cnt
|
||||
from zerver_message m
|
||||
join zerver_userprofile up on up.id = m.sender_id
|
||||
join zerver_realm r on r.id = up.realm_id
|
||||
where
|
||||
r.string_id = %s
|
||||
and
|
||||
up.is_bot
|
||||
and
|
||||
date_sent > now() - interval '2 week'
|
||||
group by
|
||||
date_sent::date
|
||||
order by
|
||||
date_sent::date
|
||||
) bots on
|
||||
series.day = bots.date_sent
|
||||
"""
|
||||
)
|
||||
cursor = connection.cursor()
|
||||
cursor.execute(query, [realm, realm])
|
||||
rows = cursor.fetchall()
|
||||
cursor.close()
|
||||
|
||||
return make_table(title, cols, rows)
|
||||
|
||||
|
||||
@require_server_admin
|
||||
def get_realm_activity(request: HttpRequest, realm_str: str) -> HttpResponse:
|
||||
data: List[Tuple[str, str]] = []
|
||||
all_user_records: Dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
admins = Realm.objects.get(string_id=realm_str).get_human_admin_users()
|
||||
except Realm.DoesNotExist:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
admin_emails = {admin.delivery_email for admin in admins}
|
||||
|
||||
for is_bot, page_title in [(False, "Humans"), (True, "Bots")]:
|
||||
all_records = list(get_user_activity_records_for_realm(realm_str, is_bot))
|
||||
|
||||
user_records, content = realm_user_summary_table(all_records, admin_emails)
|
||||
all_user_records.update(user_records)
|
||||
|
||||
data += [(page_title, content)]
|
||||
|
||||
page_title = "Clients"
|
||||
content = realm_client_table(all_user_records)
|
||||
data += [(page_title, content)]
|
||||
|
||||
page_title = "History"
|
||||
content = sent_messages_report(realm_str)
|
||||
data += [(page_title, content)]
|
||||
|
||||
title = realm_str
|
||||
return render(
|
||||
request,
|
||||
"analytics/activity.html",
|
||||
context=dict(data=data, realm_link=None, title=title),
|
||||
)
|
||||
@@ -1,514 +0,0 @@
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Tuple, Type, Union, cast
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseNotFound
|
||||
from django.shortcuts import render
|
||||
from django.utils import translation
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from analytics.lib.counts import COUNT_STATS, CountStat
|
||||
from analytics.lib.time_utils import time_range
|
||||
from analytics.models import (
|
||||
BaseCount,
|
||||
InstallationCount,
|
||||
RealmCount,
|
||||
StreamCount,
|
||||
UserCount,
|
||||
installation_epoch,
|
||||
)
|
||||
from zerver.decorator import (
|
||||
require_non_guest_user,
|
||||
require_server_admin,
|
||||
require_server_admin_api,
|
||||
to_utc_datetime,
|
||||
zulip_login_required,
|
||||
)
|
||||
from zerver.lib.exceptions import JsonableError
|
||||
from zerver.lib.i18n import get_and_set_request_language, get_language_translation_data
|
||||
from zerver.lib.request import REQ, has_request_variables
|
||||
from zerver.lib.response import json_success
|
||||
from zerver.lib.timestamp import convert_to_UTC
|
||||
from zerver.lib.validator import to_non_negative_int
|
||||
from zerver.models import Client, Realm, UserProfile, get_realm
|
||||
|
||||
if settings.ZILENCER_ENABLED:
|
||||
from zilencer.models import RemoteInstallationCount, RemoteRealmCount, RemoteZulipServer
|
||||
|
||||
MAX_TIME_FOR_FULL_ANALYTICS_GENERATION = timedelta(days=1, minutes=30)
|
||||
|
||||
|
||||
def is_analytics_ready(realm: Realm) -> bool:
|
||||
return (timezone_now() - realm.date_created) > MAX_TIME_FOR_FULL_ANALYTICS_GENERATION
|
||||
|
||||
|
||||
def render_stats(
|
||||
request: HttpRequest,
|
||||
data_url_suffix: str,
|
||||
target_name: str,
|
||||
for_installation: bool = False,
|
||||
remote: bool = False,
|
||||
analytics_ready: bool = True,
|
||||
) -> HttpResponse:
|
||||
assert request.user.is_authenticated
|
||||
page_params = dict(
|
||||
data_url_suffix=data_url_suffix,
|
||||
for_installation=for_installation,
|
||||
remote=remote,
|
||||
)
|
||||
|
||||
request_language = get_and_set_request_language(
|
||||
request,
|
||||
request.user.default_language,
|
||||
translation.get_language_from_path(request.path_info),
|
||||
)
|
||||
|
||||
page_params["translation_data"] = get_language_translation_data(request_language)
|
||||
|
||||
return render(
|
||||
request,
|
||||
"analytics/stats.html",
|
||||
context=dict(
|
||||
target_name=target_name, page_params=page_params, analytics_ready=analytics_ready
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@zulip_login_required
|
||||
def stats(request: HttpRequest) -> HttpResponse:
|
||||
assert request.user.is_authenticated
|
||||
realm = request.user.realm
|
||||
if request.user.is_guest:
|
||||
# TODO: Make @zulip_login_required pass the UserProfile so we
|
||||
# can use @require_member_or_admin
|
||||
raise JsonableError(_("Not allowed for guest users"))
|
||||
return render_stats(
|
||||
request, "", realm.name or realm.string_id, analytics_ready=is_analytics_ready(realm)
|
||||
)
|
||||
|
||||
|
||||
@require_server_admin
|
||||
@has_request_variables
|
||||
def stats_for_realm(request: HttpRequest, realm_str: str) -> HttpResponse:
|
||||
try:
|
||||
realm = get_realm(realm_str)
|
||||
except Realm.DoesNotExist:
|
||||
return HttpResponseNotFound()
|
||||
|
||||
return render_stats(
|
||||
request,
|
||||
f"/realm/{realm_str}",
|
||||
realm.name or realm.string_id,
|
||||
analytics_ready=is_analytics_ready(realm),
|
||||
)
|
||||
|
||||
|
||||
@require_server_admin
|
||||
@has_request_variables
|
||||
def stats_for_remote_realm(
|
||||
request: HttpRequest, remote_server_id: int, remote_realm_id: int
|
||||
) -> HttpResponse:
|
||||
assert settings.ZILENCER_ENABLED
|
||||
server = RemoteZulipServer.objects.get(id=remote_server_id)
|
||||
return render_stats(
|
||||
request,
|
||||
f"/remote/{server.id}/realm/{remote_realm_id}",
|
||||
f"Realm {remote_realm_id} on server {server.hostname}",
|
||||
)
|
||||
|
||||
|
||||
@require_server_admin_api
|
||||
@has_request_variables
|
||||
def get_chart_data_for_realm(
|
||||
request: HttpRequest, user_profile: UserProfile, realm_str: str, **kwargs: Any
|
||||
) -> HttpResponse:
|
||||
try:
|
||||
realm = get_realm(realm_str)
|
||||
except Realm.DoesNotExist:
|
||||
raise JsonableError(_("Invalid organization"))
|
||||
|
||||
return get_chart_data(request=request, user_profile=user_profile, realm=realm, **kwargs)
|
||||
|
||||
|
||||
@require_server_admin_api
|
||||
@has_request_variables
|
||||
def get_chart_data_for_remote_realm(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
remote_server_id: int,
|
||||
remote_realm_id: int,
|
||||
**kwargs: Any,
|
||||
) -> HttpResponse:
|
||||
assert settings.ZILENCER_ENABLED
|
||||
server = RemoteZulipServer.objects.get(id=remote_server_id)
|
||||
return get_chart_data(
|
||||
request=request,
|
||||
user_profile=user_profile,
|
||||
server=server,
|
||||
remote=True,
|
||||
remote_realm_id=int(remote_realm_id),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@require_server_admin
|
||||
def stats_for_installation(request: HttpRequest) -> HttpResponse:
|
||||
return render_stats(request, "/installation", "installation", True)
|
||||
|
||||
|
||||
@require_server_admin
|
||||
def stats_for_remote_installation(request: HttpRequest, remote_server_id: int) -> HttpResponse:
|
||||
assert settings.ZILENCER_ENABLED
|
||||
server = RemoteZulipServer.objects.get(id=remote_server_id)
|
||||
return render_stats(
|
||||
request,
|
||||
f"/remote/{server.id}/installation",
|
||||
f"remote installation {server.hostname}",
|
||||
True,
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
@require_server_admin_api
|
||||
@has_request_variables
|
||||
def get_chart_data_for_installation(
|
||||
request: HttpRequest, user_profile: UserProfile, chart_name: str = REQ(), **kwargs: Any
|
||||
) -> HttpResponse:
|
||||
return get_chart_data(
|
||||
request=request, user_profile=user_profile, for_installation=True, **kwargs
|
||||
)
|
||||
|
||||
|
||||
@require_server_admin_api
|
||||
@has_request_variables
|
||||
def get_chart_data_for_remote_installation(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
remote_server_id: int,
|
||||
chart_name: str = REQ(),
|
||||
**kwargs: Any,
|
||||
) -> HttpResponse:
|
||||
assert settings.ZILENCER_ENABLED
|
||||
server = RemoteZulipServer.objects.get(id=remote_server_id)
|
||||
return get_chart_data(
|
||||
request=request,
|
||||
user_profile=user_profile,
|
||||
for_installation=True,
|
||||
remote=True,
|
||||
server=server,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@require_non_guest_user
|
||||
@has_request_variables
|
||||
def get_chart_data(
|
||||
request: HttpRequest,
|
||||
user_profile: UserProfile,
|
||||
chart_name: str = REQ(),
|
||||
min_length: Optional[int] = REQ(converter=to_non_negative_int, default=None),
|
||||
start: Optional[datetime] = REQ(converter=to_utc_datetime, default=None),
|
||||
end: Optional[datetime] = REQ(converter=to_utc_datetime, default=None),
|
||||
realm: Optional[Realm] = None,
|
||||
for_installation: bool = False,
|
||||
remote: bool = False,
|
||||
remote_realm_id: Optional[int] = None,
|
||||
server: Optional["RemoteZulipServer"] = None,
|
||||
) -> HttpResponse:
|
||||
TableType = Union[
|
||||
Type["RemoteInstallationCount"],
|
||||
Type[InstallationCount],
|
||||
Type["RemoteRealmCount"],
|
||||
Type[RealmCount],
|
||||
]
|
||||
if for_installation:
|
||||
if remote:
|
||||
assert settings.ZILENCER_ENABLED
|
||||
aggregate_table: TableType = RemoteInstallationCount
|
||||
assert server is not None
|
||||
else:
|
||||
aggregate_table = InstallationCount
|
||||
else:
|
||||
if remote:
|
||||
assert settings.ZILENCER_ENABLED
|
||||
aggregate_table = RemoteRealmCount
|
||||
assert server is not None
|
||||
assert remote_realm_id is not None
|
||||
else:
|
||||
aggregate_table = RealmCount
|
||||
|
||||
tables: Union[Tuple[TableType], Tuple[TableType, Type[UserCount]]]
|
||||
|
||||
if chart_name == "number_of_humans":
|
||||
stats = [
|
||||
COUNT_STATS["1day_actives::day"],
|
||||
COUNT_STATS["realm_active_humans::day"],
|
||||
COUNT_STATS["active_users_audit:is_bot:day"],
|
||||
]
|
||||
tables = (aggregate_table,)
|
||||
subgroup_to_label: Dict[CountStat, Dict[Optional[str], str]] = {
|
||||
stats[0]: {None: "_1day"},
|
||||
stats[1]: {None: "_15day"},
|
||||
stats[2]: {"false": "all_time"},
|
||||
}
|
||||
labels_sort_function = None
|
||||
include_empty_subgroups = True
|
||||
elif chart_name == "messages_sent_over_time":
|
||||
stats = [COUNT_STATS["messages_sent:is_bot:hour"]]
|
||||
tables = (aggregate_table, UserCount)
|
||||
subgroup_to_label = {stats[0]: {"false": "human", "true": "bot"}}
|
||||
labels_sort_function = None
|
||||
include_empty_subgroups = True
|
||||
elif chart_name == "messages_sent_by_message_type":
|
||||
stats = [COUNT_STATS["messages_sent:message_type:day"]]
|
||||
tables = (aggregate_table, UserCount)
|
||||
subgroup_to_label = {
|
||||
stats[0]: {
|
||||
"public_stream": _("Public streams"),
|
||||
"private_stream": _("Private streams"),
|
||||
"private_message": _("Private messages"),
|
||||
"huddle_message": _("Group private messages"),
|
||||
}
|
||||
}
|
||||
labels_sort_function = lambda data: sort_by_totals(data["everyone"])
|
||||
include_empty_subgroups = True
|
||||
elif chart_name == "messages_sent_by_client":
|
||||
stats = [COUNT_STATS["messages_sent:client:day"]]
|
||||
tables = (aggregate_table, UserCount)
|
||||
# Note that the labels are further re-written by client_label_map
|
||||
subgroup_to_label = {
|
||||
stats[0]: {str(id): name for id, name in Client.objects.values_list("id", "name")}
|
||||
}
|
||||
labels_sort_function = sort_client_labels
|
||||
include_empty_subgroups = False
|
||||
elif chart_name == "messages_read_over_time":
|
||||
stats = [COUNT_STATS["messages_read::hour"]]
|
||||
tables = (aggregate_table, UserCount)
|
||||
subgroup_to_label = {stats[0]: {None: "read"}}
|
||||
labels_sort_function = None
|
||||
include_empty_subgroups = True
|
||||
else:
|
||||
raise JsonableError(_("Unknown chart name: {}").format(chart_name))
|
||||
|
||||
# Most likely someone using our API endpoint. The /stats page does not
|
||||
# pass a start or end in its requests.
|
||||
if start is not None:
|
||||
start = convert_to_UTC(start)
|
||||
if end is not None:
|
||||
end = convert_to_UTC(end)
|
||||
if start is not None and end is not None and start > end:
|
||||
raise JsonableError(
|
||||
_("Start time is later than end time. Start: {start}, End: {end}").format(
|
||||
start=start,
|
||||
end=end,
|
||||
)
|
||||
)
|
||||
|
||||
if realm is None:
|
||||
# Note that this value is invalid for Remote tables; be
|
||||
# careful not to access it in those code paths.
|
||||
realm = user_profile.realm
|
||||
|
||||
if remote:
|
||||
# For remote servers, we don't have fillstate data, and thus
|
||||
# should simply use the first and last data points for the
|
||||
# table.
|
||||
assert server is not None
|
||||
assert aggregate_table is RemoteInstallationCount or aggregate_table is RemoteRealmCount
|
||||
aggregate_table_remote = cast(
|
||||
Union[Type[RemoteInstallationCount], Type[RemoteRealmCount]], aggregate_table
|
||||
) # https://stackoverflow.com/questions/68540528/mypy-assertions-on-the-types-of-types
|
||||
if not aggregate_table_remote.objects.filter(server=server).exists():
|
||||
raise JsonableError(
|
||||
_("No analytics data available. Please contact your server administrator.")
|
||||
)
|
||||
if start is None:
|
||||
first = aggregate_table_remote.objects.filter(server=server).first()
|
||||
assert first is not None
|
||||
start = first.end_time
|
||||
if end is None:
|
||||
last = aggregate_table_remote.objects.filter(server=server).last()
|
||||
assert last is not None
|
||||
end = last.end_time
|
||||
else:
|
||||
# Otherwise, we can use tables on the current server to
|
||||
# determine a nice range, and some additional validation.
|
||||
if start is None:
|
||||
if for_installation:
|
||||
start = installation_epoch()
|
||||
else:
|
||||
start = realm.date_created
|
||||
if end is None:
|
||||
end = max(
|
||||
stat.last_successful_fill() or datetime.min.replace(tzinfo=timezone.utc)
|
||||
for stat in stats
|
||||
)
|
||||
|
||||
if start > end and (timezone_now() - start > MAX_TIME_FOR_FULL_ANALYTICS_GENERATION):
|
||||
logging.warning(
|
||||
"User from realm %s attempted to access /stats, but the computed "
|
||||
"start time: %s (creation of realm or installation) is later than the computed "
|
||||
"end time: %s (last successful analytics update). Is the "
|
||||
"analytics cron job running?",
|
||||
realm.string_id,
|
||||
start,
|
||||
end,
|
||||
)
|
||||
raise JsonableError(
|
||||
_("No analytics data available. Please contact your server administrator.")
|
||||
)
|
||||
|
||||
assert len({stat.frequency for stat in stats}) == 1
|
||||
end_times = time_range(start, end, stats[0].frequency, min_length)
|
||||
data: Dict[str, Any] = {
|
||||
"end_times": [int(end_time.timestamp()) for end_time in end_times],
|
||||
"frequency": stats[0].frequency,
|
||||
}
|
||||
|
||||
aggregation_level = {
|
||||
InstallationCount: "everyone",
|
||||
RealmCount: "everyone",
|
||||
UserCount: "user",
|
||||
}
|
||||
if settings.ZILENCER_ENABLED:
|
||||
aggregation_level[RemoteInstallationCount] = "everyone"
|
||||
aggregation_level[RemoteRealmCount] = "everyone"
|
||||
|
||||
# -1 is a placeholder value, since there is no relevant filtering on InstallationCount
|
||||
id_value = {
|
||||
InstallationCount: -1,
|
||||
RealmCount: realm.id,
|
||||
UserCount: user_profile.id,
|
||||
}
|
||||
if settings.ZILENCER_ENABLED:
|
||||
if server is not None:
|
||||
id_value[RemoteInstallationCount] = server.id
|
||||
# TODO: RemoteRealmCount logic doesn't correctly handle
|
||||
# filtering by server_id as well.
|
||||
if remote_realm_id is not None:
|
||||
id_value[RemoteRealmCount] = remote_realm_id
|
||||
|
||||
for table in tables:
|
||||
data[aggregation_level[table]] = {}
|
||||
for stat in stats:
|
||||
data[aggregation_level[table]].update(
|
||||
get_time_series_by_subgroup(
|
||||
stat,
|
||||
table,
|
||||
id_value[table],
|
||||
end_times,
|
||||
subgroup_to_label[stat],
|
||||
include_empty_subgroups,
|
||||
)
|
||||
)
|
||||
|
||||
if labels_sort_function is not None:
|
||||
data["display_order"] = labels_sort_function(data)
|
||||
else:
|
||||
data["display_order"] = None
|
||||
return json_success(request, data=data)
|
||||
|
||||
|
||||
def sort_by_totals(value_arrays: Dict[str, List[int]]) -> List[str]:
|
||||
totals = [(sum(values), label) for label, values in value_arrays.items()]
|
||||
totals.sort(reverse=True)
|
||||
return [label for total, label in totals]
|
||||
|
||||
|
||||
# For any given user, we want to show a fixed set of clients in the chart,
|
||||
# regardless of the time aggregation or whether we're looking at realm or
|
||||
# user data. This fixed set ideally includes the clients most important in
|
||||
# understanding the realm's traffic and the user's traffic. This function
|
||||
# tries to rank the clients so that taking the first N elements of the
|
||||
# sorted list has a reasonable chance of doing so.
|
||||
def sort_client_labels(data: Dict[str, Dict[str, List[int]]]) -> List[str]:
|
||||
realm_order = sort_by_totals(data["everyone"])
|
||||
user_order = sort_by_totals(data["user"])
|
||||
label_sort_values: Dict[str, float] = {}
|
||||
for i, label in enumerate(realm_order):
|
||||
label_sort_values[label] = i
|
||||
for i, label in enumerate(user_order):
|
||||
label_sort_values[label] = min(i - 0.1, label_sort_values.get(label, i))
|
||||
return [label for label, sort_value in sorted(label_sort_values.items(), key=lambda x: x[1])]
|
||||
|
||||
|
||||
def table_filtered_to_id(table: Type[BaseCount], key_id: int) -> QuerySet:
|
||||
if table == RealmCount:
|
||||
return RealmCount.objects.filter(realm_id=key_id)
|
||||
elif table == UserCount:
|
||||
return UserCount.objects.filter(user_id=key_id)
|
||||
elif table == StreamCount:
|
||||
return StreamCount.objects.filter(stream_id=key_id)
|
||||
elif table == InstallationCount:
|
||||
return InstallationCount.objects.all()
|
||||
elif settings.ZILENCER_ENABLED and table == RemoteInstallationCount:
|
||||
return RemoteInstallationCount.objects.filter(server_id=key_id)
|
||||
elif settings.ZILENCER_ENABLED and table == RemoteRealmCount:
|
||||
return RemoteRealmCount.objects.filter(realm_id=key_id)
|
||||
else:
|
||||
raise AssertionError(f"Unknown table: {table}")
|
||||
|
||||
|
||||
def client_label_map(name: str) -> str:
|
||||
if name == "website":
|
||||
return "Website"
|
||||
if name.startswith("desktop app"):
|
||||
return "Old desktop app"
|
||||
if name == "ZulipElectron":
|
||||
return "Desktop app"
|
||||
if name == "ZulipAndroid":
|
||||
return "Old Android app"
|
||||
if name == "ZulipiOS":
|
||||
return "Old iOS app"
|
||||
if name == "ZulipMobile":
|
||||
return "Mobile app"
|
||||
if name in ["ZulipPython", "API: Python"]:
|
||||
return "Python API"
|
||||
if name.startswith("Zulip") and name.endswith("Webhook"):
|
||||
return name[len("Zulip") : -len("Webhook")] + " webhook"
|
||||
return name
|
||||
|
||||
|
||||
def rewrite_client_arrays(value_arrays: Dict[str, List[int]]) -> Dict[str, List[int]]:
|
||||
mapped_arrays: Dict[str, List[int]] = {}
|
||||
for label, array in value_arrays.items():
|
||||
mapped_label = client_label_map(label)
|
||||
if mapped_label in mapped_arrays:
|
||||
for i in range(0, len(array)):
|
||||
mapped_arrays[mapped_label][i] += value_arrays[label][i]
|
||||
else:
|
||||
mapped_arrays[mapped_label] = [value_arrays[label][i] for i in range(0, len(array))]
|
||||
return mapped_arrays
|
||||
|
||||
|
||||
def get_time_series_by_subgroup(
|
||||
stat: CountStat,
|
||||
table: Type[BaseCount],
|
||||
key_id: int,
|
||||
end_times: List[datetime],
|
||||
subgroup_to_label: Dict[Optional[str], str],
|
||||
include_empty_subgroups: bool,
|
||||
) -> Dict[str, List[int]]:
|
||||
queryset = (
|
||||
table_filtered_to_id(table, key_id)
|
||||
.filter(property=stat.property)
|
||||
.values_list("subgroup", "end_time", "value")
|
||||
)
|
||||
value_dicts: Dict[Optional[str], Dict[datetime, int]] = defaultdict(lambda: defaultdict(int))
|
||||
for subgroup, end_time, value in queryset:
|
||||
value_dicts[subgroup][end_time] = value
|
||||
value_arrays = {}
|
||||
for subgroup, label in subgroup_to_label.items():
|
||||
if (subgroup in value_dicts) or include_empty_subgroups:
|
||||
value_arrays[label] = [value_dicts[subgroup][end_time] for end_time in end_times]
|
||||
|
||||
if stat == COUNT_STATS["messages_sent:client:day"]:
|
||||
# HACK: We rewrite these arrays to collapse the Client objects
|
||||
# with similar names into a single sum, and generally give
|
||||
# them better names
|
||||
return rewrite_client_arrays(value_arrays)
|
||||
return value_arrays
|
||||
@@ -1,343 +0,0 @@
|
||||
import urllib
|
||||
from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import URLValidator
|
||||
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.timesince import timesince
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from confirmation.models import Confirmation, confirmation_url
|
||||
from confirmation.settings import STATUS_ACTIVE
|
||||
from zerver.actions.create_realm import do_change_realm_subdomain
|
||||
from zerver.actions.realm_settings import (
|
||||
do_change_realm_org_type,
|
||||
do_change_realm_plan_type,
|
||||
do_deactivate_realm,
|
||||
do_scrub_realm,
|
||||
do_send_realm_reactivation_email,
|
||||
)
|
||||
from zerver.decorator import require_server_admin
|
||||
from zerver.forms import check_subdomain_available
|
||||
from zerver.lib.exceptions import JsonableError
|
||||
from zerver.lib.realm_icon import realm_icon_url
|
||||
from zerver.lib.request import REQ, has_request_variables
|
||||
from zerver.lib.subdomains import get_subdomain_from_hostname
|
||||
from zerver.lib.validator import check_bool, check_string_in, to_decimal, to_non_negative_int
|
||||
from zerver.models import (
|
||||
MultiuseInvite,
|
||||
PreregistrationUser,
|
||||
Realm,
|
||||
UserProfile,
|
||||
get_org_type_display_name,
|
||||
get_realm,
|
||||
)
|
||||
from zerver.views.invite import get_invitee_emails_set
|
||||
|
||||
if settings.BILLING_ENABLED:
|
||||
from corporate.lib.stripe import approve_sponsorship as do_approve_sponsorship
|
||||
from corporate.lib.stripe import (
|
||||
attach_discount_to_realm,
|
||||
downgrade_at_the_end_of_billing_cycle,
|
||||
downgrade_now_without_creating_additional_invoices,
|
||||
get_discount_for_realm,
|
||||
get_latest_seat_count,
|
||||
make_end_of_cycle_updates_if_needed,
|
||||
update_billing_method_of_current_plan,
|
||||
update_sponsorship_status,
|
||||
void_all_open_invoices,
|
||||
)
|
||||
from corporate.models import get_current_plan_by_realm, get_customer_by_realm
|
||||
|
||||
|
||||
def get_plan_name(plan_type: int) -> str:
|
||||
return {
|
||||
Realm.PLAN_TYPE_SELF_HOSTED: "self-hosted",
|
||||
Realm.PLAN_TYPE_LIMITED: "limited",
|
||||
Realm.PLAN_TYPE_STANDARD: "standard",
|
||||
Realm.PLAN_TYPE_STANDARD_FREE: "open source",
|
||||
Realm.PLAN_TYPE_PLUS: "plus",
|
||||
}[plan_type]
|
||||
|
||||
|
||||
def get_confirmations(
|
||||
types: List[int], object_ids: List[int], hostname: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
lowest_datetime = timezone_now() - timedelta(days=30)
|
||||
confirmations = Confirmation.objects.filter(
|
||||
type__in=types, object_id__in=object_ids, date_sent__gte=lowest_datetime
|
||||
)
|
||||
confirmation_dicts = []
|
||||
for confirmation in confirmations:
|
||||
realm = confirmation.realm
|
||||
content_object = confirmation.content_object
|
||||
|
||||
type = confirmation.type
|
||||
expiry_date = confirmation.expiry_date
|
||||
|
||||
assert content_object is not None
|
||||
if hasattr(content_object, "status"):
|
||||
if content_object.status == STATUS_ACTIVE:
|
||||
link_status = "Link has been clicked"
|
||||
else:
|
||||
link_status = "Link has never been clicked"
|
||||
else:
|
||||
link_status = ""
|
||||
|
||||
now = timezone_now()
|
||||
if expiry_date is None:
|
||||
expires_in = "Never"
|
||||
elif now < expiry_date:
|
||||
expires_in = timesince(now, expiry_date)
|
||||
else:
|
||||
expires_in = "Expired"
|
||||
|
||||
url = confirmation_url(confirmation.confirmation_key, realm, type)
|
||||
confirmation_dicts.append(
|
||||
{
|
||||
"object": confirmation.content_object,
|
||||
"url": url,
|
||||
"type": type,
|
||||
"link_status": link_status,
|
||||
"expires_in": expires_in,
|
||||
}
|
||||
)
|
||||
return confirmation_dicts
|
||||
|
||||
|
||||
VALID_DOWNGRADE_METHODS = [
|
||||
"downgrade_at_billing_cycle_end",
|
||||
"downgrade_now_without_additional_licenses",
|
||||
"downgrade_now_void_open_invoices",
|
||||
]
|
||||
|
||||
VALID_STATUS_VALUES = [
|
||||
"active",
|
||||
"deactivated",
|
||||
]
|
||||
|
||||
VALID_BILLING_METHODS = [
|
||||
"send_invoice",
|
||||
"charge_automatically",
|
||||
]
|
||||
|
||||
|
||||
@require_server_admin
|
||||
@has_request_variables
|
||||
def support(
|
||||
request: HttpRequest,
|
||||
realm_id: Optional[int] = REQ(default=None, converter=to_non_negative_int),
|
||||
plan_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
|
||||
discount: Optional[Decimal] = REQ(default=None, converter=to_decimal),
|
||||
new_subdomain: Optional[str] = REQ(default=None),
|
||||
status: Optional[str] = REQ(default=None, str_validator=check_string_in(VALID_STATUS_VALUES)),
|
||||
billing_method: Optional[str] = REQ(
|
||||
default=None, str_validator=check_string_in(VALID_BILLING_METHODS)
|
||||
),
|
||||
sponsorship_pending: Optional[bool] = REQ(default=None, json_validator=check_bool),
|
||||
approve_sponsorship: Optional[bool] = REQ(default=None, json_validator=check_bool),
|
||||
downgrade_method: Optional[str] = REQ(
|
||||
default=None, str_validator=check_string_in(VALID_DOWNGRADE_METHODS)
|
||||
),
|
||||
scrub_realm: Optional[bool] = REQ(default=None, json_validator=check_bool),
|
||||
query: Optional[str] = REQ("q", default=None),
|
||||
org_type: Optional[int] = REQ(default=None, converter=to_non_negative_int),
|
||||
) -> HttpResponse:
|
||||
context: Dict[str, Any] = {}
|
||||
|
||||
if "success_message" in request.session:
|
||||
context["success_message"] = request.session["success_message"]
|
||||
del request.session["success_message"]
|
||||
|
||||
if settings.BILLING_ENABLED and request.method == "POST":
|
||||
# We check that request.POST only has two keys in it: The
|
||||
# realm_id and a field to change.
|
||||
keys = set(request.POST.keys())
|
||||
if "csrfmiddlewaretoken" in keys:
|
||||
keys.remove("csrfmiddlewaretoken")
|
||||
if len(keys) != 2:
|
||||
raise JsonableError(_("Invalid parameters"))
|
||||
|
||||
realm = Realm.objects.get(id=realm_id)
|
||||
|
||||
acting_user = request.user
|
||||
assert isinstance(acting_user, UserProfile)
|
||||
if plan_type is not None:
|
||||
current_plan_type = realm.plan_type
|
||||
do_change_realm_plan_type(realm, plan_type, acting_user=acting_user)
|
||||
msg = f"Plan type of {realm.string_id} changed from {get_plan_name(current_plan_type)} to {get_plan_name(plan_type)} "
|
||||
context["success_message"] = msg
|
||||
elif org_type is not None:
|
||||
current_realm_type = realm.org_type
|
||||
do_change_realm_org_type(realm, org_type, acting_user=acting_user)
|
||||
msg = f"Org type of {realm.string_id} changed from {get_org_type_display_name(current_realm_type)} to {get_org_type_display_name(org_type)} "
|
||||
context["success_message"] = msg
|
||||
elif discount is not None:
|
||||
current_discount = get_discount_for_realm(realm) or 0
|
||||
attach_discount_to_realm(realm, discount, acting_user=acting_user)
|
||||
context[
|
||||
"success_message"
|
||||
] = f"Discount of {realm.string_id} changed to {discount}% from {current_discount}%."
|
||||
elif new_subdomain is not None:
|
||||
old_subdomain = realm.string_id
|
||||
try:
|
||||
check_subdomain_available(new_subdomain)
|
||||
except ValidationError as error:
|
||||
context["error_message"] = error.message
|
||||
else:
|
||||
do_change_realm_subdomain(realm, new_subdomain, acting_user=acting_user)
|
||||
request.session[
|
||||
"success_message"
|
||||
] = f"Subdomain changed from {old_subdomain} to {new_subdomain}"
|
||||
return HttpResponseRedirect(
|
||||
reverse("support") + "?" + urlencode({"q": new_subdomain})
|
||||
)
|
||||
elif status is not None:
|
||||
if status == "active":
|
||||
do_send_realm_reactivation_email(realm, acting_user=acting_user)
|
||||
context[
|
||||
"success_message"
|
||||
] = f"Realm reactivation email sent to admins of {realm.string_id}."
|
||||
elif status == "deactivated":
|
||||
do_deactivate_realm(realm, acting_user=acting_user)
|
||||
context["success_message"] = f"{realm.string_id} deactivated."
|
||||
elif billing_method is not None:
|
||||
if billing_method == "send_invoice":
|
||||
update_billing_method_of_current_plan(
|
||||
realm, charge_automatically=False, acting_user=acting_user
|
||||
)
|
||||
context[
|
||||
"success_message"
|
||||
] = f"Billing method of {realm.string_id} updated to pay by invoice."
|
||||
elif billing_method == "charge_automatically":
|
||||
update_billing_method_of_current_plan(
|
||||
realm, charge_automatically=True, acting_user=acting_user
|
||||
)
|
||||
context[
|
||||
"success_message"
|
||||
] = f"Billing method of {realm.string_id} updated to charge automatically."
|
||||
elif sponsorship_pending is not None:
|
||||
if sponsorship_pending:
|
||||
update_sponsorship_status(realm, True, acting_user=acting_user)
|
||||
context["success_message"] = f"{realm.string_id} marked as pending sponsorship."
|
||||
else:
|
||||
update_sponsorship_status(realm, False, acting_user=acting_user)
|
||||
context["success_message"] = f"{realm.string_id} is no longer pending sponsorship."
|
||||
elif approve_sponsorship:
|
||||
do_approve_sponsorship(realm, acting_user=acting_user)
|
||||
context["success_message"] = f"Sponsorship approved for {realm.string_id}"
|
||||
elif downgrade_method is not None:
|
||||
if downgrade_method == "downgrade_at_billing_cycle_end":
|
||||
downgrade_at_the_end_of_billing_cycle(realm)
|
||||
context[
|
||||
"success_message"
|
||||
] = f"{realm.string_id} marked for downgrade at the end of billing cycle"
|
||||
elif downgrade_method == "downgrade_now_without_additional_licenses":
|
||||
downgrade_now_without_creating_additional_invoices(realm)
|
||||
context[
|
||||
"success_message"
|
||||
] = f"{realm.string_id} downgraded without creating additional invoices"
|
||||
elif downgrade_method == "downgrade_now_void_open_invoices":
|
||||
downgrade_now_without_creating_additional_invoices(realm)
|
||||
voided_invoices_count = void_all_open_invoices(realm)
|
||||
context[
|
||||
"success_message"
|
||||
] = f"{realm.string_id} downgraded and voided {voided_invoices_count} open invoices"
|
||||
elif scrub_realm:
|
||||
do_scrub_realm(realm, acting_user=acting_user)
|
||||
context["success_message"] = f"{realm.string_id} scrubbed."
|
||||
|
||||
if query:
|
||||
key_words = get_invitee_emails_set(query)
|
||||
|
||||
users = set(UserProfile.objects.filter(delivery_email__in=key_words))
|
||||
realms = set(Realm.objects.filter(string_id__in=key_words))
|
||||
|
||||
for key_word in key_words:
|
||||
try:
|
||||
URLValidator()(key_word)
|
||||
parse_result = urllib.parse.urlparse(key_word)
|
||||
hostname = parse_result.hostname
|
||||
assert hostname is not None
|
||||
if parse_result.port:
|
||||
hostname = f"{hostname}:{parse_result.port}"
|
||||
subdomain = get_subdomain_from_hostname(hostname)
|
||||
try:
|
||||
realms.add(get_realm(subdomain))
|
||||
except Realm.DoesNotExist:
|
||||
pass
|
||||
except ValidationError:
|
||||
users.update(UserProfile.objects.filter(full_name__iexact=key_word))
|
||||
|
||||
for realm in realms:
|
||||
realm.customer = get_customer_by_realm(realm)
|
||||
|
||||
current_plan = get_current_plan_by_realm(realm)
|
||||
if current_plan is not None:
|
||||
new_plan, last_ledger_entry = make_end_of_cycle_updates_if_needed(
|
||||
current_plan, timezone_now()
|
||||
)
|
||||
if last_ledger_entry is not None:
|
||||
if new_plan is not None:
|
||||
realm.current_plan = new_plan
|
||||
else:
|
||||
realm.current_plan = current_plan
|
||||
realm.current_plan.licenses = last_ledger_entry.licenses
|
||||
realm.current_plan.licenses_used = get_latest_seat_count(realm)
|
||||
|
||||
# full_names can have , in them
|
||||
users.update(UserProfile.objects.filter(full_name__iexact=query))
|
||||
|
||||
context["users"] = users
|
||||
context["realms"] = realms
|
||||
|
||||
confirmations: List[Dict[str, Any]] = []
|
||||
|
||||
preregistration_users = PreregistrationUser.objects.filter(email__in=key_words)
|
||||
confirmations += get_confirmations(
|
||||
[Confirmation.USER_REGISTRATION, Confirmation.INVITATION, Confirmation.REALM_CREATION],
|
||||
preregistration_users,
|
||||
hostname=request.get_host(),
|
||||
)
|
||||
|
||||
multiuse_invites = MultiuseInvite.objects.filter(realm__in=realms)
|
||||
confirmations += get_confirmations([Confirmation.MULTIUSE_INVITE], multiuse_invites)
|
||||
|
||||
confirmations += get_confirmations(
|
||||
[Confirmation.REALM_REACTIVATION], [realm.id for realm in realms]
|
||||
)
|
||||
|
||||
context["confirmations"] = confirmations
|
||||
|
||||
def get_realm_owner_emails_as_string(realm: Realm) -> str:
|
||||
return ", ".join(
|
||||
realm.get_human_owner_users()
|
||||
.order_by("delivery_email")
|
||||
.values_list("delivery_email", flat=True)
|
||||
)
|
||||
|
||||
def get_realm_admin_emails_as_string(realm: Realm) -> str:
|
||||
return ", ".join(
|
||||
realm.get_human_admin_users(include_realm_owners=False)
|
||||
.order_by("delivery_email")
|
||||
.values_list("delivery_email", flat=True)
|
||||
)
|
||||
|
||||
context["get_realm_owner_emails_as_string"] = get_realm_owner_emails_as_string
|
||||
context["get_realm_admin_emails_as_string"] = get_realm_admin_emails_as_string
|
||||
context["get_discount_for_realm"] = get_discount_for_realm
|
||||
context["get_org_type_display_name"] = get_org_type_display_name
|
||||
context["realm_icon_url"] = realm_icon_url
|
||||
context["Confirmation"] = Confirmation
|
||||
context["sorted_realm_types"] = sorted(
|
||||
Realm.ORG_TYPES.values(), key=lambda d: d["display_order"]
|
||||
)
|
||||
|
||||
return render(request, "analytics/support.html", context=context)
|
||||
@@ -1,104 +0,0 @@
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from django.conf import settings
|
||||
from django.db.models.query import QuerySet
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
|
||||
from analytics.views.activity_common import (
|
||||
format_date_for_activity_reports,
|
||||
get_user_activity_summary,
|
||||
make_table,
|
||||
)
|
||||
from zerver.decorator import require_server_admin
|
||||
from zerver.models import UserActivity, UserProfile, get_user_profile_by_id
|
||||
|
||||
if settings.BILLING_ENABLED:
|
||||
pass
|
||||
|
||||
|
||||
def get_user_activity_records(user_profile: UserProfile) -> List[QuerySet]:
|
||||
fields = [
|
||||
"user_profile__full_name",
|
||||
"query",
|
||||
"client__name",
|
||||
"count",
|
||||
"last_visit",
|
||||
]
|
||||
|
||||
records = UserActivity.objects.filter(
|
||||
user_profile=user_profile,
|
||||
)
|
||||
records = records.order_by("-last_visit")
|
||||
records = records.select_related("user_profile", "client").only(*fields)
|
||||
return records
|
||||
|
||||
|
||||
def raw_user_activity_table(records: List[QuerySet]) -> str:
|
||||
cols = [
|
||||
"query",
|
||||
"client",
|
||||
"count",
|
||||
"last_visit",
|
||||
]
|
||||
|
||||
def row(record: QuerySet) -> List[Any]:
|
||||
return [
|
||||
record.query,
|
||||
record.client.name,
|
||||
record.count,
|
||||
format_date_for_activity_reports(record.last_visit),
|
||||
]
|
||||
|
||||
rows = list(map(row, records))
|
||||
title = "Raw data"
|
||||
return make_table(title, cols, rows)
|
||||
|
||||
|
||||
def user_activity_summary_table(user_summary: Dict[str, Dict[str, Any]]) -> str:
|
||||
rows = []
|
||||
for k, v in user_summary.items():
|
||||
if k == "name" or k == "user_profile_id":
|
||||
continue
|
||||
client = k
|
||||
count = v["count"]
|
||||
last_visit = v["last_visit"]
|
||||
row = [
|
||||
format_date_for_activity_reports(last_visit),
|
||||
client,
|
||||
count,
|
||||
]
|
||||
rows.append(row)
|
||||
|
||||
rows = sorted(rows, key=lambda r: r[0], reverse=True)
|
||||
|
||||
cols = [
|
||||
"last_visit",
|
||||
"client",
|
||||
"count",
|
||||
]
|
||||
|
||||
title = "User activity"
|
||||
return make_table(title, cols, rows)
|
||||
|
||||
|
||||
@require_server_admin
|
||||
def get_user_activity(request: HttpRequest, user_profile_id: int) -> HttpResponse:
|
||||
user_profile = get_user_profile_by_id(user_profile_id)
|
||||
records = get_user_activity_records(user_profile)
|
||||
|
||||
data: List[Tuple[str, str]] = []
|
||||
user_summary = get_user_activity_summary(records)
|
||||
content = user_activity_summary_table(user_summary)
|
||||
|
||||
data += [("Summary", content)]
|
||||
|
||||
content = raw_user_activity_table(records)
|
||||
data += [("Info", content)]
|
||||
|
||||
title = user_profile.delivery_email
|
||||
return render(
|
||||
request,
|
||||
"analytics/activity.html",
|
||||
context=dict(data=data, title=title),
|
||||
)
|
||||
@@ -14,7 +14,8 @@ module.exports = {
|
||||
[
|
||||
"@babel/preset-env",
|
||||
{
|
||||
corejs: "3.20",
|
||||
corejs: "3.6",
|
||||
loose: true, // Loose mode for…of loops are 5× faster in Firefox
|
||||
shippedProposals: true,
|
||||
useBuiltIns: "usage",
|
||||
},
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("confirmation", "0007_add_indexes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="confirmation",
|
||||
name="expiry_date",
|
||||
field=models.DateTimeField(db_index=True, null=True),
|
||||
preserve_default=False,
|
||||
),
|
||||
]
|
||||
@@ -1,70 +0,0 @@
|
||||
# Generated by Django 3.1.7 on 2021-03-31 20:47
|
||||
|
||||
import time
|
||||
from datetime import timedelta
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, transaction
|
||||
from django.db.backends.postgresql.schema import DatabaseSchemaEditor
|
||||
from django.db.migrations.state import StateApps
|
||||
|
||||
|
||||
def set_expiry_date_for_existing_confirmations(
|
||||
apps: StateApps, schema_editor: DatabaseSchemaEditor
|
||||
) -> None:
|
||||
Confirmation = apps.get_model("confirmation", "Confirmation")
|
||||
if not Confirmation.objects.exists():
|
||||
return
|
||||
|
||||
# The values at the time of this migration
|
||||
INVITATION = 2
|
||||
UNSUBSCRIBE = 4
|
||||
MULTIUSE_INVITE = 6
|
||||
|
||||
@transaction.atomic
|
||||
def backfill_confirmations_between(lower_bound: int, upper_bound: int) -> None:
|
||||
confirmations = Confirmation.objects.filter(id__gte=lower_bound, id__lte=upper_bound)
|
||||
for confirmation in confirmations:
|
||||
if confirmation.type in (INVITATION, MULTIUSE_INVITE):
|
||||
confirmation.expiry_date = confirmation.date_sent + timedelta(
|
||||
days=settings.INVITATION_LINK_VALIDITY_DAYS
|
||||
)
|
||||
elif confirmation.type == UNSUBSCRIBE:
|
||||
# Unsubscribe links never expire, which we apparently implement as in 1M days.
|
||||
confirmation.expiry_date = confirmation.date_sent + timedelta(days=1000000)
|
||||
else:
|
||||
confirmation.expiry_date = confirmation.date_sent + timedelta(
|
||||
days=settings.CONFIRMATION_LINK_DEFAULT_VALIDITY_DAYS
|
||||
)
|
||||
Confirmation.objects.bulk_update(confirmations, ["expiry_date"])
|
||||
|
||||
# Because the ranges in this code are inclusive, subtracting 1 offers round numbers.
|
||||
BATCH_SIZE = 1000 - 1
|
||||
|
||||
first_id = Confirmation.objects.earliest("id").id
|
||||
last_id = Confirmation.objects.latest("id").id
|
||||
|
||||
id_range_lower_bound = first_id
|
||||
id_range_upper_bound = first_id + BATCH_SIZE
|
||||
while id_range_lower_bound <= last_id:
|
||||
print(f"Processed {id_range_lower_bound} / {last_id}")
|
||||
backfill_confirmations_between(id_range_lower_bound, id_range_upper_bound)
|
||||
id_range_lower_bound = id_range_upper_bound + 1
|
||||
id_range_upper_bound = id_range_lower_bound + BATCH_SIZE
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
atomic = False
|
||||
|
||||
dependencies = [
|
||||
("confirmation", "0008_confirmation_expiry_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_expiry_date_for_existing_confirmations,
|
||||
reverse_code=migrations.RunPython.noop,
|
||||
elidable=True,
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.5 on 2021-08-02 19:03
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("confirmation", "0009_confirmation_expiry_date_backfill"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="confirmation",
|
||||
name="expiry_date",
|
||||
field=models.DateTimeField(db_index=True),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.9 on 2021-11-30 17:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("confirmation", "0010_alter_confirmation_expiry_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="confirmation",
|
||||
name="expiry_date",
|
||||
field=models.DateTimeField(db_index=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -4,7 +4,7 @@ __revision__ = "$Id: models.py 28 2009-10-22 15:03:02Z jarek.zgoda $"
|
||||
import datetime
|
||||
import secrets
|
||||
from base64 import b32encode
|
||||
from typing import List, Mapping, Optional, Union
|
||||
from typing import Mapping, Optional, Union
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from django.conf import settings
|
||||
@@ -16,20 +16,10 @@ from django.http import HttpRequest, HttpResponse
|
||||
from django.shortcuts import render
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from typing_extensions import Protocol
|
||||
|
||||
from zerver.lib.types import UnspecifiedValue
|
||||
from zerver.models import EmailChangeStatus, MultiuseInvite, PreregistrationUser, Realm, UserProfile
|
||||
|
||||
|
||||
class HasRealmObject(Protocol):
|
||||
realm: Realm
|
||||
|
||||
|
||||
class OptionalHasRealmObject(Protocol):
|
||||
realm: Optional[Realm]
|
||||
|
||||
|
||||
class ConfirmationKeyException(Exception):
|
||||
WRONG_LENGTH = 1
|
||||
EXPIRED = 2
|
||||
@@ -44,10 +34,10 @@ def render_confirmation_key_error(
|
||||
request: HttpRequest, exception: ConfirmationKeyException
|
||||
) -> HttpResponse:
|
||||
if exception.error_type == ConfirmationKeyException.WRONG_LENGTH:
|
||||
return render(request, "confirmation/link_malformed.html", status=404)
|
||||
return render(request, "confirmation/link_malformed.html")
|
||||
if exception.error_type == ConfirmationKeyException.EXPIRED:
|
||||
return render(request, "confirmation/link_expired.html", status=404)
|
||||
return render(request, "confirmation/link_does_not_exist.html", status=404)
|
||||
return render(request, "confirmation/link_expired.html")
|
||||
return render(request, "confirmation/link_does_not_exist.html")
|
||||
|
||||
|
||||
def generate_key() -> str:
|
||||
@@ -59,23 +49,23 @@ ConfirmationObjT = Union[MultiuseInvite, PreregistrationUser, EmailChangeStatus]
|
||||
|
||||
|
||||
def get_object_from_key(
|
||||
confirmation_key: str, confirmation_types: List[int], activate_object: bool = True
|
||||
confirmation_key: str, confirmation_type: int, activate_object: bool = True
|
||||
) -> ConfirmationObjT:
|
||||
# Confirmation keys used to be 40 characters
|
||||
if len(confirmation_key) not in (24, 40):
|
||||
raise ConfirmationKeyException(ConfirmationKeyException.WRONG_LENGTH)
|
||||
try:
|
||||
confirmation = Confirmation.objects.get(
|
||||
confirmation_key=confirmation_key, type__in=confirmation_types
|
||||
confirmation_key=confirmation_key, type=confirmation_type
|
||||
)
|
||||
except Confirmation.DoesNotExist:
|
||||
raise ConfirmationKeyException(ConfirmationKeyException.DOES_NOT_EXIST)
|
||||
|
||||
if confirmation.expiry_date is not None and timezone_now() > confirmation.expiry_date:
|
||||
time_elapsed = timezone_now() - confirmation.date_sent
|
||||
if time_elapsed.total_seconds() > _properties[confirmation.type].validity_in_days * 24 * 3600:
|
||||
raise ConfirmationKeyException(ConfirmationKeyException.EXPIRED)
|
||||
|
||||
obj = confirmation.content_object
|
||||
assert obj is not None
|
||||
if activate_object and hasattr(obj, "status"):
|
||||
obj.status = getattr(settings, "STATUS_ACTIVE", 1)
|
||||
obj.save(update_fields=["status"])
|
||||
@@ -83,41 +73,20 @@ def get_object_from_key(
|
||||
|
||||
|
||||
def create_confirmation_link(
|
||||
obj: Union[Realm, HasRealmObject, OptionalHasRealmObject],
|
||||
confirmation_type: int,
|
||||
*,
|
||||
validity_in_days: Union[Optional[int], UnspecifiedValue] = UnspecifiedValue(),
|
||||
url_args: Mapping[str, str] = {},
|
||||
obj: ContentType, confirmation_type: int, url_args: Mapping[str, str] = {}
|
||||
) -> str:
|
||||
# validity_in_days is an override for the default values which are
|
||||
# determined by the confirmation_type - its main purpose is for use
|
||||
# in tests which may want to have control over the exact expiration time.
|
||||
key = generate_key()
|
||||
realm = None
|
||||
if isinstance(obj, Realm):
|
||||
realm = obj
|
||||
elif hasattr(obj, "realm"):
|
||||
if hasattr(obj, "realm"):
|
||||
realm = obj.realm
|
||||
|
||||
current_time = timezone_now()
|
||||
expiry_date = None
|
||||
if not isinstance(validity_in_days, UnspecifiedValue):
|
||||
if validity_in_days is None:
|
||||
expiry_date = None
|
||||
else:
|
||||
assert validity_in_days is not None
|
||||
expiry_date = current_time + datetime.timedelta(days=validity_in_days)
|
||||
else:
|
||||
expiry_date = current_time + datetime.timedelta(
|
||||
days=_properties[confirmation_type].validity_in_days
|
||||
)
|
||||
elif isinstance(obj, Realm):
|
||||
realm = obj
|
||||
|
||||
Confirmation.objects.create(
|
||||
content_object=obj,
|
||||
date_sent=current_time,
|
||||
date_sent=timezone_now(),
|
||||
confirmation_key=key,
|
||||
realm=realm,
|
||||
expiry_date=expiry_date,
|
||||
type=confirmation_type,
|
||||
)
|
||||
return confirmation_url(key, realm, confirmation_type, url_args)
|
||||
@@ -143,7 +112,6 @@ class Confirmation(models.Model):
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
date_sent: datetime.datetime = models.DateTimeField(db_index=True)
|
||||
confirmation_key: str = models.CharField(max_length=40, db_index=True)
|
||||
expiry_date: Optional[datetime.datetime] = models.DateTimeField(db_index=True, null=True)
|
||||
realm: Optional[Realm] = models.ForeignKey(Realm, null=True, on_delete=CASCADE)
|
||||
|
||||
# The following list is the set of valid types
|
||||
@@ -175,9 +143,9 @@ class ConfirmationType:
|
||||
|
||||
|
||||
_properties = {
|
||||
Confirmation.USER_REGISTRATION: ConfirmationType("get_prereg_key_and_redirect"),
|
||||
Confirmation.USER_REGISTRATION: ConfirmationType("check_prereg_key_and_redirect"),
|
||||
Confirmation.INVITATION: ConfirmationType(
|
||||
"get_prereg_key_and_redirect", validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS
|
||||
"check_prereg_key_and_redirect", validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS
|
||||
),
|
||||
Confirmation.EMAIL_CHANGE: ConfirmationType("confirm_email_change"),
|
||||
Confirmation.UNSUBSCRIBE: ConfirmationType(
|
||||
@@ -187,7 +155,7 @@ _properties = {
|
||||
Confirmation.MULTIUSE_INVITE: ConfirmationType(
|
||||
"join", validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS
|
||||
),
|
||||
Confirmation.REALM_CREATION: ConfirmationType("get_prereg_key_and_redirect"),
|
||||
Confirmation.REALM_CREATION: ConfirmationType("check_prereg_key_and_redirect"),
|
||||
Confirmation.REALM_REACTIVATION: ConfirmationType("realm_reactivation"),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
from typing import Optional
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from corporate.lib.stripe import LicenseLimitError, get_latest_seat_count
|
||||
from corporate.models import get_current_plan_by_realm
|
||||
from zerver.actions.create_user import send_message_to_signup_notification_stream
|
||||
from zerver.lib.exceptions import InvitationError
|
||||
from zerver.models import Realm, get_system_bot
|
||||
|
||||
|
||||
def generate_licenses_low_warning_message_if_required(realm: Realm) -> Optional[str]:
|
||||
plan = get_current_plan_by_realm(realm)
|
||||
if plan is None or plan.automanage_licenses:
|
||||
return None
|
||||
|
||||
licenses_remaining = plan.licenses() - get_latest_seat_count(realm)
|
||||
if licenses_remaining > 3:
|
||||
return None
|
||||
|
||||
format_kwargs = {
|
||||
"billing_page_link": "/billing/#settings",
|
||||
"deactivate_user_help_page_link": "/help/deactivate-or-reactivate-a-user",
|
||||
}
|
||||
|
||||
if licenses_remaining <= 0:
|
||||
return _(
|
||||
"Your organization has no Zulip licenses remaining and can no longer accept new users. "
|
||||
"Please [increase the number of licenses]({billing_page_link}) or "
|
||||
"[deactivate inactive users]({deactivate_user_help_page_link}) to allow new users to join."
|
||||
).format(**format_kwargs)
|
||||
|
||||
return {
|
||||
1: _(
|
||||
"Your organization has only one Zulip license remaining. You can "
|
||||
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
|
||||
"to allow more than one user to join."
|
||||
),
|
||||
2: _(
|
||||
"Your organization has only two Zulip licenses remaining. You can "
|
||||
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
|
||||
"to allow more than two users to join."
|
||||
),
|
||||
3: _(
|
||||
"Your organization has only three Zulip licenses remaining. You can "
|
||||
"[increase the number of licenses]({billing_page_link}) or [deactivate inactive users]({deactivate_user_help_page_link}) "
|
||||
"to allow more than three users to join."
|
||||
),
|
||||
}[licenses_remaining].format(**format_kwargs)
|
||||
|
||||
|
||||
def send_user_unable_to_signup_message_to_signup_notification_stream(
|
||||
realm: Realm, user_email: str
|
||||
) -> None:
|
||||
message = _(
|
||||
"A new member ({email}) was unable to join your organization because all Zulip licenses "
|
||||
"are in use. Please [increase the number of licenses]({billing_page_link}) or "
|
||||
"[deactivate inactive users]({deactivate_user_help_page_link}) to allow new members to join."
|
||||
).format(
|
||||
email=user_email,
|
||||
billing_page_link="/billing/#settings",
|
||||
deactivate_user_help_page_link="/help/deactivate-or-reactivate-a-user",
|
||||
)
|
||||
|
||||
send_message_to_signup_notification_stream(
|
||||
get_system_bot(settings.NOTIFICATION_BOT, realm.id), realm, message
|
||||
)
|
||||
|
||||
|
||||
def check_spare_licenses_available_for_adding_new_users(
|
||||
realm: Realm, number_of_users_to_add: int
|
||||
) -> None:
|
||||
plan = get_current_plan_by_realm(realm)
|
||||
if (
|
||||
plan is None
|
||||
or plan.automanage_licenses
|
||||
or plan.customer.exempt_from_from_license_number_check
|
||||
):
|
||||
return
|
||||
|
||||
if plan.licenses() < get_latest_seat_count(realm) + number_of_users_to_add:
|
||||
raise LicenseLimitError()
|
||||
|
||||
|
||||
def check_spare_licenses_available_for_registering_new_user(
|
||||
realm: Realm, user_email_to_add: str
|
||||
) -> None:
|
||||
try:
|
||||
check_spare_licenses_available_for_adding_new_users(realm, 1)
|
||||
except LicenseLimitError:
|
||||
send_user_unable_to_signup_message_to_signup_notification_stream(realm, user_email_to_add)
|
||||
raise
|
||||
|
||||
|
||||
def check_spare_licenses_available_for_inviting_new_users(realm: Realm, num_invites: int) -> None:
|
||||
try:
|
||||
check_spare_licenses_available_for_adding_new_users(realm, num_invites)
|
||||
except LicenseLimitError:
|
||||
if num_invites == 1:
|
||||
message = _("All Zulip licenses for this organization are currently in use.")
|
||||
else:
|
||||
message = _(
|
||||
"Your organization does not have enough unused Zulip licenses to invite {num_invites} users."
|
||||
).format(num_invites=num_invites)
|
||||
raise InvitationError(message, [], sent_invitations=False, license_limit_reached=True)
|
||||
@@ -5,14 +5,13 @@ import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from functools import wraps
|
||||
from typing import Any, Callable, Dict, Generator, Optional, Tuple, TypeVar, Union, cast
|
||||
from typing import Callable, Dict, Optional, Tuple, TypeVar, cast
|
||||
|
||||
import orjson
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
from django.core.signing import Signer
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now as timezone_now
|
||||
from django.utils.translation import gettext as _
|
||||
from django.utils.translation import gettext_lazy
|
||||
@@ -26,15 +25,12 @@ from corporate.models import (
|
||||
get_current_plan_by_realm,
|
||||
get_customer_by_realm,
|
||||
)
|
||||
from zerver.lib.exceptions import JsonableError
|
||||
from zerver.lib.logging_util import log_to_file
|
||||
from zerver.lib.send_email import FromAddress, send_email_to_billing_admins_and_realm_owners
|
||||
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
|
||||
from zerver.lib.utils import assert_is_not_none
|
||||
from zerver.models import Realm, RealmAuditLog, UserProfile, get_system_bot
|
||||
from zilencer.models import RemoteZulipServer, RemoteZulipServerAuditLog
|
||||
from zproject.config import get_secret
|
||||
|
||||
STRIPE_PUBLISHABLE_KEY = get_secret("stripe_publishable_key")
|
||||
stripe.api_key = get_secret("stripe_secret_key")
|
||||
|
||||
BILLING_LOG_PATH = os.path.join(
|
||||
@@ -51,9 +47,6 @@ MIN_INVOICED_LICENSES = 30
|
||||
MAX_INVOICED_LICENSES = 1000
|
||||
DEFAULT_INVOICE_DAYS_UNTIL_DUE = 30
|
||||
|
||||
# The version of Stripe API the billing system supports.
|
||||
STRIPE_API_VERSION = "2020-08-27"
|
||||
|
||||
|
||||
def get_latest_seat_count(realm: Realm) -> int:
|
||||
non_guests = (
|
||||
@@ -78,26 +71,6 @@ def unsign_string(signed_string: str, salt: str) -> str:
|
||||
return signer.unsign(signed_string)
|
||||
|
||||
|
||||
def validate_licenses(charge_automatically: bool, licenses: Optional[int], seat_count: int) -> None:
|
||||
min_licenses = seat_count
|
||||
max_licenses = None
|
||||
if not charge_automatically:
|
||||
min_licenses = max(seat_count, MIN_INVOICED_LICENSES)
|
||||
max_licenses = MAX_INVOICED_LICENSES
|
||||
|
||||
if licenses is None or licenses < min_licenses:
|
||||
raise BillingError(
|
||||
"not enough licenses", _("You must invoice for at least {} users.").format(min_licenses)
|
||||
)
|
||||
|
||||
if max_licenses is not None and licenses > max_licenses:
|
||||
message = _(
|
||||
"Invoices with more than {} licenses can't be processed from this page. To complete "
|
||||
"the upgrade, please contact {}."
|
||||
).format(max_licenses, settings.ZULIP_ADMINISTRATOR)
|
||||
raise BillingError("too many licenses", message)
|
||||
|
||||
|
||||
# Be extremely careful changing this function. Historical billing periods
|
||||
# are not stored anywhere, and are just computed on the fly using this
|
||||
# function. Any change you make here should return the same value (or be
|
||||
@@ -143,6 +116,10 @@ def next_month(billing_cycle_anchor: datetime, dt: datetime) -> datetime:
|
||||
|
||||
|
||||
def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> datetime:
|
||||
if plan.status == CustomerPlan.FREE_TRIAL:
|
||||
assert plan.next_invoice_date is not None # for mypy
|
||||
return plan.next_invoice_date
|
||||
|
||||
months_per_period = {
|
||||
CustomerPlan.ANNUAL: 12,
|
||||
CustomerPlan.MONTHLY: 1,
|
||||
@@ -193,26 +170,17 @@ def get_idempotency_key(ledger_entry: LicenseLedger) -> Optional[str]:
|
||||
return f"ledger_entry:{ledger_entry.id}" # nocoverage
|
||||
|
||||
|
||||
def cents_to_dollar_string(cents: int) -> str:
|
||||
return f"{cents / 100.:,.2f}"
|
||||
|
||||
|
||||
class BillingError(JsonableError):
|
||||
data_fields = ["error_description"]
|
||||
class BillingError(Exception):
|
||||
# error messages
|
||||
CONTACT_SUPPORT = gettext_lazy("Something went wrong. Please contact {email}.")
|
||||
TRY_RELOADING = gettext_lazy("Something went wrong. Please reload the page.")
|
||||
|
||||
# description is used only for tests
|
||||
def __init__(self, description: str, message: Optional[str] = None) -> None:
|
||||
self.error_description = description
|
||||
self.description = description
|
||||
if message is None:
|
||||
message = BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR)
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class LicenseLimitError(Exception):
|
||||
pass
|
||||
self.message = message
|
||||
|
||||
|
||||
class StripeCardError(BillingError):
|
||||
@@ -223,29 +191,22 @@ class StripeConnectionError(BillingError):
|
||||
pass
|
||||
|
||||
|
||||
class UpgradeWithExistingPlanError(BillingError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(
|
||||
"subscribing with existing subscription",
|
||||
"The organization is already subscribed to a plan. Please reload the billing page.",
|
||||
)
|
||||
|
||||
|
||||
class InvalidBillingSchedule(Exception):
|
||||
def __init__(self, billing_schedule: int) -> None:
|
||||
self.message = f"Unknown billing_schedule: {billing_schedule}"
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class InvalidTier(Exception):
|
||||
def __init__(self, tier: int) -> None:
|
||||
self.message = f"Unknown tier: {tier}"
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
def catch_stripe_errors(func: CallableT) -> CallableT:
|
||||
@wraps(func)
|
||||
def wrapped(*args: object, **kwargs: object) -> object:
|
||||
if settings.DEVELOPMENT and not settings.TEST_SUITE: # nocoverage
|
||||
if STRIPE_PUBLISHABLE_KEY is None:
|
||||
raise BillingError(
|
||||
"missing stripe config",
|
||||
"Missing Stripe config. "
|
||||
"See https://zulip.readthedocs.io/en/latest/subsystems/billing.html.",
|
||||
)
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
# See https://stripe.com/docs/api/python#error_handling, though
|
||||
@@ -284,13 +245,11 @@ def catch_stripe_errors(func: CallableT) -> CallableT:
|
||||
|
||||
@catch_stripe_errors
|
||||
def stripe_get_customer(stripe_customer_id: str) -> stripe.Customer:
|
||||
return stripe.Customer.retrieve(
|
||||
stripe_customer_id, expand=["invoice_settings", "invoice_settings.default_payment_method"]
|
||||
)
|
||||
return stripe.Customer.retrieve(stripe_customer_id, expand=["default_source"])
|
||||
|
||||
|
||||
@catch_stripe_errors
|
||||
def do_create_stripe_customer(user: UserProfile, payment_method: Optional[str] = None) -> Customer:
|
||||
def do_create_stripe_customer(user: UserProfile, stripe_token: Optional[str] = None) -> Customer:
|
||||
realm = user.realm
|
||||
# We could do a better job of handling race conditions here, but if two
|
||||
# people from a realm try to upgrade at exactly the same time, the main
|
||||
@@ -300,10 +259,7 @@ def do_create_stripe_customer(user: UserProfile, payment_method: Optional[str] =
|
||||
description=f"{realm.string_id} ({realm.name})",
|
||||
email=user.delivery_email,
|
||||
metadata={"realm_id": realm.id, "realm_str": realm.string_id},
|
||||
payment_method=payment_method,
|
||||
)
|
||||
stripe.Customer.modify(
|
||||
stripe_customer.id, invoice_settings={"default_payment_method": payment_method}
|
||||
source=stripe_token,
|
||||
)
|
||||
event_time = timestamp_to_datetime(stripe_customer.created)
|
||||
with transaction.atomic():
|
||||
@@ -313,7 +269,7 @@ def do_create_stripe_customer(user: UserProfile, payment_method: Optional[str] =
|
||||
event_type=RealmAuditLog.STRIPE_CUSTOMER_CREATED,
|
||||
event_time=event_time,
|
||||
)
|
||||
if payment_method is not None:
|
||||
if stripe_token is not None:
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user.realm,
|
||||
acting_user=user,
|
||||
@@ -323,24 +279,22 @@ def do_create_stripe_customer(user: UserProfile, payment_method: Optional[str] =
|
||||
customer, created = Customer.objects.update_or_create(
|
||||
realm=realm, defaults={"stripe_customer_id": stripe_customer.id}
|
||||
)
|
||||
from zerver.actions.users import do_make_user_billing_admin
|
||||
|
||||
do_make_user_billing_admin(user)
|
||||
user.is_billing_admin = True
|
||||
user.save(update_fields=["is_billing_admin"])
|
||||
return customer
|
||||
|
||||
|
||||
@catch_stripe_errors
|
||||
def do_replace_payment_method(
|
||||
user: UserProfile, payment_method: str, pay_invoices: bool = False
|
||||
) -> None:
|
||||
def do_replace_payment_source(
|
||||
user: UserProfile, stripe_token: str, pay_invoices: bool = False
|
||||
) -> stripe.Customer:
|
||||
customer = get_customer_by_realm(user.realm)
|
||||
assert customer is not None # for mypy
|
||||
assert customer.stripe_customer_id is not None # for mypy
|
||||
|
||||
stripe.Customer.modify(
|
||||
customer.stripe_customer_id, invoice_settings={"default_payment_method": payment_method}
|
||||
)
|
||||
|
||||
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
||||
stripe_customer.source = stripe_token
|
||||
# Deletes existing card: https://stripe.com/docs/api#update_customer-source
|
||||
updated_stripe_customer = stripe.Customer.save(stripe_customer)
|
||||
RealmAuditLog.objects.create(
|
||||
realm=user.realm,
|
||||
acting_user=user,
|
||||
@@ -349,30 +303,14 @@ def do_replace_payment_method(
|
||||
)
|
||||
if pay_invoices:
|
||||
for stripe_invoice in stripe.Invoice.list(
|
||||
collection_method="charge_automatically",
|
||||
customer=customer.stripe_customer_id,
|
||||
status="open",
|
||||
billing="charge_automatically", customer=stripe_customer.id, status="open"
|
||||
):
|
||||
# The user will get either a receipt or a "failed payment" email, but the in-app
|
||||
# messaging could be clearer here (e.g. it could explicitly tell the user that there
|
||||
# were payment(s) and that they succeeded or failed).
|
||||
# Worth fixing if we notice that a lot of cards end up failing at this step.
|
||||
stripe.Invoice.pay(stripe_invoice)
|
||||
|
||||
|
||||
def stripe_customer_has_credit_card_as_default_payment_method(
|
||||
stripe_customer: stripe.Customer,
|
||||
) -> bool:
|
||||
if not stripe_customer.invoice_settings.default_payment_method:
|
||||
return False
|
||||
return stripe_customer.invoice_settings.default_payment_method.type == "card"
|
||||
|
||||
|
||||
def customer_has_credit_card_as_default_payment_method(customer: Customer) -> bool:
|
||||
if not customer.stripe_customer_id:
|
||||
return False
|
||||
stripe_customer = stripe_get_customer(customer.stripe_customer_id)
|
||||
return stripe_customer_has_credit_card_as_default_payment_method(stripe_customer)
|
||||
return updated_stripe_customer
|
||||
|
||||
|
||||
# event_time should roughly be timezone_now(). Not designed to handle
|
||||
@@ -382,39 +320,31 @@ def make_end_of_cycle_updates_if_needed(
|
||||
plan: CustomerPlan, event_time: datetime
|
||||
) -> Tuple[Optional[CustomerPlan], Optional[LicenseLedger]]:
|
||||
last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by("-id").first()
|
||||
last_ledger_renewal = (
|
||||
LicenseLedger.objects.filter(plan=plan, is_renewal=True).order_by("-id").first()
|
||||
last_renewal = (
|
||||
LicenseLedger.objects.filter(plan=plan, is_renewal=True).order_by("-id").first().event_time
|
||||
)
|
||||
assert last_ledger_renewal is not None
|
||||
last_renewal = last_ledger_renewal.event_time
|
||||
|
||||
if plan.is_free_trial() or plan.status == CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS:
|
||||
assert plan.next_invoice_date is not None
|
||||
next_billing_cycle = plan.next_invoice_date
|
||||
else:
|
||||
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
|
||||
if next_billing_cycle <= event_time and last_ledger_entry is not None:
|
||||
licenses_at_next_renewal = last_ledger_entry.licenses_at_next_renewal
|
||||
assert licenses_at_next_renewal is not None
|
||||
next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal)
|
||||
if next_billing_cycle <= event_time:
|
||||
if plan.status == CustomerPlan.ACTIVE:
|
||||
return None, LicenseLedger.objects.create(
|
||||
plan=plan,
|
||||
is_renewal=True,
|
||||
event_time=next_billing_cycle,
|
||||
licenses=licenses_at_next_renewal,
|
||||
licenses_at_next_renewal=licenses_at_next_renewal,
|
||||
licenses=last_ledger_entry.licenses_at_next_renewal,
|
||||
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal,
|
||||
)
|
||||
if plan.is_free_trial():
|
||||
if plan.status == CustomerPlan.FREE_TRIAL:
|
||||
plan.invoiced_through = last_ledger_entry
|
||||
plan.billing_cycle_anchor = next_billing_cycle.replace(microsecond=0)
|
||||
assert plan.next_invoice_date is not None
|
||||
plan.billing_cycle_anchor = plan.next_invoice_date.replace(microsecond=0)
|
||||
plan.status = CustomerPlan.ACTIVE
|
||||
plan.save(update_fields=["invoiced_through", "billing_cycle_anchor", "status"])
|
||||
return None, LicenseLedger.objects.create(
|
||||
plan=plan,
|
||||
is_renewal=True,
|
||||
event_time=next_billing_cycle,
|
||||
licenses=licenses_at_next_renewal,
|
||||
licenses_at_next_renewal=licenses_at_next_renewal,
|
||||
licenses=last_ledger_entry.licenses_at_next_renewal,
|
||||
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal,
|
||||
)
|
||||
|
||||
if plan.status == CustomerPlan.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE:
|
||||
@@ -426,7 +356,6 @@ def make_end_of_cycle_updates_if_needed(
|
||||
|
||||
discount = plan.customer.default_discount or plan.discount
|
||||
_, _, _, price_per_license = compute_plan_parameters(
|
||||
tier=plan.tier,
|
||||
automanage_licenses=plan.automanage_licenses,
|
||||
billing_schedule=CustomerPlan.ANNUAL,
|
||||
discount=plan.discount,
|
||||
@@ -451,15 +380,12 @@ def make_end_of_cycle_updates_if_needed(
|
||||
plan=new_plan,
|
||||
is_renewal=True,
|
||||
event_time=next_billing_cycle,
|
||||
licenses=licenses_at_next_renewal,
|
||||
licenses_at_next_renewal=licenses_at_next_renewal,
|
||||
licenses=last_ledger_entry.licenses_at_next_renewal,
|
||||
licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal,
|
||||
)
|
||||
|
||||
realm = new_plan.customer.realm
|
||||
assert realm is not None
|
||||
|
||||
RealmAuditLog.objects.create(
|
||||
realm=realm,
|
||||
realm=new_plan.customer.realm,
|
||||
event_time=event_time,
|
||||
event_type=RealmAuditLog.CUSTOMER_SWITCHED_FROM_MONTHLY_TO_ANNUAL_PLAN,
|
||||
extra_data=orjson.dumps(
|
||||
@@ -471,47 +397,6 @@ def make_end_of_cycle_updates_if_needed(
|
||||
)
|
||||
return new_plan, new_plan_ledger_entry
|
||||
|
||||
if plan.status == CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS:
|
||||
standard_plan = plan
|
||||
standard_plan.end_date = next_billing_cycle
|
||||
standard_plan.status = CustomerPlan.ENDED
|
||||
standard_plan.save(update_fields=["status", "end_date"])
|
||||
|
||||
(_, _, _, plus_plan_price_per_license) = compute_plan_parameters(
|
||||
CustomerPlan.PLUS,
|
||||
standard_plan.automanage_licenses,
|
||||
standard_plan.billing_schedule,
|
||||
standard_plan.customer.default_discount,
|
||||
)
|
||||
plus_plan_billing_cycle_anchor = standard_plan.end_date.replace(microsecond=0)
|
||||
|
||||
plus_plan = CustomerPlan.objects.create(
|
||||
customer=standard_plan.customer,
|
||||
status=CustomerPlan.ACTIVE,
|
||||
automanage_licenses=standard_plan.automanage_licenses,
|
||||
charge_automatically=standard_plan.charge_automatically,
|
||||
price_per_license=plus_plan_price_per_license,
|
||||
discount=standard_plan.customer.default_discount,
|
||||
billing_schedule=standard_plan.billing_schedule,
|
||||
tier=CustomerPlan.PLUS,
|
||||
billing_cycle_anchor=plus_plan_billing_cycle_anchor,
|
||||
invoicing_status=CustomerPlan.INITIAL_INVOICE_TO_BE_SENT,
|
||||
next_invoice_date=plus_plan_billing_cycle_anchor,
|
||||
)
|
||||
|
||||
standard_plan_last_ledger = (
|
||||
LicenseLedger.objects.filter(plan=standard_plan).order_by("id").last()
|
||||
)
|
||||
licenses_for_plus_plan = standard_plan_last_ledger.licenses_at_next_renewal
|
||||
plus_plan_ledger_entry = LicenseLedger.objects.create(
|
||||
plan=plus_plan,
|
||||
is_renewal=True,
|
||||
event_time=plus_plan_billing_cycle_anchor,
|
||||
licenses=licenses_for_plus_plan,
|
||||
licenses_at_next_renewal=licenses_for_plus_plan,
|
||||
)
|
||||
return plus_plan, plus_plan_ledger_entry
|
||||
|
||||
if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
||||
process_downgrade(plan)
|
||||
return None, None
|
||||
@@ -520,16 +405,15 @@ def make_end_of_cycle_updates_if_needed(
|
||||
|
||||
# Returns Customer instead of stripe_customer so that we don't make a Stripe
|
||||
# API call if there's nothing to update
|
||||
@catch_stripe_errors
|
||||
def update_or_create_stripe_customer(
|
||||
user: UserProfile, payment_method: Optional[str] = None
|
||||
user: UserProfile, stripe_token: Optional[str] = None
|
||||
) -> Customer:
|
||||
realm = user.realm
|
||||
customer = get_customer_by_realm(realm)
|
||||
if customer is None or customer.stripe_customer_id is None:
|
||||
return do_create_stripe_customer(user, payment_method=payment_method)
|
||||
if payment_method is not None:
|
||||
do_replace_payment_method(user, payment_method, True)
|
||||
return do_create_stripe_customer(user, stripe_token=stripe_token)
|
||||
if stripe_token is not None:
|
||||
do_replace_payment_source(user, stripe_token)
|
||||
return customer
|
||||
|
||||
|
||||
@@ -543,32 +427,22 @@ def calculate_discounted_price_per_license(
|
||||
def get_price_per_license(
|
||||
tier: int, billing_schedule: int, discount: Optional[Decimal] = None
|
||||
) -> int:
|
||||
# TODO use variables to account for Zulip Plus
|
||||
assert tier == CustomerPlan.STANDARD
|
||||
|
||||
price_per_license: Optional[int] = None
|
||||
|
||||
if tier == CustomerPlan.STANDARD:
|
||||
if billing_schedule == CustomerPlan.ANNUAL:
|
||||
price_per_license = 8000
|
||||
elif billing_schedule == CustomerPlan.MONTHLY:
|
||||
price_per_license = 800
|
||||
else: # nocoverage
|
||||
raise InvalidBillingSchedule(billing_schedule)
|
||||
elif tier == CustomerPlan.PLUS:
|
||||
if billing_schedule == CustomerPlan.ANNUAL:
|
||||
price_per_license = 16000
|
||||
elif billing_schedule == CustomerPlan.MONTHLY:
|
||||
price_per_license = 1600
|
||||
else: # nocoverage
|
||||
raise InvalidBillingSchedule(billing_schedule)
|
||||
else:
|
||||
raise InvalidTier(tier)
|
||||
|
||||
if billing_schedule == CustomerPlan.ANNUAL:
|
||||
price_per_license = 8000
|
||||
elif billing_schedule == CustomerPlan.MONTHLY:
|
||||
price_per_license = 800
|
||||
else: # nocoverage
|
||||
raise InvalidBillingSchedule(billing_schedule)
|
||||
if discount is not None:
|
||||
price_per_license = calculate_discounted_price_per_license(price_per_license, discount)
|
||||
return price_per_license
|
||||
|
||||
|
||||
def compute_plan_parameters(
|
||||
tier: int,
|
||||
automanage_licenses: bool,
|
||||
billing_schedule: int,
|
||||
discount: Optional[Decimal],
|
||||
@@ -576,7 +450,7 @@ def compute_plan_parameters(
|
||||
) -> Tuple[datetime, datetime, datetime, int]:
|
||||
# Everything in Stripe is stored as timestamps with 1 second resolution,
|
||||
# so standardize on 1 second resolution.
|
||||
# TODO talk about leap seconds?
|
||||
# TODO talk about leapseconds?
|
||||
billing_cycle_anchor = timezone_now().replace(microsecond=0)
|
||||
if billing_schedule == CustomerPlan.ANNUAL:
|
||||
period_end = add_months(billing_cycle_anchor, 12)
|
||||
@@ -585,15 +459,13 @@ def compute_plan_parameters(
|
||||
else: # nocoverage
|
||||
raise InvalidBillingSchedule(billing_schedule)
|
||||
|
||||
price_per_license = get_price_per_license(tier, billing_schedule, discount)
|
||||
price_per_license = get_price_per_license(CustomerPlan.STANDARD, billing_schedule, discount)
|
||||
|
||||
next_invoice_date = period_end
|
||||
if automanage_licenses:
|
||||
next_invoice_date = add_months(billing_cycle_anchor, 1)
|
||||
if free_trial:
|
||||
period_end = billing_cycle_anchor + timedelta(
|
||||
days=assert_is_not_none(settings.FREE_TRIAL_DAYS)
|
||||
)
|
||||
period_end = billing_cycle_anchor + timedelta(days=settings.FREE_TRIAL_DAYS)
|
||||
next_invoice_date = period_end
|
||||
return billing_cycle_anchor, next_invoice_date, period_end, price_per_license
|
||||
|
||||
@@ -604,53 +476,6 @@ def decimal_to_float(obj: object) -> object:
|
||||
raise TypeError # nocoverage
|
||||
|
||||
|
||||
def is_free_trial_offer_enabled() -> bool:
|
||||
return settings.FREE_TRIAL_DAYS not in (None, 0)
|
||||
|
||||
|
||||
def ensure_realm_does_not_have_active_plan(realm: Customer) -> None:
|
||||
if get_current_plan_by_realm(realm) is not None:
|
||||
# Unlikely race condition from two people upgrading (clicking "Make payment")
|
||||
# at exactly the same time. Doesn't fully resolve the race condition, but having
|
||||
# a check here reduces the likelihood.
|
||||
billing_logger.warning(
|
||||
"Upgrade of %s failed because of existing active plan.",
|
||||
realm.string_id,
|
||||
)
|
||||
raise UpgradeWithExistingPlanError()
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def do_change_remote_server_plan_type(remote_server: RemoteZulipServer, plan_type: int) -> None:
|
||||
old_value = remote_server.plan_type
|
||||
remote_server.plan_type = plan_type
|
||||
remote_server.save(update_fields=["plan_type"])
|
||||
RemoteZulipServerAuditLog.objects.create(
|
||||
event_type=RealmAuditLog.REMOTE_SERVER_PLAN_TYPE_CHANGED,
|
||||
server=remote_server,
|
||||
event_time=timezone_now(),
|
||||
extra_data={"old_value": old_value, "new_value": plan_type},
|
||||
)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def do_deactivate_remote_server(remote_server: RemoteZulipServer) -> None:
|
||||
if remote_server.deactivated:
|
||||
billing_logger.warning(
|
||||
f"Cannot deactivate remote server with ID {remote_server.id}, "
|
||||
"server has already been deactivated."
|
||||
)
|
||||
return
|
||||
|
||||
remote_server.deactivated = True
|
||||
remote_server.save(update_fields=["deactivated"])
|
||||
RemoteZulipServerAuditLog.objects.create(
|
||||
event_type=RealmAuditLog.REMOTE_SERVER_DEACTIVATED,
|
||||
server=remote_server,
|
||||
event_time=timezone_now(),
|
||||
)
|
||||
|
||||
|
||||
# Only used for cloud signups
|
||||
@catch_stripe_errors
|
||||
def process_initial_upgrade(
|
||||
@@ -658,26 +483,59 @@ def process_initial_upgrade(
|
||||
licenses: int,
|
||||
automanage_licenses: bool,
|
||||
billing_schedule: int,
|
||||
charge_automatically: bool,
|
||||
free_trial: bool,
|
||||
stripe_token: Optional[str],
|
||||
) -> None:
|
||||
realm = user.realm
|
||||
customer = update_or_create_stripe_customer(user)
|
||||
assert customer.stripe_customer_id is not None # for mypy
|
||||
assert customer.realm is not None
|
||||
ensure_realm_does_not_have_active_plan(customer.realm)
|
||||
customer = update_or_create_stripe_customer(user, stripe_token=stripe_token)
|
||||
charge_automatically = stripe_token is not None
|
||||
free_trial = settings.FREE_TRIAL_DAYS not in (None, 0)
|
||||
|
||||
if get_current_plan_by_customer(customer) is not None:
|
||||
# Unlikely race condition from two people upgrading (clicking "Make payment")
|
||||
# at exactly the same time. Doesn't fully resolve the race condition, but having
|
||||
# a check here reduces the likelihood.
|
||||
billing_logger.warning(
|
||||
"Customer %s trying to upgrade, but has an active subscription",
|
||||
customer,
|
||||
)
|
||||
raise BillingError(
|
||||
"subscribing with existing subscription", str(BillingError.TRY_RELOADING)
|
||||
)
|
||||
|
||||
(
|
||||
billing_cycle_anchor,
|
||||
next_invoice_date,
|
||||
period_end,
|
||||
price_per_license,
|
||||
) = compute_plan_parameters(
|
||||
CustomerPlan.STANDARD,
|
||||
automanage_licenses,
|
||||
billing_schedule,
|
||||
customer.default_discount,
|
||||
free_trial,
|
||||
automanage_licenses, billing_schedule, customer.default_discount, free_trial
|
||||
)
|
||||
# The main design constraint in this function is that if you upgrade with a credit card, and the
|
||||
# charge fails, everything should be rolled back as if nothing had happened. This is because we
|
||||
# expect frequent card failures on initial signup.
|
||||
# Hence, if we're going to charge a card, do it at the beginning, even if we later may have to
|
||||
# adjust the number of licenses.
|
||||
if charge_automatically:
|
||||
if not free_trial:
|
||||
stripe_charge = stripe.Charge.create(
|
||||
amount=price_per_license * licenses,
|
||||
currency="usd",
|
||||
customer=customer.stripe_customer_id,
|
||||
description=f"Upgrade to Zulip Standard, ${price_per_license/100} x {licenses}",
|
||||
receipt_email=user.delivery_email,
|
||||
statement_descriptor="Zulip Standard",
|
||||
)
|
||||
# Not setting a period start and end, but maybe we should? Unclear what will make things
|
||||
# most similar to the renewal case from an accounting perspective.
|
||||
assert isinstance(stripe_charge.source, stripe.Card)
|
||||
description = f"Payment (Card ending in {stripe_charge.source.last4})"
|
||||
stripe.InvoiceItem.create(
|
||||
amount=price_per_license * licenses * -1,
|
||||
currency="usd",
|
||||
customer=customer.stripe_customer_id,
|
||||
description=description,
|
||||
discountable=False,
|
||||
)
|
||||
|
||||
# TODO: The correctness of this relies on user creation, deactivation, etc being
|
||||
# in a transaction.atomic() with the relevant RealmAuditLog entries
|
||||
@@ -720,7 +578,7 @@ def process_initial_upgrade(
|
||||
stripe.InvoiceItem.create(
|
||||
currency="usd",
|
||||
customer=customer.stripe_customer_id,
|
||||
description="Zulip Cloud Standard",
|
||||
description="Zulip Standard",
|
||||
discountable=False,
|
||||
period={
|
||||
"start": datetime_to_timestamp(billing_cycle_anchor),
|
||||
@@ -731,50 +589,24 @@ def process_initial_upgrade(
|
||||
)
|
||||
|
||||
if charge_automatically:
|
||||
collection_method = "charge_automatically"
|
||||
billing_method = "charge_automatically"
|
||||
days_until_due = None
|
||||
else:
|
||||
collection_method = "send_invoice"
|
||||
billing_method = "send_invoice"
|
||||
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
|
||||
|
||||
stripe_invoice = stripe.Invoice.create(
|
||||
auto_advance=True,
|
||||
collection_method=collection_method,
|
||||
billing=billing_method,
|
||||
customer=customer.stripe_customer_id,
|
||||
days_until_due=days_until_due,
|
||||
statement_descriptor="Zulip Cloud Standard",
|
||||
statement_descriptor="Zulip Standard",
|
||||
)
|
||||
stripe.Invoice.finalize_invoice(stripe_invoice)
|
||||
|
||||
from zerver.actions.realm_settings import do_change_realm_plan_type
|
||||
from zerver.lib.actions import do_change_plan_type
|
||||
|
||||
do_change_realm_plan_type(realm, Realm.PLAN_TYPE_STANDARD, acting_user=user)
|
||||
|
||||
|
||||
def update_license_ledger_for_manual_plan(
|
||||
plan: CustomerPlan,
|
||||
event_time: datetime,
|
||||
licenses: Optional[int] = None,
|
||||
licenses_at_next_renewal: Optional[int] = None,
|
||||
) -> None:
|
||||
if licenses is not None:
|
||||
assert plan.customer.realm is not None
|
||||
assert get_latest_seat_count(plan.customer.realm) <= licenses
|
||||
assert licenses > plan.licenses()
|
||||
LicenseLedger.objects.create(
|
||||
plan=plan, event_time=event_time, licenses=licenses, licenses_at_next_renewal=licenses
|
||||
)
|
||||
elif licenses_at_next_renewal is not None:
|
||||
assert plan.customer.realm is not None
|
||||
assert get_latest_seat_count(plan.customer.realm) <= licenses_at_next_renewal
|
||||
LicenseLedger.objects.create(
|
||||
plan=plan,
|
||||
event_time=event_time,
|
||||
licenses=plan.licenses(),
|
||||
licenses_at_next_renewal=licenses_at_next_renewal,
|
||||
)
|
||||
else:
|
||||
raise AssertionError("Pass licenses or licenses_at_next_renewal")
|
||||
do_change_plan_type(realm, Realm.STANDARD, acting_user=user)
|
||||
|
||||
|
||||
def update_license_ledger_for_automanaged_plan(
|
||||
@@ -805,23 +637,9 @@ def update_license_ledger_if_needed(realm: Realm, event_time: datetime) -> None:
|
||||
update_license_ledger_for_automanaged_plan(realm, plan, event_time)
|
||||
|
||||
|
||||
def get_plan_renewal_or_end_date(plan: CustomerPlan, event_time: datetime) -> datetime:
|
||||
billing_period_end = start_of_next_billing_cycle(plan, event_time)
|
||||
|
||||
if plan.end_date is not None and plan.end_date < billing_period_end:
|
||||
return plan.end_date
|
||||
return billing_period_end
|
||||
|
||||
|
||||
def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
||||
if plan.invoicing_status == CustomerPlan.STARTED:
|
||||
raise NotImplementedError("Plan with invoicing_status==STARTED needs manual resolution.")
|
||||
if not plan.customer.stripe_customer_id:
|
||||
assert plan.customer.realm is not None
|
||||
raise BillingError(
|
||||
f"Realm {plan.customer.realm.string_id} has a paid plan without a Stripe customer."
|
||||
)
|
||||
|
||||
make_end_of_cycle_updates_if_needed(plan, event_time)
|
||||
|
||||
if plan.invoicing_status == CustomerPlan.INITIAL_INVOICE_TO_BE_SENT:
|
||||
@@ -846,30 +664,27 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
||||
"unit_amount": plan.price_per_license,
|
||||
"quantity": ledger_entry.licenses,
|
||||
}
|
||||
description = f"{plan.name} - renewal"
|
||||
description = "Zulip Standard - renewal"
|
||||
elif licenses_base is not None and ledger_entry.licenses != licenses_base:
|
||||
assert plan.price_per_license
|
||||
last_ledger_entry_renewal = (
|
||||
last_renewal = (
|
||||
LicenseLedger.objects.filter(
|
||||
plan=plan, is_renewal=True, event_time__lte=ledger_entry.event_time
|
||||
)
|
||||
.order_by("-id")
|
||||
.first()
|
||||
.event_time
|
||||
)
|
||||
assert last_ledger_entry_renewal is not None
|
||||
last_renewal = last_ledger_entry_renewal.event_time
|
||||
billing_period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
||||
plan_renewal_or_end_date = get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
|
||||
proration_fraction = (plan_renewal_or_end_date - ledger_entry.event_time) / (
|
||||
billing_period_end - last_renewal
|
||||
period_end = start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
||||
proration_fraction = (period_end - ledger_entry.event_time) / (
|
||||
period_end - last_renewal
|
||||
)
|
||||
price_args = {
|
||||
"unit_amount": int(plan.price_per_license * proration_fraction + 0.5),
|
||||
"quantity": ledger_entry.licenses - licenses_base,
|
||||
}
|
||||
description = "Additional license ({} - {})".format(
|
||||
ledger_entry.event_time.strftime("%b %-d, %Y"),
|
||||
plan_renewal_or_end_date.strftime("%b %-d, %Y"),
|
||||
ledger_entry.event_time.strftime("%b %-d, %Y"), period_end.strftime("%b %-d, %Y")
|
||||
)
|
||||
|
||||
if price_args:
|
||||
@@ -884,7 +699,7 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
||||
period={
|
||||
"start": datetime_to_timestamp(ledger_entry.event_time),
|
||||
"end": datetime_to_timestamp(
|
||||
get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
|
||||
start_of_next_billing_cycle(plan, ledger_entry.event_time)
|
||||
),
|
||||
},
|
||||
idempotency_key=get_idempotency_key(ledger_entry),
|
||||
@@ -898,17 +713,17 @@ def invoice_plan(plan: CustomerPlan, event_time: datetime) -> None:
|
||||
|
||||
if invoice_item_created:
|
||||
if plan.charge_automatically:
|
||||
collection_method = "charge_automatically"
|
||||
billing_method = "charge_automatically"
|
||||
days_until_due = None
|
||||
else:
|
||||
collection_method = "send_invoice"
|
||||
billing_method = "send_invoice"
|
||||
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
|
||||
stripe_invoice = stripe.Invoice.create(
|
||||
auto_advance=True,
|
||||
collection_method=collection_method,
|
||||
billing=billing_method,
|
||||
customer=plan.customer.stripe_customer_id,
|
||||
days_until_due=days_until_due,
|
||||
statement_descriptor=plan.name,
|
||||
statement_descriptor="Zulip Standard",
|
||||
)
|
||||
stripe.Invoice.finalize_invoice(stripe_invoice)
|
||||
|
||||
@@ -921,11 +736,6 @@ def invoice_plans_as_needed(event_time: datetime = timezone_now()) -> None:
|
||||
invoice_plan(plan, event_time)
|
||||
|
||||
|
||||
def is_realm_on_free_trial(realm: Realm) -> bool:
|
||||
plan = get_current_plan_by_realm(realm)
|
||||
return plan is not None and plan.is_free_trial()
|
||||
|
||||
|
||||
def attach_discount_to_realm(
|
||||
realm: Realm, discount: Decimal, *, acting_user: Optional[UserProfile]
|
||||
) -> None:
|
||||
@@ -969,10 +779,9 @@ def update_sponsorship_status(
|
||||
|
||||
|
||||
def approve_sponsorship(realm: Realm, *, acting_user: Optional[UserProfile]) -> None:
|
||||
from zerver.actions.message_send import internal_send_private_message
|
||||
from zerver.actions.realm_settings import do_change_realm_plan_type
|
||||
from zerver.lib.actions import do_change_plan_type, internal_send_private_message
|
||||
|
||||
do_change_realm_plan_type(realm, Realm.PLAN_TYPE_STANDARD_FREE, acting_user=acting_user)
|
||||
do_change_plan_type(realm, Realm.STANDARD_FREE, acting_user=acting_user)
|
||||
customer = get_customer_by_realm(realm)
|
||||
if customer is not None and customer.sponsorship_pending:
|
||||
customer.sponsorship_pending = False
|
||||
@@ -983,9 +792,9 @@ def approve_sponsorship(realm: Realm, *, acting_user: Optional[UserProfile]) ->
|
||||
event_type=RealmAuditLog.REALM_SPONSORSHIP_APPROVED,
|
||||
event_time=timezone_now(),
|
||||
)
|
||||
notification_bot = get_system_bot(settings.NOTIFICATION_BOT, realm.id)
|
||||
for user in realm.get_human_billing_admin_and_realm_owner_users():
|
||||
with override_language(user.default_language):
|
||||
notification_bot = get_system_bot(settings.NOTIFICATION_BOT)
|
||||
for billing_admin in realm.get_human_billing_admin_users():
|
||||
with override_language(billing_admin.default_language):
|
||||
# Using variable to make life easier for translators if these details change.
|
||||
plan_name = "Zulip Cloud Standard"
|
||||
emoji = ":tada:"
|
||||
@@ -993,11 +802,7 @@ def approve_sponsorship(realm: Realm, *, acting_user: Optional[UserProfile]) ->
|
||||
f"Your organization's request for sponsored hosting has been approved! {emoji}.\n"
|
||||
f"You have been upgraded to {plan_name}, free of charge."
|
||||
)
|
||||
internal_send_private_message(notification_bot, user, message)
|
||||
|
||||
|
||||
def is_sponsored_realm(realm: Realm) -> bool:
|
||||
return realm.plan_type == Realm.PLAN_TYPE_STANDARD_FREE
|
||||
internal_send_private_message(notification_bot, billing_admin, message)
|
||||
|
||||
|
||||
def get_discount_for_realm(realm: Realm) -> Optional[Decimal]:
|
||||
@@ -1019,10 +824,9 @@ def do_change_plan_status(plan: CustomerPlan, status: int) -> None:
|
||||
|
||||
|
||||
def process_downgrade(plan: CustomerPlan) -> None:
|
||||
from zerver.actions.realm_settings import do_change_realm_plan_type
|
||||
from zerver.lib.actions import do_change_plan_type
|
||||
|
||||
assert plan.customer.realm is not None
|
||||
do_change_realm_plan_type(plan.customer.realm, Realm.PLAN_TYPE_LIMITED, acting_user=None)
|
||||
do_change_plan_type(plan.customer.realm, Realm.LIMITED, acting_user=None)
|
||||
plan.status = CustomerPlan.ENDED
|
||||
plan.save(update_fields=["status"])
|
||||
|
||||
@@ -1042,16 +846,6 @@ def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]: # nocoverag
|
||||
return annual_revenue
|
||||
|
||||
|
||||
def get_realms_to_default_discount_dict() -> Dict[str, Decimal]:
|
||||
realms_to_default_discount: Dict[str, Any] = {}
|
||||
customers = Customer.objects.exclude(default_discount=None).exclude(default_discount=0)
|
||||
for customer in customers:
|
||||
realms_to_default_discount[customer.realm.string_id] = assert_is_not_none(
|
||||
customer.default_discount
|
||||
)
|
||||
return realms_to_default_discount
|
||||
|
||||
|
||||
# During realm deactivation we instantly downgrade the plan to Limited.
|
||||
# Extra users added in the final month are not charged. Also used
|
||||
# for the cancellation of Free Trial.
|
||||
@@ -1072,25 +866,11 @@ def downgrade_at_the_end_of_billing_cycle(realm: Realm) -> None:
|
||||
do_change_plan_status(plan, CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE)
|
||||
|
||||
|
||||
def get_all_invoices_for_customer(customer: Customer) -> Generator[stripe.Invoice, None, None]:
|
||||
if customer.stripe_customer_id is None:
|
||||
return
|
||||
|
||||
invoices = stripe.Invoice.list(customer=customer.stripe_customer_id, limit=100)
|
||||
while len(invoices):
|
||||
for invoice in invoices:
|
||||
yield invoice
|
||||
last_invoice = invoice
|
||||
invoices = stripe.Invoice.list(
|
||||
customer=customer.stripe_customer_id, starting_after=last_invoice, limit=100
|
||||
)
|
||||
|
||||
|
||||
def void_all_open_invoices(realm: Realm) -> int:
|
||||
customer = get_customer_by_realm(realm)
|
||||
if customer is None:
|
||||
return 0
|
||||
invoices = get_all_invoices_for_customer(customer)
|
||||
invoices = stripe.Invoice.list(customer=customer.stripe_customer_id)
|
||||
voided_invoices_count = 0
|
||||
for invoice in invoices:
|
||||
if invoice.status == "open":
|
||||
@@ -1099,99 +879,6 @@ def void_all_open_invoices(realm: Realm) -> int:
|
||||
return voided_invoices_count
|
||||
|
||||
|
||||
def customer_has_last_n_invoices_open(customer: Customer, n: int) -> bool:
|
||||
if customer.stripe_customer_id is None: # nocoverage
|
||||
return False
|
||||
|
||||
open_invoice_count = 0
|
||||
for invoice in stripe.Invoice.list(customer=customer.stripe_customer_id, limit=n):
|
||||
if invoice.status == "open":
|
||||
open_invoice_count += 1
|
||||
return open_invoice_count == n
|
||||
|
||||
|
||||
def downgrade_small_realms_behind_on_payments_as_needed() -> None:
|
||||
customers = Customer.objects.all().exclude(stripe_customer_id=None)
|
||||
for customer in customers:
|
||||
realm = customer.realm
|
||||
|
||||
# For larger realms, we generally want to talk to the customer
|
||||
# before downgrading or cancelling invoices; so this logic only applies with 5.
|
||||
if get_latest_seat_count(realm) >= 5:
|
||||
continue
|
||||
|
||||
if get_current_plan_by_customer(customer) is not None:
|
||||
# Only customers with last 2 invoices open should be downgraded.
|
||||
if not customer_has_last_n_invoices_open(customer, 2):
|
||||
continue
|
||||
|
||||
# We've now decided to downgrade this customer and void all invoices, and the below will execute this.
|
||||
|
||||
downgrade_now_without_creating_additional_invoices(realm)
|
||||
void_all_open_invoices(realm)
|
||||
context: Dict[str, Union[str, Realm]] = {
|
||||
"upgrade_url": f"{realm.uri}{reverse('initial_upgrade')}",
|
||||
"realm": realm,
|
||||
}
|
||||
send_email_to_billing_admins_and_realm_owners(
|
||||
"zerver/emails/realm_auto_downgraded",
|
||||
realm,
|
||||
from_name=FromAddress.security_email_from_name(language=realm.default_language),
|
||||
from_address=FromAddress.tokenized_no_reply_address(),
|
||||
language=realm.default_language,
|
||||
context=context,
|
||||
)
|
||||
else:
|
||||
if customer_has_last_n_invoices_open(customer, 1):
|
||||
void_all_open_invoices(realm)
|
||||
|
||||
|
||||
def switch_realm_from_standard_to_plus_plan(realm: Realm) -> None:
|
||||
standard_plan = get_current_plan_by_realm(realm)
|
||||
|
||||
if (
|
||||
not standard_plan
|
||||
or standard_plan.status != CustomerPlan.ACTIVE
|
||||
or standard_plan.tier != CustomerPlan.STANDARD
|
||||
):
|
||||
raise BillingError("Organization does not have an active Standard plan")
|
||||
|
||||
if not standard_plan.customer.stripe_customer_id:
|
||||
raise BillingError("Organization missing Stripe customer.")
|
||||
|
||||
plan_switch_time = timezone_now()
|
||||
|
||||
standard_plan.status = CustomerPlan.SWITCH_NOW_FROM_STANDARD_TO_PLUS
|
||||
standard_plan.next_invoice_date = plan_switch_time
|
||||
standard_plan.save(update_fields=["status", "next_invoice_date"])
|
||||
|
||||
standard_plan_next_renewal_date = start_of_next_billing_cycle(standard_plan, plan_switch_time)
|
||||
|
||||
standard_plan_last_renewal_ledger = (
|
||||
LicenseLedger.objects.filter(is_renewal=True, plan=standard_plan).order_by("id").last()
|
||||
)
|
||||
standard_plan_last_renewal_amount = (
|
||||
standard_plan_last_renewal_ledger.licenses * standard_plan.price_per_license
|
||||
)
|
||||
standard_plan_last_renewal_date = standard_plan_last_renewal_ledger.event_time
|
||||
unused_proration_fraction = 1 - (plan_switch_time - standard_plan_last_renewal_date) / (
|
||||
standard_plan_next_renewal_date - standard_plan_last_renewal_date
|
||||
)
|
||||
amount_to_credit_back_to_realm = math.ceil(
|
||||
standard_plan_last_renewal_amount * unused_proration_fraction
|
||||
)
|
||||
stripe.Customer.create_balance_transaction(
|
||||
standard_plan.customer.stripe_customer_id,
|
||||
amount=-1 * amount_to_credit_back_to_realm,
|
||||
currency="usd",
|
||||
description="Credit from early termination of Standard plan",
|
||||
)
|
||||
invoice_plan(standard_plan, plan_switch_time)
|
||||
plus_plan = get_current_plan_by_realm(realm)
|
||||
assert plus_plan is not None # for mypy
|
||||
invoice_plan(plus_plan, plan_switch_time)
|
||||
|
||||
|
||||
def update_billing_method_of_current_plan(
|
||||
realm: Realm, charge_automatically: bool, *, acting_user: Optional[UserProfile]
|
||||
) -> None:
|
||||
|
||||
@@ -1,181 +0,0 @@
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Union
|
||||
|
||||
import stripe
|
||||
from django.conf import settings
|
||||
|
||||
from corporate.lib.stripe import (
|
||||
BillingError,
|
||||
UpgradeWithExistingPlanError,
|
||||
ensure_realm_does_not_have_active_plan,
|
||||
process_initial_upgrade,
|
||||
update_or_create_stripe_customer,
|
||||
)
|
||||
from corporate.models import Event, PaymentIntent, Session
|
||||
from zerver.models import get_active_user_profile_by_id_in_realm
|
||||
|
||||
billing_logger = logging.getLogger("corporate.stripe")
|
||||
|
||||
|
||||
def error_handler(
|
||||
func: Callable[[Any, Any], None],
|
||||
) -> Callable[[Union[stripe.checkout.Session, stripe.PaymentIntent], Event], None]:
|
||||
def wrapper(
|
||||
stripe_object: Union[stripe.checkout.Session, stripe.PaymentIntent], event: Event
|
||||
) -> None:
|
||||
event.status = Event.EVENT_HANDLER_STARTED
|
||||
event.save(update_fields=["status"])
|
||||
|
||||
try:
|
||||
func(stripe_object, event.content_object)
|
||||
except BillingError as e:
|
||||
billing_logger.warning(
|
||||
"BillingError in %s event handler: %s. stripe_object_id=%s, customer_id=%s metadata=%s",
|
||||
event.type,
|
||||
e.error_description,
|
||||
stripe_object.id,
|
||||
stripe_object.customer,
|
||||
stripe_object.metadata,
|
||||
)
|
||||
event.status = Event.EVENT_HANDLER_FAILED
|
||||
event.handler_error = {
|
||||
"message": e.msg,
|
||||
"description": e.error_description,
|
||||
}
|
||||
event.save(update_fields=["status", "handler_error"])
|
||||
except Exception:
|
||||
billing_logger.exception(
|
||||
"Uncaught exception in %s event handler:",
|
||||
event.type,
|
||||
stack_info=True,
|
||||
)
|
||||
event.status = Event.EVENT_HANDLER_FAILED
|
||||
event.handler_error = {
|
||||
"description": f"uncaught exception in {event.type} event handler",
|
||||
"message": BillingError.CONTACT_SUPPORT.format(email=settings.ZULIP_ADMINISTRATOR),
|
||||
}
|
||||
event.save(update_fields=["status", "handler_error"])
|
||||
else:
|
||||
event.status = Event.EVENT_HANDLER_SUCCEEDED
|
||||
event.save()
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@error_handler
|
||||
def handle_checkout_session_completed_event(
|
||||
stripe_session: stripe.checkout.Session, session: Session
|
||||
) -> None:
|
||||
session.status = Session.COMPLETED
|
||||
session.save()
|
||||
|
||||
stripe_setup_intent = stripe.SetupIntent.retrieve(stripe_session.setup_intent)
|
||||
assert session.customer.realm is not None
|
||||
user_id = stripe_session.metadata.get("user_id")
|
||||
assert user_id is not None
|
||||
user = get_active_user_profile_by_id_in_realm(user_id, session.customer.realm)
|
||||
payment_method = stripe_setup_intent.payment_method
|
||||
|
||||
if session.type in [
|
||||
Session.UPGRADE_FROM_BILLING_PAGE,
|
||||
Session.RETRY_UPGRADE_WITH_ANOTHER_PAYMENT_METHOD,
|
||||
]:
|
||||
ensure_realm_does_not_have_active_plan(user.realm)
|
||||
update_or_create_stripe_customer(user, payment_method)
|
||||
session.payment_intent.status = PaymentIntent.PROCESSING
|
||||
session.payment_intent.last_payment_error = ()
|
||||
session.payment_intent.save(update_fields=["status", "last_payment_error"])
|
||||
try:
|
||||
stripe.PaymentIntent.confirm(
|
||||
session.payment_intent.stripe_payment_intent_id,
|
||||
payment_method=payment_method,
|
||||
off_session=True,
|
||||
)
|
||||
except stripe.error.CardError:
|
||||
pass
|
||||
elif session.type in [
|
||||
Session.FREE_TRIAL_UPGRADE_FROM_BILLING_PAGE,
|
||||
Session.FREE_TRIAL_UPGRADE_FROM_ONBOARDING_PAGE,
|
||||
]:
|
||||
ensure_realm_does_not_have_active_plan(user.realm)
|
||||
update_or_create_stripe_customer(user, payment_method)
|
||||
process_initial_upgrade(
|
||||
user,
|
||||
int(stripe_setup_intent.metadata["licenses"]),
|
||||
stripe_setup_intent.metadata["license_management"] == "automatic",
|
||||
int(stripe_setup_intent.metadata["billing_schedule"]),
|
||||
charge_automatically=True,
|
||||
free_trial=True,
|
||||
)
|
||||
elif session.type in [Session.CARD_UPDATE_FROM_BILLING_PAGE]:
|
||||
update_or_create_stripe_customer(user, payment_method)
|
||||
|
||||
|
||||
@error_handler
|
||||
def handle_payment_intent_succeeded_event(
|
||||
stripe_payment_intent: stripe.PaymentIntent, payment_intent: PaymentIntent
|
||||
) -> None:
|
||||
payment_intent.status = PaymentIntent.SUCCEEDED
|
||||
payment_intent.save()
|
||||
metadata: Dict[str, Any] = stripe_payment_intent.metadata
|
||||
assert payment_intent.customer.realm is not None
|
||||
user_id = metadata.get("user_id")
|
||||
assert user_id is not None
|
||||
user = get_active_user_profile_by_id_in_realm(user_id, payment_intent.customer.realm)
|
||||
|
||||
description = ""
|
||||
for charge in stripe_payment_intent.charges:
|
||||
description = f"Payment (Card ending in {charge.payment_method_details.card.last4})"
|
||||
break
|
||||
|
||||
stripe.InvoiceItem.create(
|
||||
amount=stripe_payment_intent.amount * -1,
|
||||
currency="usd",
|
||||
customer=stripe_payment_intent.customer,
|
||||
description=description,
|
||||
discountable=False,
|
||||
)
|
||||
try:
|
||||
ensure_realm_does_not_have_active_plan(user.realm)
|
||||
except UpgradeWithExistingPlanError as e:
|
||||
stripe_invoice = stripe.Invoice.create(
|
||||
auto_advance=True,
|
||||
collection_method="charge_automatically",
|
||||
customer=stripe_payment_intent.customer,
|
||||
days_until_due=None,
|
||||
statement_descriptor="Zulip Cloud Standard Credit",
|
||||
)
|
||||
stripe.Invoice.finalize_invoice(stripe_invoice)
|
||||
raise e
|
||||
|
||||
process_initial_upgrade(
|
||||
user,
|
||||
int(metadata["licenses"]),
|
||||
metadata["license_management"] == "automatic",
|
||||
int(metadata["billing_schedule"]),
|
||||
True,
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
@error_handler
|
||||
def handle_payment_intent_payment_failed_event(
|
||||
stripe_payment_intent: stripe.PaymentIntent, payment_intent: Event
|
||||
) -> None:
|
||||
payment_intent.status = PaymentIntent.get_status_integer_from_status_text(
|
||||
stripe_payment_intent.status
|
||||
)
|
||||
billing_logger.info(
|
||||
"Stripe payment intent failed: %s %s %s %s",
|
||||
payment_intent.customer.realm.string_id,
|
||||
stripe_payment_intent.last_payment_error.get("type"),
|
||||
stripe_payment_intent.last_payment_error.get("code"),
|
||||
stripe_payment_intent.last_payment_error.get("param"),
|
||||
)
|
||||
payment_intent.last_payment_error = {
|
||||
"description": stripe_payment_intent.last_payment_error.get("type"),
|
||||
}
|
||||
payment_intent.last_payment_error["message"] = stripe_payment_intent.last_payment_error.get(
|
||||
"message"
|
||||
)
|
||||
payment_intent.save(update_fields=["status", "last_payment_error"])
|
||||
@@ -1,15 +0,0 @@
|
||||
from urllib.parse import urlencode, urljoin, urlunsplit
|
||||
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from zerver.models import Realm, get_realm
|
||||
|
||||
|
||||
def get_support_url(realm: Realm) -> str:
|
||||
support_realm_uri = get_realm(settings.STAFF_SUBDOMAIN).uri
|
||||
support_url = urljoin(
|
||||
support_realm_uri,
|
||||
urlunsplit(("", "", reverse("support"), urlencode({"q": realm.string_id}), "")),
|
||||
)
|
||||
return support_url
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.2 on 2021-06-08 08:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("corporate", "0009_customer_sponsorship_pending"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customerplan",
|
||||
name="exempt_from_from_license_number_check",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,27 +0,0 @@
|
||||
# Generated by Django 3.2.4 on 2021-06-18 18:39
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
"""
|
||||
We haven't set the values for this field for the relevant organizations
|
||||
as of this moment, so we can simply drop the column from CustomerPlan
|
||||
and add it to Customer without worrying about losing the values.
|
||||
"""
|
||||
|
||||
dependencies = [
|
||||
("corporate", "0010_customerplan_exempt_from_from_license_number_check"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveField(
|
||||
model_name="customerplan",
|
||||
name="exempt_from_from_license_number_check",
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="customer",
|
||||
name="exempt_from_from_license_number_check",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
]
|
||||
@@ -1,63 +0,0 @@
|
||||
# Generated by Django 3.2.5 on 2021-07-15 17:15
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("zerver", "0333_alter_realm_org_type"),
|
||||
("corporate", "0011_move_exempt_from_from_license_number_check_to_customer_model"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ZulipSponsorshipRequest",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
(
|
||||
"org_type",
|
||||
models.PositiveSmallIntegerField(
|
||||
choices=[
|
||||
(0, "Unspecified"),
|
||||
(10, "Business"),
|
||||
(20, "Open-source project"),
|
||||
(30, "Education (non-profit)"),
|
||||
(35, "Education (for-profit)"),
|
||||
(40, "Research"),
|
||||
(50, "Event or conference"),
|
||||
(60, "Non-profit (registered)"),
|
||||
(70, "Government"),
|
||||
(80, "Political group"),
|
||||
(90, "Community"),
|
||||
(100, "Personal"),
|
||||
(1000, "Other"),
|
||||
],
|
||||
default=0,
|
||||
),
|
||||
),
|
||||
("org_website", models.URLField()),
|
||||
("org_description", models.TextField(default="")),
|
||||
(
|
||||
"realm",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
|
||||
),
|
||||
),
|
||||
(
|
||||
"requested_by",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.5 on 2021-08-06 19:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("corporate", "0012_zulipsponsorshiprequest"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="zulipsponsorshiprequest",
|
||||
name="org_website",
|
||||
field=models.URLField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 3.2.6 on 2021-09-17 10:52
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("corporate", "0013_alter_zulipsponsorshiprequest_org_website"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customerplan",
|
||||
name="end_date",
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -1,85 +0,0 @@
|
||||
# Generated by Django 3.2.9 on 2021-11-04 16:23
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("contenttypes", "0002_remove_content_type_name"),
|
||||
("corporate", "0014_customerplan_end_date"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="PaymentIntent",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("stripe_payment_intent_id", models.CharField(max_length=255, unique=True)),
|
||||
("status", models.SmallIntegerField()),
|
||||
("last_payment_error", models.JSONField(default=None, null=True)),
|
||||
(
|
||||
"customer",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Session",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("stripe_session_id", models.CharField(max_length=255, unique=True)),
|
||||
("type", models.SmallIntegerField()),
|
||||
("status", models.SmallIntegerField(default=1)),
|
||||
(
|
||||
"customer",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="corporate.customer"
|
||||
),
|
||||
),
|
||||
(
|
||||
"payment_intent",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="corporate.paymentintent",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="Event",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
|
||||
),
|
||||
),
|
||||
("stripe_event_id", models.CharField(max_length=255)),
|
||||
("type", models.CharField(max_length=255)),
|
||||
("status", models.SmallIntegerField(default=1)),
|
||||
("object_id", models.PositiveIntegerField(db_index=True)),
|
||||
("handler_error", models.JSONField(default=None, null=True)),
|
||||
(
|
||||
"content_type",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE, to="contenttypes.contenttype"
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -1,32 +0,0 @@
|
||||
# Generated by Django 3.2.9 on 2021-11-27 00:08
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("zilencer", "0018_remoterealmauditlog"),
|
||||
("zerver", "0370_realm_enable_spectator_access"),
|
||||
("corporate", "0015_event_paymentintent_session"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="customer",
|
||||
name="remote_server",
|
||||
field=models.OneToOneField(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="zilencer.remotezulipserver",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="customer",
|
||||
name="realm",
|
||||
field=models.OneToOneField(
|
||||
null=True, on_delete=django.db.models.deletion.CASCADE, to="zerver.realm"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,52 +1,21 @@
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any, Dict, Optional, Union
|
||||
from typing import Optional
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import models
|
||||
from django.db.models import CASCADE
|
||||
|
||||
from zerver.models import Realm, UserProfile
|
||||
from zilencer.models import RemoteZulipServer
|
||||
from zerver.models import Realm
|
||||
|
||||
|
||||
class Customer(models.Model):
|
||||
"""
|
||||
This model primarily serves to connect a Realm with
|
||||
the corresponding Stripe customer object for payment purposes
|
||||
and the active plan, if any.
|
||||
"""
|
||||
|
||||
realm: Optional[Realm] = models.OneToOneField(Realm, on_delete=CASCADE, null=True)
|
||||
remote_server: Optional[RemoteZulipServer] = models.OneToOneField(
|
||||
RemoteZulipServer, on_delete=CASCADE, null=True
|
||||
)
|
||||
stripe_customer_id: Optional[str] = models.CharField(max_length=255, null=True, unique=True)
|
||||
realm: Realm = models.OneToOneField(Realm, on_delete=CASCADE)
|
||||
stripe_customer_id: str = models.CharField(max_length=255, null=True, unique=True)
|
||||
sponsorship_pending: bool = models.BooleanField(default=False)
|
||||
# A percentage, like 85.
|
||||
default_discount: Optional[Decimal] = models.DecimalField(
|
||||
decimal_places=4, max_digits=7, null=True
|
||||
)
|
||||
# Some non-profit organizations on manual license management pay
|
||||
# only for their paid employees. We don't prevent these
|
||||
# organizations from adding more users than the number of licenses
|
||||
# they purchased.
|
||||
exempt_from_from_license_number_check: bool = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def is_self_hosted(self) -> bool:
|
||||
is_self_hosted = self.remote_server is not None
|
||||
if is_self_hosted:
|
||||
assert self.realm is None
|
||||
return is_self_hosted
|
||||
|
||||
@property
|
||||
def is_cloud(self) -> bool:
|
||||
is_cloud = self.realm is not None
|
||||
if is_cloud:
|
||||
assert self.remote_server is None
|
||||
return is_cloud
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"<Customer {self.realm} {self.stripe_customer_id}>"
|
||||
@@ -56,152 +25,8 @@ def get_customer_by_realm(realm: Realm) -> Optional[Customer]:
|
||||
return Customer.objects.filter(realm=realm).first()
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
stripe_event_id = models.CharField(max_length=255)
|
||||
|
||||
type = models.CharField(max_length=255)
|
||||
|
||||
RECEIVED = 1
|
||||
EVENT_HANDLER_STARTED = 30
|
||||
EVENT_HANDLER_FAILED = 40
|
||||
EVENT_HANDLER_SUCCEEDED = 50
|
||||
status = models.SmallIntegerField(default=RECEIVED)
|
||||
|
||||
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
|
||||
object_id = models.PositiveIntegerField(db_index=True)
|
||||
content_object = GenericForeignKey("content_type", "object_id")
|
||||
|
||||
handler_error = models.JSONField(default=None, null=True)
|
||||
|
||||
def get_event_handler_details_as_dict(self) -> Dict[str, Any]:
|
||||
details_dict = {}
|
||||
details_dict["status"] = {
|
||||
Event.RECEIVED: "not_started",
|
||||
Event.EVENT_HANDLER_STARTED: "started",
|
||||
Event.EVENT_HANDLER_FAILED: "failed",
|
||||
Event.EVENT_HANDLER_SUCCEEDED: "succeeded",
|
||||
}[self.status]
|
||||
if self.handler_error:
|
||||
details_dict["error"] = self.handler_error
|
||||
return details_dict
|
||||
|
||||
|
||||
def get_last_associated_event_by_type(
|
||||
content_object: Union["PaymentIntent", "Session"], event_type: str
|
||||
) -> Optional[Event]:
|
||||
content_type = ContentType.objects.get_for_model(type(content_object))
|
||||
return Event.objects.filter(
|
||||
content_type=content_type, object_id=content_object.id, type=event_type
|
||||
).last()
|
||||
|
||||
|
||||
class Session(models.Model):
|
||||
customer: Customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
||||
stripe_session_id: str = models.CharField(max_length=255, unique=True)
|
||||
payment_intent = models.ForeignKey("PaymentIntent", null=True, on_delete=CASCADE)
|
||||
|
||||
UPGRADE_FROM_BILLING_PAGE = 1
|
||||
RETRY_UPGRADE_WITH_ANOTHER_PAYMENT_METHOD = 10
|
||||
FREE_TRIAL_UPGRADE_FROM_BILLING_PAGE = 20
|
||||
FREE_TRIAL_UPGRADE_FROM_ONBOARDING_PAGE = 30
|
||||
CARD_UPDATE_FROM_BILLING_PAGE = 40
|
||||
type: int = models.SmallIntegerField()
|
||||
|
||||
CREATED = 1
|
||||
COMPLETED = 10
|
||||
status: int = models.SmallIntegerField(default=CREATED)
|
||||
|
||||
def get_status_as_string(self) -> str:
|
||||
return {Session.CREATED: "created", Session.COMPLETED: "completed"}[self.status]
|
||||
|
||||
def get_type_as_string(self) -> str:
|
||||
return {
|
||||
Session.UPGRADE_FROM_BILLING_PAGE: "upgrade_from_billing_page",
|
||||
Session.RETRY_UPGRADE_WITH_ANOTHER_PAYMENT_METHOD: "retry_upgrade_with_another_payment_method",
|
||||
Session.FREE_TRIAL_UPGRADE_FROM_BILLING_PAGE: "free_trial_upgrade_from_billing_page",
|
||||
Session.FREE_TRIAL_UPGRADE_FROM_ONBOARDING_PAGE: "free_trial_upgrade_from_onboarding_page",
|
||||
Session.CARD_UPDATE_FROM_BILLING_PAGE: "card_update_from_billing_page",
|
||||
}[self.type]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
session_dict: Dict[str, Any] = {}
|
||||
|
||||
session_dict["status"] = self.get_status_as_string()
|
||||
session_dict["type"] = self.get_type_as_string()
|
||||
if self.payment_intent:
|
||||
session_dict["stripe_payment_intent_id"] = self.payment_intent.stripe_payment_intent_id
|
||||
event = self.get_last_associated_event()
|
||||
if event is not None:
|
||||
session_dict["event_handler"] = event.get_event_handler_details_as_dict()
|
||||
return session_dict
|
||||
|
||||
def get_last_associated_event(self) -> Optional[Event]:
|
||||
if self.status == Session.CREATED:
|
||||
return None
|
||||
return get_last_associated_event_by_type(self, "checkout.session.completed")
|
||||
|
||||
|
||||
class PaymentIntent(models.Model):
|
||||
customer: Customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
||||
stripe_payment_intent_id: str = models.CharField(max_length=255, unique=True)
|
||||
|
||||
REQUIRES_PAYMENT_METHOD = 1
|
||||
REQUIRES_CONFIRMATION = 20
|
||||
REQUIRES_ACTION = 30
|
||||
PROCESSING = 40
|
||||
REQUIRES_CAPTURE = 50
|
||||
CANCELLED = 60
|
||||
SUCCEEDED = 70
|
||||
|
||||
status: int = models.SmallIntegerField()
|
||||
last_payment_error = models.JSONField(default=None, null=True)
|
||||
|
||||
@classmethod
|
||||
def get_status_integer_from_status_text(cls, status_text: str) -> int:
|
||||
return getattr(cls, status_text.upper())
|
||||
|
||||
def get_status_as_string(self) -> str:
|
||||
return {
|
||||
PaymentIntent.REQUIRES_PAYMENT_METHOD: "requires_payment_method",
|
||||
PaymentIntent.REQUIRES_CONFIRMATION: "requires_confirmation",
|
||||
PaymentIntent.REQUIRES_ACTION: "requires_action",
|
||||
PaymentIntent.PROCESSING: "processing",
|
||||
PaymentIntent.REQUIRES_CAPTURE: "requires_capture",
|
||||
PaymentIntent.CANCELLED: "cancelled",
|
||||
PaymentIntent.SUCCEEDED: "succeeded",
|
||||
}[self.status]
|
||||
|
||||
def get_last_associated_event(self) -> Optional[Event]:
|
||||
if self.status == PaymentIntent.SUCCEEDED:
|
||||
event_type = "payment_intent.succeeded"
|
||||
elif self.status == PaymentIntent.REQUIRES_PAYMENT_METHOD:
|
||||
event_type = "payment_intent.payment_failed"
|
||||
else:
|
||||
return None
|
||||
return get_last_associated_event_by_type(self, event_type)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
payment_intent_dict: Dict[str, Any] = {}
|
||||
payment_intent_dict["status"] = self.get_status_as_string()
|
||||
event = self.get_last_associated_event()
|
||||
if self.last_payment_error:
|
||||
payment_intent_dict["last_payment_error"] = self.last_payment_error
|
||||
if event is not None:
|
||||
payment_intent_dict["event_handler"] = event.get_event_handler_details_as_dict()
|
||||
return payment_intent_dict
|
||||
|
||||
|
||||
class CustomerPlan(models.Model):
|
||||
"""
|
||||
This is for storing most of the fiddly details
|
||||
of the customer's plan.
|
||||
"""
|
||||
|
||||
# A customer can only have one ACTIVE plan, but old, inactive plans
|
||||
# are preserved to allow auditing - so there can be multiple
|
||||
# CustomerPlan objects pointing to one Customer.
|
||||
customer: Customer = models.ForeignKey(Customer, on_delete=CASCADE)
|
||||
|
||||
automanage_licenses: bool = models.BooleanField(default=False)
|
||||
charge_automatically: bool = models.BooleanField(default=False)
|
||||
|
||||
@@ -214,40 +39,18 @@ class CustomerPlan(models.Model):
|
||||
# Discount that was applied. For display purposes only.
|
||||
discount: Optional[Decimal] = models.DecimalField(decimal_places=4, max_digits=6, null=True)
|
||||
|
||||
# Initialized with the time of plan creation. Used for calculating
|
||||
# start of next billing cycle, next invoice date etc. This value
|
||||
# should never be modified. The only exception is when we change
|
||||
# the status of the plan from free trial to active and reset the
|
||||
# billing_cycle_anchor.
|
||||
billing_cycle_anchor: datetime.datetime = models.DateTimeField()
|
||||
|
||||
ANNUAL = 1
|
||||
MONTHLY = 2
|
||||
billing_schedule: int = models.SmallIntegerField()
|
||||
|
||||
# The next date the billing system should go through ledger
|
||||
# entries and create invoices for additional users or plan
|
||||
# renewal. Since we use a daily cron job for invoicing, the
|
||||
# invoice will be generated the first time the cron job runs after
|
||||
# next_invoice_date.
|
||||
next_invoice_date: Optional[datetime.datetime] = models.DateTimeField(db_index=True, null=True)
|
||||
|
||||
# On next_invoice_date, we go through ledger entries that were
|
||||
# created after invoiced_through and process them by generating
|
||||
# invoices for any additional users and/or plan renewal. Once the
|
||||
# invoice is generated, we update the value of invoiced_through
|
||||
# and set it to the last ledger entry we processed.
|
||||
invoiced_through: Optional["LicenseLedger"] = models.ForeignKey(
|
||||
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
|
||||
)
|
||||
end_date: Optional[datetime.datetime] = models.DateTimeField(null=True)
|
||||
|
||||
DONE = 1
|
||||
STARTED = 2
|
||||
INITIAL_INVOICE_TO_BE_SENT = 3
|
||||
# This status field helps ensure any errors encountered during the
|
||||
# invoicing process do not leave our invoicing system in a broken
|
||||
# state.
|
||||
invoicing_status: int = models.SmallIntegerField(default=DONE)
|
||||
|
||||
STANDARD = 1
|
||||
@@ -259,7 +62,6 @@ class CustomerPlan(models.Model):
|
||||
DOWNGRADE_AT_END_OF_CYCLE = 2
|
||||
FREE_TRIAL = 3
|
||||
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
|
||||
SWITCH_NOW_FROM_STANDARD_TO_PLUS = 5
|
||||
# "Live" plans should have a value < LIVE_STATUS_THRESHOLD.
|
||||
# There should be at most one live plan per customer.
|
||||
LIVE_STATUS_THRESHOLD = 10
|
||||
@@ -267,13 +69,12 @@ class CustomerPlan(models.Model):
|
||||
NEVER_STARTED = 12
|
||||
status: int = models.SmallIntegerField(default=ACTIVE)
|
||||
|
||||
# TODO maybe override setattr to ensure billing_cycle_anchor, etc
|
||||
# are immutable.
|
||||
# TODO maybe override setattr to ensure billing_cycle_anchor, etc are immutable
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return {
|
||||
CustomerPlan.STANDARD: "Zulip Cloud Standard",
|
||||
CustomerPlan.STANDARD: "Zulip Standard",
|
||||
CustomerPlan.PLUS: "Zulip Plus",
|
||||
CustomerPlan.ENTERPRISE: "Zulip Enterprise",
|
||||
}[self.tier]
|
||||
@@ -287,21 +88,6 @@ class CustomerPlan(models.Model):
|
||||
self.NEVER_STARTED: "Never started",
|
||||
}[self.status]
|
||||
|
||||
def licenses(self) -> int:
|
||||
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
|
||||
assert ledger_entry is not None
|
||||
return ledger_entry.licenses
|
||||
|
||||
def licenses_at_next_renewal(self) -> Optional[int]:
|
||||
if self.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE:
|
||||
return None
|
||||
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
|
||||
assert ledger_entry is not None
|
||||
return ledger_entry.licenses_at_next_renewal
|
||||
|
||||
def is_free_trial(self) -> bool:
|
||||
return self.status == CustomerPlan.FREE_TRIAL
|
||||
|
||||
|
||||
def get_current_plan_by_customer(customer: Customer) -> Optional[CustomerPlan]:
|
||||
return CustomerPlan.objects.filter(
|
||||
@@ -317,50 +103,11 @@ def get_current_plan_by_realm(realm: Realm) -> Optional[CustomerPlan]:
|
||||
|
||||
|
||||
class LicenseLedger(models.Model):
|
||||
"""
|
||||
This table's purpose is to store the current, and historical,
|
||||
count of "seats" purchased by the organization.
|
||||
|
||||
Because we want to keep historical data, when the purchased
|
||||
seat count changes, a new LicenseLedger object is created,
|
||||
instead of updating the old one. This lets us preserve
|
||||
the entire history of how the seat count changes, which is
|
||||
important for analytics as well as auditing and debugging
|
||||
in case of issues.
|
||||
"""
|
||||
|
||||
plan: CustomerPlan = models.ForeignKey(CustomerPlan, on_delete=CASCADE)
|
||||
|
||||
# Also True for the initial upgrade.
|
||||
is_renewal: bool = models.BooleanField(default=False)
|
||||
|
||||
event_time: datetime.datetime = models.DateTimeField()
|
||||
|
||||
# The number of licenses ("seats") purchased by the the organization at the time of ledger
|
||||
# entry creation. Normally, to add a user the organization needs at least one spare license.
|
||||
# Once a license is purchased, it is valid till the end of the billing period, irrespective
|
||||
# of whether the license is used or not. So the value of licenses will never decrease for
|
||||
# subsequent LicenseLedger entries in the same billing period.
|
||||
licenses: int = models.IntegerField()
|
||||
|
||||
# The number of licenses the organization needs in the next billing cycle. The value of
|
||||
# licenses_at_next_renewal can increase or decrease for subsequent LicenseLedger entries in
|
||||
# the same billing period. For plans on automatic license management this value is usually
|
||||
# equal to the number of activated users in the organization.
|
||||
# None means the plan does not automatically renew.
|
||||
# This cannot be None if plan.automanage_licenses.
|
||||
licenses_at_next_renewal: Optional[int] = models.IntegerField(null=True)
|
||||
|
||||
|
||||
class ZulipSponsorshipRequest(models.Model):
|
||||
id: int = models.AutoField(auto_created=True, primary_key=True, verbose_name="ID")
|
||||
realm: Realm = models.ForeignKey(Realm, on_delete=CASCADE)
|
||||
requested_by: UserProfile = models.ForeignKey(UserProfile, on_delete=CASCADE)
|
||||
|
||||
org_type: int = models.PositiveSmallIntegerField(
|
||||
default=Realm.ORG_TYPES["unspecified"]["id"],
|
||||
choices=[(t["id"], t["name"]) for t in Realm.ORG_TYPES.values()],
|
||||
)
|
||||
|
||||
MAX_ORG_URL_LENGTH: int = 200
|
||||
org_website: str = models.URLField(max_length=MAX_ORG_URL_LENGTH, blank=True, null=True)
|
||||
|
||||
org_description: str = models.TextField(default="")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"amount": 7200,
|
||||
"amount_captured": 7200,
|
||||
"amount_refunded": 0,
|
||||
"application": null,
|
||||
"application_fee": null,
|
||||
"application_fee_amount": null,
|
||||
"balance_transaction": "txn_NORMALIZED00000000000001",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": "Pacific",
|
||||
"country": "United States",
|
||||
"line1": "Under the sea,",
|
||||
"line2": null,
|
||||
"postal_code": "33333",
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": "Ada Starr",
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
"captured": true,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Standard, $12.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
"failure_code": null,
|
||||
"failure_message": null,
|
||||
"fraud_details": {},
|
||||
"id": "ch_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
"outcome": {
|
||||
"network_status": "approved_by_network",
|
||||
"reason": null,
|
||||
"risk_level": "normal",
|
||||
"risk_score": 0,
|
||||
"seller_message": "Payment complete.",
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_method": "card_NORMALIZED00000000000001",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": "pass",
|
||||
"address_postal_code_check": "pass",
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"installments": null,
|
||||
"last4": "4242",
|
||||
"network": "visa",
|
||||
"three_d_secure": null,
|
||||
"wallet": null
|
||||
},
|
||||
"type": "card"
|
||||
},
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
|
||||
},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": {
|
||||
"address_city": "Pacific",
|
||||
"address_country": "United States",
|
||||
"address_line1": "Under the sea,",
|
||||
"address_line1_check": "pass",
|
||||
"address_line2": null,
|
||||
"address_state": null,
|
||||
"address_zip": "33333",
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"id": "card_NORMALIZED00000000000001",
|
||||
"last4": "4242",
|
||||
"metadata": {},
|
||||
"name": "Ada Starr",
|
||||
"object": "card",
|
||||
"tokenization_method": null
|
||||
},
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"amount": 36000,
|
||||
"amount_captured": 36000,
|
||||
"amount_refunded": 0,
|
||||
"application": null,
|
||||
"application_fee": null,
|
||||
"application_fee_amount": null,
|
||||
"balance_transaction": "txn_NORMALIZED00000000000002",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": "Pacific",
|
||||
"country": "United States",
|
||||
"line1": "Under the sea,",
|
||||
"line2": null,
|
||||
"postal_code": "33333",
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": "Ada Starr",
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
"captured": true,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Standard, $60.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
"failure_code": null,
|
||||
"failure_message": null,
|
||||
"fraud_details": {},
|
||||
"id": "ch_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
"outcome": {
|
||||
"network_status": "approved_by_network",
|
||||
"reason": null,
|
||||
"risk_level": "normal",
|
||||
"risk_score": 0,
|
||||
"seller_message": "Payment complete.",
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_method": "card_NORMALIZED00000000000002",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": "pass",
|
||||
"address_postal_code_check": "pass",
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"installments": null,
|
||||
"last4": "4242",
|
||||
"network": "visa",
|
||||
"three_d_secure": null,
|
||||
"wallet": null
|
||||
},
|
||||
"type": "card"
|
||||
},
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000002/rcpt_NORMALIZED000000000000000000002",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000002/refunds"
|
||||
},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": {
|
||||
"address_city": "Pacific",
|
||||
"address_country": "United States",
|
||||
"address_line1": "Under the sea,",
|
||||
"address_line1_check": "pass",
|
||||
"address_line2": null,
|
||||
"address_state": null,
|
||||
"address_zip": "33333",
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"id": "card_NORMALIZED00000000000002",
|
||||
"last4": "4242",
|
||||
"metadata": {},
|
||||
"name": "Ada Starr",
|
||||
"object": "card",
|
||||
"tokenization_method": null
|
||||
},
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
@@ -10,15 +10,15 @@
|
||||
"balance_transaction": "txn_NORMALIZED00000000000001",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"city": "Pacific",
|
||||
"country": "United States",
|
||||
"line1": "Under the sea,",
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"postal_code": "33333",
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"name": "Ada Starr",
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
@@ -26,7 +26,7 @@
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"description": "Upgrade to Zulip Standard, $12.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
@@ -36,19 +36,7 @@
|
||||
"id": "ch_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"metadata": {},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
@@ -61,14 +49,14 @@
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_NORMALIZED00000000000001",
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_intent": null,
|
||||
"payment_method": "card_NORMALIZED00000000000001",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"address_line1_check": "pass",
|
||||
"address_postal_code_check": "pass",
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
@@ -88,18 +76,36 @@
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
|
||||
},
|
||||
"refunds": {},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source": {
|
||||
"address_city": "Pacific",
|
||||
"address_country": "United States",
|
||||
"address_line1": "Under the sea,",
|
||||
"address_line1_check": "pass",
|
||||
"address_line2": null,
|
||||
"address_state": null,
|
||||
"address_zip": "33333",
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"id": "card_NORMALIZED00000000000001",
|
||||
"last4": "4242",
|
||||
"metadata": {},
|
||||
"name": "Ada Starr",
|
||||
"object": "card",
|
||||
"tokenization_method": null
|
||||
},
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
"balance_transaction": "txn_NORMALIZED00000000000002",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"city": "Pacific",
|
||||
"country": "United States",
|
||||
"line1": "Under the sea,",
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"postal_code": "33333",
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"name": "Ada Starr",
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
@@ -26,7 +26,7 @@
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $60.0 x 6",
|
||||
"description": "Upgrade to Zulip Standard, $60.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
@@ -36,19 +36,7 @@
|
||||
"id": "ch_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"metadata": {},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
@@ -61,14 +49,14 @@
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_NORMALIZED00000000000002",
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_intent": null,
|
||||
"payment_method": "card_NORMALIZED00000000000002",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"address_line1_check": "pass",
|
||||
"address_postal_code_check": "pass",
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
@@ -88,18 +76,36 @@
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000002/rcpt_NORMALIZED000000000000000000002",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000002/refunds"
|
||||
},
|
||||
"refunds": {},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source": {
|
||||
"address_city": "Pacific",
|
||||
"address_country": "United States",
|
||||
"address_line1": "Under the sea,",
|
||||
"address_line1_check": "pass",
|
||||
"address_line2": null,
|
||||
"address_state": null,
|
||||
"address_zip": "33333",
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"id": "card_NORMALIZED00000000000002",
|
||||
"last4": "4242",
|
||||
"metadata": {},
|
||||
"name": "Ada Starr",
|
||||
"object": "card",
|
||||
"tokenization_method": null
|
||||
},
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
@@ -115,15 +121,15 @@
|
||||
"balance_transaction": "txn_NORMALIZED00000000000001",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"city": "Pacific",
|
||||
"country": "United States",
|
||||
"line1": "Under the sea,",
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"postal_code": "33333",
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"name": "Ada Starr",
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
@@ -131,7 +137,7 @@
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"description": "Upgrade to Zulip Standard, $12.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
@@ -141,19 +147,7 @@
|
||||
"id": "ch_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"metadata": {},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
@@ -166,14 +160,14 @@
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_NORMALIZED00000000000001",
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_intent": null,
|
||||
"payment_method": "card_NORMALIZED00000000000001",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"address_line1_check": "pass",
|
||||
"address_postal_code_check": "pass",
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
@@ -193,18 +187,36 @@
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
|
||||
},
|
||||
"refunds": {},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source": {
|
||||
"address_city": "Pacific",
|
||||
"address_country": "United States",
|
||||
"address_line1": "Under the sea,",
|
||||
"address_line1_check": "pass",
|
||||
"address_line2": null,
|
||||
"address_state": null,
|
||||
"address_zip": "33333",
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"id": "card_NORMALIZED00000000000001",
|
||||
"last4": "4242",
|
||||
"metadata": {},
|
||||
"name": "Ada Starr",
|
||||
"object": "card",
|
||||
"tokenization_method": null
|
||||
},
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
{
|
||||
"account_balance": 0,
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": null,
|
||||
"default_source": null,
|
||||
"default_source": "card_NORMALIZED00000000000001",
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
@@ -26,5 +27,54 @@
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none"
|
||||
"sources": {
|
||||
"data": [
|
||||
{
|
||||
"address_city": "Pacific",
|
||||
"address_country": "United States",
|
||||
"address_line1": "Under the sea,",
|
||||
"address_line1_check": "pass",
|
||||
"address_line2": null,
|
||||
"address_state": null,
|
||||
"address_zip": "33333",
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"id": "card_NORMALIZED00000000000001",
|
||||
"last4": "4242",
|
||||
"metadata": {},
|
||||
"name": "Ada Starr",
|
||||
"object": "card",
|
||||
"tokenization_method": null
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/sources"
|
||||
},
|
||||
"subscriptions": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
|
||||
},
|
||||
"tax_exempt": "none",
|
||||
"tax_ids": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
|
||||
},
|
||||
"tax_info": null,
|
||||
"tax_info_verification": null
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": null,
|
||||
"default_source": null,
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": null,
|
||||
"footer": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 1,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none"
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": null,
|
||||
"default_source": null,
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"footer": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 1,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none"
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"default_source": null,
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"footer": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 2,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none"
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"account_balance": 0,
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": null,
|
||||
"currency": "usd",
|
||||
"default_source": {
|
||||
"address_city": "Pacific",
|
||||
"address_country": "United States",
|
||||
@@ -14,7 +15,7 @@
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0002",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
@@ -31,9 +32,9 @@
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "iago@zulip.com",
|
||||
"id": "cus_NORMALIZED0002",
|
||||
"invoice_prefix": "NORMA02",
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": null,
|
||||
@@ -45,7 +46,7 @@
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 1,
|
||||
"next_invoice_sequence": 2,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
@@ -63,7 +64,7 @@
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0002",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
@@ -81,7 +82,23 @@
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/customers/cus_NORMALIZED0002/sources"
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/sources"
|
||||
},
|
||||
"tax_exempt": "none"
|
||||
"subscriptions": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
|
||||
},
|
||||
"tax_exempt": "none",
|
||||
"tax_ids": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
|
||||
},
|
||||
"tax_info": null,
|
||||
"tax_info_verification": null
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"account_balance": 0,
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"default_source": "card_NORMALIZED00000000000002",
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": null,
|
||||
"footer": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 2,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"sources": {
|
||||
"data": [
|
||||
{
|
||||
"address_city": "Pacific",
|
||||
"address_country": "United States",
|
||||
"address_line1": "Under the sea,",
|
||||
"address_line1_check": "pass",
|
||||
"address_line2": null,
|
||||
"address_state": null,
|
||||
"address_zip": "33333",
|
||||
"address_zip_check": "pass",
|
||||
"brand": "Visa",
|
||||
"country": "US",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"cvc_check": "pass",
|
||||
"dynamic_last4": null,
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"id": "card_NORMALIZED00000000000002",
|
||||
"last4": "4242",
|
||||
"metadata": {},
|
||||
"name": "Ada Starr",
|
||||
"object": "card",
|
||||
"tokenization_method": null
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/sources"
|
||||
},
|
||||
"subscriptions": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/subscriptions"
|
||||
},
|
||||
"tax_exempt": "none",
|
||||
"tax_ids": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/customers/cus_NORMALIZED0001/tax_ids"
|
||||
},
|
||||
"tax_info": null,
|
||||
"tax_info_verification": null
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0002",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BRzhHOXVXS0dTMk5hbEl2TEhOdnM1ZUF0dloz0100yY43uPHV",
|
||||
"id": "in_NORMALIZED00000000000001",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BRzhHOXVXS0dTMk5hbEl2TEhOdnM1ZUF0dloz0100yY43uPHV/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 48000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000001",
|
||||
"invoice_item": "ii_NORMALIZED00000000000001",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000001",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0001",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 8000,
|
||||
"unit_amount_decimal": "8000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -48000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000002",
|
||||
"invoice_item": "ii_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0002",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -48000,
|
||||
"unit_amount_decimal": "-48000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0001",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXlHSaWXyvFpKIjChqmtl",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0001",
|
||||
"idempotency_key": "cae8e48c-5622-4bdf-85a2-cb06a7ed12d4"
|
||||
},
|
||||
"type": "invoice.payment_succeeded"
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,718 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 7200,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 7200,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": null,
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "automatic",
|
||||
"charges": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"amount_captured": 7200,
|
||||
"amount_refunded": 0,
|
||||
"application": null,
|
||||
"application_fee": null,
|
||||
"application_fee_amount": null,
|
||||
"balance_transaction": "txn_NORMALIZED00000000000001",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
"captured": true,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
"failure_code": null,
|
||||
"failure_message": null,
|
||||
"fraud_details": {},
|
||||
"id": "ch_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
"outcome": {
|
||||
"network_status": "approved_by_network",
|
||||
"reason": null,
|
||||
"risk_level": "normal",
|
||||
"risk_score": 0,
|
||||
"seller_message": "Payment complete.",
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_NORMALIZED00000000000001",
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"installments": null,
|
||||
"last4": "4242",
|
||||
"network": "visa",
|
||||
"three_d_secure": null,
|
||||
"wallet": null
|
||||
},
|
||||
"type": "card"
|
||||
},
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
|
||||
},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/charges?payment_intent=pi_NORMALIZED00000000000001"
|
||||
},
|
||||
"client_secret": "pi_NORMALIZED00000000000001_secret_fD7F9AdDLLYQt94Ii4rEflkYg",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"id": "pi_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"id": "evt_3K2OXoHSaWXyvFpK1IgAmGCx",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0002",
|
||||
"idempotency_key": "bbd6840b-375d-408a-a4b2-0353118fef83"
|
||||
},
|
||||
"type": "payment_intent.succeeded"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 7200,
|
||||
"amount_captured": 7200,
|
||||
"amount_refunded": 0,
|
||||
"application": null,
|
||||
"application_fee": null,
|
||||
"application_fee_amount": null,
|
||||
"balance_transaction": "txn_NORMALIZED00000000000001",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
"captured": true,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
"failure_code": null,
|
||||
"failure_message": null,
|
||||
"fraud_details": {},
|
||||
"id": "ch_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
"outcome": {
|
||||
"network_status": "approved_by_network",
|
||||
"reason": null,
|
||||
"risk_level": "normal",
|
||||
"risk_score": 0,
|
||||
"seller_message": "Payment complete.",
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_NORMALIZED00000000000001",
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"installments": null,
|
||||
"last4": "4242",
|
||||
"network": "visa",
|
||||
"three_d_secure": null,
|
||||
"wallet": null
|
||||
},
|
||||
"type": "card"
|
||||
},
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
|
||||
},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"id": "evt_3K2OXoHSaWXyvFpK1BCXNPsv",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 2,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0002",
|
||||
"idempotency_key": "bbd6840b-375d-408a-a4b2-0353118fef83"
|
||||
},
|
||||
"type": "charge.succeeded"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": null,
|
||||
"default_source": null,
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"footer": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 1,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none"
|
||||
},
|
||||
"previous_attributes": {
|
||||
"invoice_settings": {
|
||||
"default_payment_method": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXrHSaWXyvFpKTft9z8iM",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0003",
|
||||
"idempotency_key": "41855f8d-7eaf-45a3-8f4b-ebf23c527d2a"
|
||||
},
|
||||
"type": "customer.updated"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXpHSaWXyvFpKOq6F3F9K_secret_KhoAzpsEjV8G4oAeYDSFmGYMKv5BRkc",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXpHSaWXyvFpKOq6F3F9K",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": "setatt_1K2OXpHSaWXyvFpKcauo7Bx8",
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "succeeded",
|
||||
"usage": "off_session"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXqHSaWXyvFpKyccpbqqf",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0004",
|
||||
"idempotency_key": "8c0e4f10-2995-45d3-9b35-aa76865e3557"
|
||||
},
|
||||
"type": "setup_intent.succeeded"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"generated_from": null,
|
||||
"last4": "4242",
|
||||
"networks": {
|
||||
"available": [
|
||||
"visa"
|
||||
],
|
||||
"preferred": null
|
||||
},
|
||||
"three_d_secure_usage": {
|
||||
"supported": true
|
||||
},
|
||||
"wallet": null
|
||||
},
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"id": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "payment_method",
|
||||
"type": "card"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXqHSaWXyvFpKUpLnWMHc",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0004",
|
||||
"idempotency_key": "8c0e4f10-2995-45d3-9b35-aa76865e3557"
|
||||
},
|
||||
"type": "payment_method.attached"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXpHSaWXyvFpKOq6F3F9K_secret_KhoAzpsEjV8G4oAeYDSFmGYMKv5BRkc",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXpHSaWXyvFpKOq6F3F9K",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": "setatt_1K2OXpHSaWXyvFpKcauo7Bx8",
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "succeeded",
|
||||
"usage": "off_session"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXqHSaWXyvFpKZ0zBpGzN",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0004",
|
||||
"idempotency_key": "8c0e4f10-2995-45d3-9b35-aa76865e3557"
|
||||
},
|
||||
"type": "setup_intent.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXoHSaWXyvFpKLyy5ns16_secret_KhoANgFdO2YICL1Urfnax58nGUY0MeV",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXoHSaWXyvFpKLyy5ns16",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": null,
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "requires_payment_method",
|
||||
"usage": "off_session"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXoHSaWXyvFpKm8uayD4o",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0005",
|
||||
"idempotency_key": "1eca5c75-5177-4abb-94c1-4e6c39916bef"
|
||||
},
|
||||
"type": "setup_intent.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 7200,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": null,
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "automatic",
|
||||
"charges": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges?payment_intent=pi_NORMALIZED00000000000001"
|
||||
},
|
||||
"client_secret": "pi_NORMALIZED00000000000001_secret_fD7F9AdDLLYQt94Ii4rEflkYg",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"id": "pi_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"id": "evt_3K2OXoHSaWXyvFpK1tATyd2u",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0006",
|
||||
"idempotency_key": "d6ea8ee3-361c-451e-a3d7-e841957c3d25"
|
||||
},
|
||||
"type": "payment_intent.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": null,
|
||||
"default_source": null,
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": null,
|
||||
"footer": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 1,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXnHSaWXyvFpKsZOF5R1j",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0007",
|
||||
"idempotency_key": "d4faf9c7-d357-4870-b964-b1d993c5c058"
|
||||
},
|
||||
"type": "customer.created"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,694 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0002",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
},
|
||||
"previous_attributes": {
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"ending_balance": null,
|
||||
"hosted_invoice_url": null,
|
||||
"invoice_pdf": null,
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": null,
|
||||
"paid": false,
|
||||
"status": "draft",
|
||||
"status_transitions": {
|
||||
"finalized_at": null,
|
||||
"paid_at": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXvHSaWXyvFpKAxZLQePJ",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 2,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0008",
|
||||
"idempotency_key": "f3d64bf5-ddf0-4933-86ee-96e9f6aa7149"
|
||||
},
|
||||
"type": "invoice.updated"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": null,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": null,
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": null,
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": null,
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "draft",
|
||||
"status_transitions": {
|
||||
"finalized_at": null,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": null,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXvHSaWXyvFpKykfdKZvo",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0009",
|
||||
"idempotency_key": "6f6f6557-9e62-418d-ac7f-8a0a1f602c00"
|
||||
},
|
||||
"type": "invoice.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000004",
|
||||
"invoice": "in_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "invoiceitem",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_rates": [],
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"previous_attributes": {
|
||||
"invoice": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXvHSaWXyvFpKRcyaG8m2",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0009",
|
||||
"idempotency_key": "6f6f6557-9e62-418d-ac7f-8a0a1f602c00"
|
||||
},
|
||||
"type": "invoiceitem.updated"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000003",
|
||||
"invoice": "in_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "invoiceitem",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_rates": [],
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"previous_attributes": {
|
||||
"invoice": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXvHSaWXyvFpKGZKUgbvc",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0009",
|
||||
"idempotency_key": "6f6f6557-9e62-418d-ac7f-8a0a1f602c00"
|
||||
},
|
||||
"type": "invoiceitem.updated"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000003",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "invoiceitem",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_rates": [],
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXuHSaWXyvFpK7gSvsu8e",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0010",
|
||||
"idempotency_key": "b60066b7-cf3e-4569-aa2e-8050562cedb4"
|
||||
},
|
||||
"type": "invoiceitem.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000004",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "invoiceitem",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_rates": [],
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXuHSaWXyvFpK4K7m30wK",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0011",
|
||||
"idempotency_key": "cd6054f5-d831-4874-9ed9-d7b0e3b24336"
|
||||
},
|
||||
"type": "invoiceitem.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"default_source": null,
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"footer": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 1,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none"
|
||||
},
|
||||
"previous_attributes": {
|
||||
"currency": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXtHSaWXyvFpKaZ6mOKiF",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0011",
|
||||
"idempotency_key": "cd6054f5-d831-4874-9ed9-d7b0e3b24336"
|
||||
},
|
||||
"type": "customer.updated"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,574 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXwHSaWXyvFpKr5uez7KF",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0008",
|
||||
"idempotency_key": "f3d64bf5-ddf0-4933-86ee-96e9f6aa7149"
|
||||
},
|
||||
"type": "invoice.payment_succeeded"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXvHSaWXyvFpKbgniTOEt",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0008",
|
||||
"idempotency_key": "f3d64bf5-ddf0-4933-86ee-96e9f6aa7149"
|
||||
},
|
||||
"type": "invoice.paid"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXvHSaWXyvFpKPFo4dPYw",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0008",
|
||||
"idempotency_key": "f3d64bf5-ddf0-4933-86ee-96e9f6aa7149"
|
||||
},
|
||||
"type": "invoice.finalized"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXwHSaWXyvFpKr5uez7KF",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0008",
|
||||
"idempotency_key": "f3d64bf5-ddf0-4933-86ee-96e9f6aa7149"
|
||||
},
|
||||
"type": "invoice.payment_succeeded"
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,553 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 36000,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 36000,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": null,
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "automatic",
|
||||
"charges": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"amount_captured": 36000,
|
||||
"amount_refunded": 0,
|
||||
"application": null,
|
||||
"application_fee": null,
|
||||
"application_fee_amount": null,
|
||||
"balance_transaction": "txn_NORMALIZED00000000000002",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
"captured": true,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $60.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
"failure_code": null,
|
||||
"failure_message": null,
|
||||
"fraud_details": {},
|
||||
"id": "ch_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
"outcome": {
|
||||
"network_status": "approved_by_network",
|
||||
"reason": null,
|
||||
"risk_level": "normal",
|
||||
"risk_score": 0,
|
||||
"seller_message": "Payment complete.",
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_NORMALIZED00000000000002",
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"installments": null,
|
||||
"last4": "4242",
|
||||
"network": "visa",
|
||||
"three_d_secure": null,
|
||||
"wallet": null
|
||||
},
|
||||
"type": "card"
|
||||
},
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000002/rcpt_NORMALIZED000000000000000000002",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000002/refunds"
|
||||
},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/charges?payment_intent=pi_NORMALIZED00000000000002"
|
||||
},
|
||||
"client_secret": "pi_NORMALIZED00000000000002_secret_KPTN4Jk0sR9DrnVymxUmd0o0F",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $60.0 x 6",
|
||||
"id": "pi_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"id": "evt_3K2OXxHSaWXyvFpK152vEHml",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 2,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0012",
|
||||
"idempotency_key": "5169e60e-793c-4161-9873-1ba5c523737f"
|
||||
},
|
||||
"type": "payment_intent.succeeded"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"address": null,
|
||||
"balance": 0,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"default_source": null,
|
||||
"delinquent": false,
|
||||
"description": "zulip (Zulip Dev)",
|
||||
"discount": null,
|
||||
"email": "hamlet@zulip.com",
|
||||
"id": "cus_NORMALIZED0001",
|
||||
"invoice_prefix": "NORMA01",
|
||||
"invoice_settings": {
|
||||
"custom_fields": null,
|
||||
"default_payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"footer": null
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip"
|
||||
},
|
||||
"name": null,
|
||||
"next_invoice_sequence": 2,
|
||||
"object": "customer",
|
||||
"phone": null,
|
||||
"preferred_locales": [],
|
||||
"shipping": null,
|
||||
"tax_exempt": "none"
|
||||
},
|
||||
"previous_attributes": {
|
||||
"invoice_settings": {
|
||||
"default_payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI"
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY1HSaWXyvFpKNFkX6Ye6",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0013",
|
||||
"idempotency_key": "196311c6-d3ab-40d0-aefc-f770c1411cf5"
|
||||
},
|
||||
"type": "customer.updated"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXzHSaWXyvFpKzkPjM4k7_secret_KhoALa8Xt6eK1ATCOPGurJLxmo2y6xh",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXzHSaWXyvFpKzkPjM4k7",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": "setatt_1K2OXzHSaWXyvFpKG0BonJhW",
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "succeeded",
|
||||
"usage": "off_session"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY0HSaWXyvFpKOGS3PDsC",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0014",
|
||||
"idempotency_key": "415ae25a-0c46-48f9-8299-d873f9fc51f1"
|
||||
},
|
||||
"type": "setup_intent.succeeded"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"generated_from": null,
|
||||
"last4": "4242",
|
||||
"networks": {
|
||||
"available": [
|
||||
"visa"
|
||||
],
|
||||
"preferred": null
|
||||
},
|
||||
"three_d_secure_usage": {
|
||||
"supported": true
|
||||
},
|
||||
"wallet": null
|
||||
},
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"id": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "payment_method",
|
||||
"type": "card"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY0HSaWXyvFpKHdeiTg2r",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0014",
|
||||
"idempotency_key": "415ae25a-0c46-48f9-8299-d873f9fc51f1"
|
||||
},
|
||||
"type": "payment_method.attached"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXzHSaWXyvFpKzkPjM4k7_secret_KhoALa8Xt6eK1ATCOPGurJLxmo2y6xh",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXzHSaWXyvFpKzkPjM4k7",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": "setatt_1K2OXzHSaWXyvFpKG0BonJhW",
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "succeeded",
|
||||
"usage": "off_session"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY0HSaWXyvFpKuYHMv2vM",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0014",
|
||||
"idempotency_key": "415ae25a-0c46-48f9-8299-d873f9fc51f1"
|
||||
},
|
||||
"type": "setup_intent.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXyHSaWXyvFpKvIhWI0JW_secret_KhoAlunPkSHQPGu7zyaTpT81wBUPhqc",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXyHSaWXyvFpKvIhWI0JW",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": null,
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "requires_payment_method",
|
||||
"usage": "off_session"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OXyHSaWXyvFpK5sw7382A",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0015",
|
||||
"idempotency_key": "de8b28ae-b3f1-416b-bae0-43f9af477f33"
|
||||
},
|
||||
"type": "setup_intent.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 36000,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": null,
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "automatic",
|
||||
"charges": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges?payment_intent=pi_NORMALIZED00000000000002"
|
||||
},
|
||||
"client_secret": "pi_NORMALIZED00000000000002_secret_KPTN4Jk0sR9DrnVymxUmd0o0F",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $60.0 x 6",
|
||||
"id": "pi_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
},
|
||||
"id": "evt_3K2OXxHSaWXyvFpK1GA2kN6r",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0016",
|
||||
"idempotency_key": "d8d89509-f2c5-433c-b8ca-1ae78f192ee5"
|
||||
},
|
||||
"type": "payment_intent.created"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,835 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0004",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY5HSaWXyvFpK1LOtVUsj",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 2,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0017",
|
||||
"idempotency_key": "c3e3b8e3-19f0-4bab-865b-d2d621dd254b"
|
||||
},
|
||||
"type": "invoice.finalized"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0004",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
},
|
||||
"previous_attributes": {
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"ending_balance": null,
|
||||
"hosted_invoice_url": null,
|
||||
"invoice_pdf": null,
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": null,
|
||||
"paid": false,
|
||||
"status": "draft",
|
||||
"status_transitions": {
|
||||
"finalized_at": null,
|
||||
"paid_at": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY5HSaWXyvFpKAdaAGCmF",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 1,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0017",
|
||||
"idempotency_key": "c3e3b8e3-19f0-4bab-865b-d2d621dd254b"
|
||||
},
|
||||
"type": "invoice.updated"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": null,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": null,
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": null,
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": null,
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "draft",
|
||||
"status_transitions": {
|
||||
"finalized_at": null,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": null,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY4HSaWXyvFpK5WkKkAff",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0018",
|
||||
"idempotency_key": "f117a125-002e-4795-b38a-dd9babcf6f10"
|
||||
},
|
||||
"type": "invoice.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000006",
|
||||
"invoice": "in_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "invoiceitem",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_rates": [],
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"previous_attributes": {
|
||||
"invoice": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY4HSaWXyvFpKtk38Nrov",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0018",
|
||||
"idempotency_key": "f117a125-002e-4795-b38a-dd9babcf6f10"
|
||||
},
|
||||
"type": "invoiceitem.updated"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000005",
|
||||
"invoice": "in_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "invoiceitem",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_rates": [],
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"previous_attributes": {
|
||||
"invoice": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY4HSaWXyvFpK7ju3WXQp",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0018",
|
||||
"idempotency_key": "f117a125-002e-4795-b38a-dd9babcf6f10"
|
||||
},
|
||||
"type": "invoiceitem.updated"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000005",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "invoiceitem",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_rates": [],
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY4HSaWXyvFpKxihqsHVn",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0019",
|
||||
"idempotency_key": "613148af-3c2d-4206-968d-e4ed39a3d02e"
|
||||
},
|
||||
"type": "invoiceitem.created"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000006",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "invoiceitem",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_rates": [],
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY3HSaWXyvFpKc0mBnhSo",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0020",
|
||||
"idempotency_key": "14c29b95-ba26-4fee-afc1-97ad8a540ebb"
|
||||
},
|
||||
"type": "invoiceitem.created"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,385 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0004",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY5HSaWXyvFpK4UZiVK2B",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0017",
|
||||
"idempotency_key": "c3e3b8e3-19f0-4bab-865b-d2d621dd254b"
|
||||
},
|
||||
"type": "invoice.payment_succeeded"
|
||||
},
|
||||
{
|
||||
"api_version": "2020-08-27",
|
||||
"created": 1000000000,
|
||||
"data": {
|
||||
"object": {
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0004",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
}
|
||||
},
|
||||
"id": "evt_1K2OY5HSaWXyvFpKy72Pl7GJ",
|
||||
"livemode": false,
|
||||
"object": "event",
|
||||
"pending_webhooks": 0,
|
||||
"request": {
|
||||
"id": "req_NORMALIZED0017",
|
||||
"idempotency_key": "c3e3b8e3-19f0-4bab-865b-d2d621dd254b"
|
||||
},
|
||||
"type": "invoice.paid"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/events"
|
||||
}
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
@@ -27,6 +24,7 @@
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
@@ -35,9 +33,10 @@
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": null,
|
||||
"finalized_at": null,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": null,
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"id": "in_NORMALIZED00000000000001",
|
||||
"invoice_pdf": null,
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
@@ -45,12 +44,12 @@
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"id": "ii_NORMALIZED00000000000001",
|
||||
"invoice_item": "ii_NORMALIZED00000000000001",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -64,15 +63,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"id": "price_1HufhsD2X8vgpBNGtyNs4AI9",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"product": "prod_IVh67i06KRHwdX",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -84,7 +82,8 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhsD2X8vgpBNGtA08rM3i"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
@@ -93,8 +92,8 @@
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"id": "ii_NORMALIZED00000000000002",
|
||||
"invoice_item": "ii_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -108,15 +107,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"id": "price_1HufhrD2X8vgpBNGD9sFn8tJ",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"product": "prod_IVh6pGP4ldOFFV",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -128,34 +126,29 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhrD2X8vgpBNGf4QcWhh8"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": null,
|
||||
"number": "NORMALI-0001",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "draft",
|
||||
"status_transitions": {
|
||||
"finalized_at": null,
|
||||
@@ -166,6 +159,7 @@
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
@@ -27,6 +24,7 @@
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
@@ -35,9 +33,10 @@
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": null,
|
||||
"finalized_at": null,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": null,
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": null,
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
@@ -45,12 +44,12 @@
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"id": "ii_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -64,15 +63,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"id": "price_1HufhzD2X8vgpBNGlpQImV07",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"product": "prod_IVh6VKlEd957ap",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -84,7 +82,8 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhzD2X8vgpBNGwPaEObnC"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
@@ -93,8 +92,8 @@
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"id": "ii_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -108,15 +107,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"id": "price_1HufhyD2X8vgpBNG58auoETW",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"product": "prod_IVh6Yrwv6xv7Bm",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -128,34 +126,29 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhyD2X8vgpBNGQAOpJ22e"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": null,
|
||||
"number": "NORMALI-0002",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "draft",
|
||||
"status_transitions": {
|
||||
"finalized_at": null,
|
||||
@@ -166,6 +159,7 @@
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 24000,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 24000,
|
||||
"application_fee_amount": null,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
@@ -27,6 +24,7 @@
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
@@ -35,9 +33,10 @@
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": null,
|
||||
"finalized_at": null,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": null,
|
||||
"id": "in_NORMALIZED00000000000004",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": null,
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
@@ -45,12 +44,12 @@
|
||||
{
|
||||
"amount": 24000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard - renewal",
|
||||
"description": "Zulip Standard - renewal",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000007",
|
||||
"invoice_item": "ii_NORMALIZED00000000000007",
|
||||
"id": "ii_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -64,15 +63,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000007",
|
||||
"id": "price_1Hufi2D2X8vgpBNGLrDQYzwi",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0007",
|
||||
"product": "prod_IVh6pB9D73emPf",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -84,34 +82,29 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1Hufi2D2X8vgpBNGj13daEPu"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000004/lines"
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": null,
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "draft",
|
||||
"status_transitions": {
|
||||
"finalized_at": null,
|
||||
@@ -122,6 +115,7 @@
|
||||
"subscription": null,
|
||||
"subtotal": 24000,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 24000,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
@@ -27,6 +24,7 @@
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
@@ -35,22 +33,23 @@
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED0000000000000001jwmXq",
|
||||
"id": "in_NORMALIZED00000000000001",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED0000000000000001jwmXq/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"id": "ii_NORMALIZED00000000000001",
|
||||
"invoice_item": "ii_NORMALIZED00000000000001",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -64,15 +63,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"id": "price_1HufhsD2X8vgpBNGtyNs4AI9",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"product": "prod_IVh67i06KRHwdX",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -84,7 +82,8 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhsD2X8vgpBNGtA08rM3i"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
@@ -93,8 +92,8 @@
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"id": "ii_NORMALIZED00000000000002",
|
||||
"invoice_item": "ii_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -108,15 +107,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"id": "price_1HufhrD2X8vgpBNGD9sFn8tJ",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"product": "prod_IVh6pGP4ldOFFV",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -128,34 +126,29 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhrD2X8vgpBNGf4QcWhh8"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0003",
|
||||
"number": "NORMALI-0001",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
@@ -166,6 +159,7 @@
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
@@ -27,6 +24,7 @@
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
@@ -35,22 +33,23 @@
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8/pdf",
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED0000000000000002TO6zL",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED0000000000000002TO6zL/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"id": "ii_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -64,15 +63,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"id": "price_1HufhzD2X8vgpBNGlpQImV07",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"product": "prod_IVh6VKlEd957ap",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -84,7 +82,8 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhzD2X8vgpBNGwPaEObnC"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
@@ -93,8 +92,8 @@
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"id": "ii_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -108,15 +107,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"id": "price_1HufhyD2X8vgpBNG58auoETW",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"product": "prod_IVh6Yrwv6xv7Bm",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -128,34 +126,29 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhyD2X8vgpBNGQAOpJ22e"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0004",
|
||||
"number": "NORMALI-0002",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
@@ -166,6 +159,7 @@
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 24000,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 24000,
|
||||
"application_fee_amount": null,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
@@ -27,6 +24,7 @@
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
@@ -35,22 +33,23 @@
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BZ1A2VnVzRGRzR1BIS3V5YWJtejhEYVd1Z0xO0100PL5hiifd",
|
||||
"id": "in_NORMALIZED00000000000004",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BZ1A2VnVzRGRzR1BIS3V5YWJtejhEYVd1Z0xO0100PL5hiifd/pdf",
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED00000000000000039Nm5X",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED00000000000000039Nm5X/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 24000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard - renewal",
|
||||
"description": "Zulip Standard - renewal",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000007",
|
||||
"invoice_item": "ii_NORMALIZED00000000000007",
|
||||
"id": "ii_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
@@ -64,15 +63,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000007",
|
||||
"id": "price_1Hufi2D2X8vgpBNGLrDQYzwi",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0007",
|
||||
"product": "prod_IVh6pB9D73emPf",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -84,34 +82,29 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1Hufi2D2X8vgpBNGj13daEPu"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000004/lines"
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": "NORMALI-0005",
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"payment_intent": "pi_NORMALIZED00000000000003",
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"payment_intent": "pi_1Hufi3D2X8vgpBNGmAdVFaWD",
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "open",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
@@ -122,6 +115,7 @@
|
||||
"subscription": null,
|
||||
"subtotal": 24000,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 24000,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
|
||||
@@ -1,5 +1,174 @@
|
||||
{
|
||||
"data": [],
|
||||
"data": [
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED0000000000000001jwmXq",
|
||||
"id": "in_NORMALIZED00000000000001",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED0000000000000001jwmXq/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000001",
|
||||
"invoice_item": "ii_NORMALIZED00000000000001",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1HufhsD2X8vgpBNGtyNs4AI9",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh67i06KRHwdX",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhsD2X8vgpBNGtA08rM3i"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000002",
|
||||
"invoice_item": "ii_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1HufhrD2X8vgpBNGD9sFn8tJ",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh6pGP4ldOFFV",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhrD2X8vgpBNGf4QcWhh8"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0001",
|
||||
"object": "invoice",
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1000000000
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/invoices"
|
||||
|
||||
@@ -2,19 +2,16 @@
|
||||
"data": [
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
@@ -29,6 +26,7 @@
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
@@ -37,28 +35,29 @@
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED0000000000000002TO6zL",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED0000000000000002TO6zL/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"id": "ii_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
@@ -66,36 +65,36 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"id": "price_1HufhzD2X8vgpBNGlpQImV07",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"product": "prod_IVh6VKlEd957ap",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhzD2X8vgpBNGwPaEObnC"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"id": "ii_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
@@ -110,15 +109,182 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"id": "price_1HufhyD2X8vgpBNG58auoETW",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"product": "prod_IVh6Yrwv6xv7Bm",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhyD2X8vgpBNGQAOpJ22e"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0002",
|
||||
"object": "invoice",
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
},
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED0000000000000001jwmXq",
|
||||
"id": "in_NORMALIZED00000000000001",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED0000000000000001jwmXq/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000001",
|
||||
"invoice_item": "ii_NORMALIZED00000000000001",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1HufhsD2X8vgpBNGtyNs4AI9",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh67i06KRHwdX",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhsD2X8vgpBNGtA08rM3i"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000002",
|
||||
"invoice_item": "ii_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1HufhrD2X8vgpBNGD9sFn8tJ",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh6pGP4ldOFFV",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
@@ -130,34 +296,29 @@
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhrD2X8vgpBNGf4QcWhh8"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0003",
|
||||
"number": "NORMALI-0001",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
@@ -168,6 +329,7 @@
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
|
||||
@@ -1,5 +1,466 @@
|
||||
{
|
||||
"data": [],
|
||||
"data": [
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 24000,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 24000,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED00000000000000039Nm5X",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED00000000000000039Nm5X/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 24000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Standard - renewal",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1388631845,
|
||||
"start": 1357095845
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1Hufi2D2X8vgpBNGLrDQYzwi",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh6pB9D73emPf",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 4000,
|
||||
"unit_amount_decimal": "4000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1Hufi2D2X8vgpBNGj13daEPu"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"paid": false,
|
||||
"payment_intent": "pi_1Hufi3D2X8vgpBNGmAdVFaWD",
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "open",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": null,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 24000,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 24000,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": null
|
||||
},
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED0000000000000002TO6zL",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED0000000000000002TO6zL/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1HufhzD2X8vgpBNGlpQImV07",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh6VKlEd957ap",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhzD2X8vgpBNGwPaEObnC"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1HufhyD2X8vgpBNG58auoETW",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh6Yrwv6xv7Bm",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhyD2X8vgpBNGQAOpJ22e"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0002",
|
||||
"object": "invoice",
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1000000000
|
||||
},
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "Vishnu Test",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"billing": "charge_automatically",
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"date": 1000000000,
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"finalized_at": 1000000000,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/invst_NORMALIZED0000000000000001jwmXq",
|
||||
"id": "in_NORMALIZED00000000000001",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/invst_NORMALIZED0000000000000001jwmXq/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000001",
|
||||
"invoice_item": "ii_NORMALIZED00000000000001",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1HufhsD2X8vgpBNGtyNs4AI9",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh67i06KRHwdX",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhsD2X8vgpBNGtA08rM3i"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000002",
|
||||
"invoice_item": "ii_NORMALIZED00000000000002",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_1HufhrD2X8vgpBNGD9sFn8tJ",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_IVh6pGP4ldOFFV",
|
||||
"recurring": null,
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem",
|
||||
"unique_id": "il_1HufhrD2X8vgpBNGf4QcWhh8"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000001/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0001",
|
||||
"object": "invoice",
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"tax_percent": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1000000000
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/invoices"
|
||||
|
||||
@@ -1,355 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0004",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1000000000
|
||||
},
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1000000000
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/invoices"
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 24000,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 24000,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": false,
|
||||
"auto_advance": true,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BZ1A2VnVzRGRzR1BIS3V5YWJtejhEYVd1Z0xO0100PL5hiifd",
|
||||
"id": "in_NORMALIZED00000000000004",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BZ1A2VnVzRGRzR1BIS3V5YWJtejhEYVd1Z0xO0100PL5hiifd/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 24000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard - renewal",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000007",
|
||||
"invoice_item": "ii_NORMALIZED00000000000007",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1388631845,
|
||||
"start": 1357095845
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000007",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0007",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 4000,
|
||||
"unit_amount_decimal": "4000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000004/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": 1000000000,
|
||||
"number": "NORMALI-0005",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": false,
|
||||
"payment_intent": "pi_NORMALIZED00000000000003",
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "open",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": null,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 24000,
|
||||
"tax": null,
|
||||
"total": 24000,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1000000000
|
||||
},
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8",
|
||||
"id": "in_NORMALIZED00000000000003",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9Ba2NPMVJlTGJDNXlwMEFBVU1sY01VTDNHTHNk0100ARPvAEt8/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000005",
|
||||
"invoice_item": "ii_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1357095845,
|
||||
"start": 1325473445
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 6000,
|
||||
"unit_amount_decimal": "6000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -36000,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000006",
|
||||
"invoice_item": "ii_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -36000,
|
||||
"unit_amount_decimal": "-36000"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000003/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0004",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1000000000
|
||||
},
|
||||
{
|
||||
"account_country": "US",
|
||||
"account_name": "NORMALIZED-1",
|
||||
"account_tax_ids": null,
|
||||
"amount_due": 0,
|
||||
"amount_paid": 0,
|
||||
"amount_remaining": 0,
|
||||
"application_fee_amount": null,
|
||||
"attempt_count": 0,
|
||||
"attempted": true,
|
||||
"auto_advance": false,
|
||||
"automatic_tax": {
|
||||
"enabled": false,
|
||||
"status": null
|
||||
},
|
||||
"billing_reason": "manual",
|
||||
"charge": null,
|
||||
"collection_method": "charge_automatically",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"custom_fields": null,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"customer_address": null,
|
||||
"customer_email": "hamlet@zulip.com",
|
||||
"customer_name": null,
|
||||
"customer_phone": null,
|
||||
"customer_shipping": null,
|
||||
"customer_tax_exempt": "none",
|
||||
"customer_tax_ids": [],
|
||||
"default_payment_method": null,
|
||||
"default_source": null,
|
||||
"default_tax_rates": [],
|
||||
"description": null,
|
||||
"discount": null,
|
||||
"discounts": [],
|
||||
"due_date": null,
|
||||
"ending_balance": 0,
|
||||
"footer": null,
|
||||
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX",
|
||||
"id": "in_NORMALIZED00000000000002",
|
||||
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED000001/test_NORMALIZED01cDVIU2FXWHl2RnBLLF9LaG9BNDRqUzlSeE5ZbkM2YXNhYmhHRDNCN0pTZ3E001000SGRn6zX/pdf",
|
||||
"last_finalization_error": null,
|
||||
"lines": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"currency": "usd",
|
||||
"description": "Zulip Cloud Standard",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000003",
|
||||
"invoice_item": "ii_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": 1200,
|
||||
"unit_amount_decimal": "1200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 6,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
},
|
||||
{
|
||||
"amount": -7200,
|
||||
"currency": "usd",
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discount_amounts": [],
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "il_NORMALIZED00000000000004",
|
||||
"invoice_item": "ii_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "line_item",
|
||||
"period": {
|
||||
"end": 1000000000,
|
||||
"start": 1000000000
|
||||
},
|
||||
"plan": null,
|
||||
"price": {
|
||||
"active": false,
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
"unit_amount": -7200,
|
||||
"unit_amount_decimal": "-7200"
|
||||
},
|
||||
"proration": false,
|
||||
"quantity": 1,
|
||||
"subscription": null,
|
||||
"tax_amounts": [],
|
||||
"tax_rates": [],
|
||||
"type": "invoiceitem"
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 2,
|
||||
"url": "/v1/invoices/in_NORMALIZED00000000000002/lines"
|
||||
},
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"next_payment_attempt": null,
|
||||
"number": "NORMALI-0003",
|
||||
"object": "invoice",
|
||||
"on_behalf_of": null,
|
||||
"paid": true,
|
||||
"payment_intent": null,
|
||||
"payment_settings": {
|
||||
"payment_method_options": null,
|
||||
"payment_method_types": null
|
||||
},
|
||||
"period_end": 1000000000,
|
||||
"period_start": 1000000000,
|
||||
"post_payment_credit_notes_amount": 0,
|
||||
"pre_payment_credit_notes_amount": 0,
|
||||
"quote": null,
|
||||
"receipt_number": null,
|
||||
"starting_balance": 0,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"status": "paid",
|
||||
"status_transitions": {
|
||||
"finalized_at": 1000000000,
|
||||
"marked_uncollectible_at": null,
|
||||
"paid_at": 1000000000,
|
||||
"voided_at": null
|
||||
},
|
||||
"subscription": null,
|
||||
"subtotal": 0,
|
||||
"tax": null,
|
||||
"total": 0,
|
||||
"total_discount_amounts": [],
|
||||
"total_tax_amounts": [],
|
||||
"transfer_data": null,
|
||||
"webhooks_delivered_at": 1000000000
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"url": "/v1/invoices"
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000004",
|
||||
"id": "ii_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
@@ -21,15 +21,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000004",
|
||||
"id": "price_1HufhrD2X8vgpBNGD9sFn8tJ",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0004",
|
||||
"product": "prod_IVh6pGP4ldOFFV",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Zulip Cloud Standard",
|
||||
"description": "Zulip Standard",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000003",
|
||||
"id": "ii_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
@@ -21,15 +21,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000003",
|
||||
"id": "price_1HufhsD2X8vgpBNGtyNs4AI9",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0003",
|
||||
"product": "prod_IVh67i06KRHwdX",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"description": "Payment (Card ending in 4242)",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000006",
|
||||
"id": "ii_NORMALIZED00000000000004",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
@@ -21,15 +21,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000006",
|
||||
"id": "price_1HufhyD2X8vgpBNG58auoETW",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0006",
|
||||
"product": "prod_IVh6Yrwv6xv7Bm",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Zulip Cloud Standard",
|
||||
"description": "Zulip Standard",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000005",
|
||||
"id": "ii_NORMALIZED00000000000003",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
@@ -21,15 +21,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000005",
|
||||
"id": "price_1HufhzD2X8vgpBNGlpQImV07",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0005",
|
||||
"product": "prod_IVh6VKlEd957ap",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"date": 1000000000,
|
||||
"description": "Zulip Cloud Standard - renewal",
|
||||
"description": "Zulip Standard - renewal",
|
||||
"discountable": false,
|
||||
"discounts": [],
|
||||
"id": "ii_NORMALIZED00000000000007",
|
||||
"id": "ii_NORMALIZED00000000000005",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
@@ -21,15 +21,14 @@
|
||||
"billing_scheme": "per_unit",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"id": "price_NORMALIZED00000000000007",
|
||||
"id": "price_1Hufi2D2X8vgpBNGLrDQYzwi",
|
||||
"livemode": false,
|
||||
"lookup_key": null,
|
||||
"metadata": {},
|
||||
"nickname": null,
|
||||
"object": "price",
|
||||
"product": "prod_NORMALIZED0007",
|
||||
"product": "prod_IVh6pB9D73emPf",
|
||||
"recurring": null,
|
||||
"tax_behavior": "unspecified",
|
||||
"tiers_mode": null,
|
||||
"transform_quantity": null,
|
||||
"type": "one_time",
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
{
|
||||
"amount": 7200,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 7200,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": null,
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "automatic",
|
||||
"charges": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 7200,
|
||||
"amount_captured": 7200,
|
||||
"amount_refunded": 0,
|
||||
"application": null,
|
||||
"application_fee": null,
|
||||
"application_fee_amount": null,
|
||||
"balance_transaction": "txn_NORMALIZED00000000000001",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
"captured": true,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
"failure_code": null,
|
||||
"failure_message": null,
|
||||
"fraud_details": {},
|
||||
"id": "ch_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
"outcome": {
|
||||
"network_status": "approved_by_network",
|
||||
"reason": null,
|
||||
"risk_level": "normal",
|
||||
"risk_score": 0,
|
||||
"seller_message": "Payment complete.",
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_NORMALIZED00000000000001",
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"installments": null,
|
||||
"last4": "4242",
|
||||
"network": "visa",
|
||||
"three_d_secure": null,
|
||||
"wallet": null
|
||||
},
|
||||
"type": "card"
|
||||
},
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000001/rcpt_NORMALIZED000000000000000000001",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000001/refunds"
|
||||
},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/charges?payment_intent=pi_NORMALIZED00000000000001"
|
||||
},
|
||||
"client_secret": "pi_NORMALIZED00000000000001_secret_fD7F9AdDLLYQt94Ii4rEflkYg",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"id": "pi_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
{
|
||||
"amount": 36000,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 36000,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": null,
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "automatic",
|
||||
"charges": {
|
||||
"data": [
|
||||
{
|
||||
"amount": 36000,
|
||||
"amount_captured": 36000,
|
||||
"amount_refunded": 0,
|
||||
"application": null,
|
||||
"application_fee": null,
|
||||
"application_fee_amount": null,
|
||||
"balance_transaction": "txn_NORMALIZED00000000000002",
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"calculated_statement_descriptor": "ZULIP STANDARD",
|
||||
"captured": true,
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $60.0 x 6",
|
||||
"destination": null,
|
||||
"dispute": null,
|
||||
"disputed": false,
|
||||
"failure_code": null,
|
||||
"failure_message": null,
|
||||
"fraud_details": {},
|
||||
"id": "ch_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"object": "charge",
|
||||
"on_behalf_of": null,
|
||||
"order": null,
|
||||
"outcome": {
|
||||
"network_status": "approved_by_network",
|
||||
"reason": null,
|
||||
"risk_level": "normal",
|
||||
"risk_score": 0,
|
||||
"seller_message": "Payment complete.",
|
||||
"type": "authorized"
|
||||
},
|
||||
"paid": true,
|
||||
"payment_intent": "pi_NORMALIZED00000000000002",
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_method_details": {
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "pass"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"installments": null,
|
||||
"last4": "4242",
|
||||
"network": "visa",
|
||||
"three_d_secure": null,
|
||||
"wallet": null
|
||||
},
|
||||
"type": "card"
|
||||
},
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"receipt_number": null,
|
||||
"receipt_url": "https://pay.stripe.com/receipts/acct_NORMALIZED000001/ch_NORMALIZED00000000000002/rcpt_NORMALIZED000000000000000000002",
|
||||
"refunded": false,
|
||||
"refunds": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges/ch_NORMALIZED00000000000002/refunds"
|
||||
},
|
||||
"review": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"source_transfer": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 1,
|
||||
"url": "/v1/charges?payment_intent=pi_NORMALIZED00000000000002"
|
||||
},
|
||||
"client_secret": "pi_NORMALIZED00000000000002_secret_KPTN4Jk0sR9DrnVymxUmd0o0F",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $60.0 x 6",
|
||||
"id": "pi_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "succeeded",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"amount": 7200,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": null,
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "automatic",
|
||||
"charges": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges?payment_intent=pi_NORMALIZED00000000000001"
|
||||
},
|
||||
"client_secret": "pi_NORMALIZED00000000000001_secret_fD7F9AdDLLYQt94Ii4rEflkYg",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $12.0 x 6",
|
||||
"id": "pi_NORMALIZED00000000000001",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
{
|
||||
"amount": 36000,
|
||||
"amount_capturable": 0,
|
||||
"amount_received": 0,
|
||||
"application": null,
|
||||
"application_fee_amount": null,
|
||||
"automatic_payment_methods": null,
|
||||
"canceled_at": null,
|
||||
"cancellation_reason": null,
|
||||
"capture_method": "automatic",
|
||||
"charges": {
|
||||
"data": [],
|
||||
"has_more": false,
|
||||
"object": "list",
|
||||
"total_count": 0,
|
||||
"url": "/v1/charges?payment_intent=pi_NORMALIZED00000000000002"
|
||||
},
|
||||
"client_secret": "pi_NORMALIZED00000000000002_secret_KPTN4Jk0sR9DrnVymxUmd0o0F",
|
||||
"confirmation_method": "automatic",
|
||||
"created": 1000000000,
|
||||
"currency": "usd",
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": "Upgrade to Zulip Cloud Standard, $60.0 x 6",
|
||||
"id": "pi_NORMALIZED00000000000002",
|
||||
"invoice": null,
|
||||
"last_payment_error": null,
|
||||
"livemode": false,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "payment_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"installments": null,
|
||||
"network": null,
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"receipt_email": "hamlet@zulip.com",
|
||||
"review": null,
|
||||
"setup_future_usage": null,
|
||||
"shipping": null,
|
||||
"source": null,
|
||||
"statement_descriptor": "Zulip Cloud Standard",
|
||||
"statement_descriptor_suffix": null,
|
||||
"status": "requires_payment_method",
|
||||
"transfer_data": null,
|
||||
"transfer_group": null
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "unchecked"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"generated_from": null,
|
||||
"last4": "4242",
|
||||
"networks": {
|
||||
"available": [
|
||||
"visa"
|
||||
],
|
||||
"preferred": null
|
||||
},
|
||||
"three_d_secure_usage": {
|
||||
"supported": true
|
||||
},
|
||||
"wallet": null
|
||||
},
|
||||
"created": 1000000000,
|
||||
"customer": null,
|
||||
"id": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "payment_method",
|
||||
"type": "card"
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
{
|
||||
"billing_details": {
|
||||
"address": {
|
||||
"city": null,
|
||||
"country": null,
|
||||
"line1": null,
|
||||
"line2": null,
|
||||
"postal_code": null,
|
||||
"state": null
|
||||
},
|
||||
"email": null,
|
||||
"name": null,
|
||||
"phone": null
|
||||
},
|
||||
"card": {
|
||||
"brand": "visa",
|
||||
"checks": {
|
||||
"address_line1_check": null,
|
||||
"address_postal_code_check": null,
|
||||
"cvc_check": "unchecked"
|
||||
},
|
||||
"country": "US",
|
||||
"exp_month": 3,
|
||||
"exp_year": 2033,
|
||||
"fingerprint": "NORMALIZED000001",
|
||||
"funding": "credit",
|
||||
"generated_from": null,
|
||||
"last4": "4242",
|
||||
"networks": {
|
||||
"available": [
|
||||
"visa"
|
||||
],
|
||||
"preferred": null
|
||||
},
|
||||
"three_d_secure_usage": {
|
||||
"supported": true
|
||||
},
|
||||
"wallet": null
|
||||
},
|
||||
"created": 1000000000,
|
||||
"customer": null,
|
||||
"id": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"livemode": false,
|
||||
"metadata": {},
|
||||
"object": "payment_method",
|
||||
"type": "card"
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXpHSaWXyvFpKOq6F3F9K_secret_KhoAzpsEjV8G4oAeYDSFmGYMKv5BRkc",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXpHSaWXyvFpKOq6F3F9K",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": "setatt_1K2OXpHSaWXyvFpKcauo7Bx8",
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "succeeded",
|
||||
"usage": "off_session"
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXzHSaWXyvFpKzkPjM4k7_secret_KhoALa8Xt6eK1ATCOPGurJLxmo2y6xh",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXzHSaWXyvFpKzkPjM4k7",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": "setatt_1K2OXzHSaWXyvFpKG0BonJhW",
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "succeeded",
|
||||
"usage": "off_session"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXoHSaWXyvFpKLyy5ns16_secret_KhoANgFdO2YICL1Urfnax58nGUY0MeV",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXoHSaWXyvFpKLyy5ns16",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": null,
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "requires_payment_method",
|
||||
"usage": "off_session"
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"object": "list",
|
||||
"url": "/v1/setup_intents"
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXyHSaWXyvFpKvIhWI0JW_secret_KhoAlunPkSHQPGu7zyaTpT81wBUPhqc",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXyHSaWXyvFpKvIhWI0JW",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": null,
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": null,
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "requires_payment_method",
|
||||
"usage": "off_session"
|
||||
}
|
||||
],
|
||||
"has_more": true,
|
||||
"object": "list",
|
||||
"url": "/v1/setup_intents"
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXpHSaWXyvFpKOq6F3F9K_secret_KhoAzpsEjV8G4oAeYDSFmGYMKv5BRkc",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXpHSaWXyvFpKOq6F3F9K",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": "setatt_1K2OXpHSaWXyvFpKcauo7Bx8",
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "1200",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXoHSaWXyvFpKhrceNhbI",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "succeeded",
|
||||
"usage": "off_session"
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"application": null,
|
||||
"cancellation_reason": null,
|
||||
"client_secret": "seti_1K2OXzHSaWXyvFpKzkPjM4k7_secret_KhoALa8Xt6eK1ATCOPGurJLxmo2y6xh",
|
||||
"created": 1000000000,
|
||||
"customer": "cus_NORMALIZED0001",
|
||||
"description": null,
|
||||
"id": "seti_1K2OXzHSaWXyvFpKzkPjM4k7",
|
||||
"last_setup_error": null,
|
||||
"latest_attempt": "setatt_1K2OXzHSaWXyvFpKG0BonJhW",
|
||||
"livemode": false,
|
||||
"mandate": null,
|
||||
"metadata": {
|
||||
"billing_modality": "charge_automatically",
|
||||
"billing_schedule": "1",
|
||||
"license_management": "automatic",
|
||||
"licenses": "6",
|
||||
"price_per_license": "6000",
|
||||
"realm_id": "1",
|
||||
"realm_str": "zulip",
|
||||
"seat_count": "6",
|
||||
"type": "upgrade",
|
||||
"user_email": "hamlet@zulip.com",
|
||||
"user_id": "10"
|
||||
},
|
||||
"next_action": null,
|
||||
"object": "setup_intent",
|
||||
"on_behalf_of": null,
|
||||
"payment_method": "pm_1K2OXyHSaWXyvFpKNE66Ex7T",
|
||||
"payment_method_options": {
|
||||
"card": {
|
||||
"request_three_d_secure": "automatic"
|
||||
}
|
||||
},
|
||||
"payment_method_types": [
|
||||
"card"
|
||||
],
|
||||
"single_use_mandate": null,
|
||||
"status": "succeeded",
|
||||
"usage": "off_session"
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user