mirror of
				https://github.com/zulip/zulip.git
				synced 2025-10-31 03:53:50 +00:00 
			
		
		
		
	Compare commits
	
		
			42 Commits
		
	
	
		
			shared-0.0
			...
			1.8.x
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 8a1e20f734 | ||
|  | 93d4c807a9 | ||
|  | 4741e683ce | ||
|  | d5252ff0c9 | ||
|  | 3d003a8f34 | ||
|  | 1ec0414786 | ||
|  | fd06380701 | ||
|  | d345564ce2 | ||
|  | dbf19ae3e3 | ||
|  | 63437e89d7 | ||
|  | 0a065636c9 | ||
|  | d61a8c96c5 | ||
|  | e346044d6a | ||
|  | b1ff1633b1 | ||
|  | 1b253cb9e0 | ||
|  | f612274f91 | ||
|  | a9e6ad5c6a | ||
|  | eae16d42d4 | ||
|  | ca221da997 | ||
|  | c16b252699 | ||
|  | e3f8108ca6 | ||
|  | 5530fe8cb1 | ||
|  | fca8479065 | ||
|  | fe34001dd1 | ||
|  | 9a6b4aeda2 | ||
|  | 76957a62a5 | ||
|  | 76f6d9aaa2 | ||
|  | 5d9eadb734 | ||
|  | cb8941a081 | ||
|  | 062df3697a | ||
|  | ad113134c7 | ||
|  | c4b2e986c3 | ||
|  | 1b49c5658c | ||
|  | cbdb3d6bbf | ||
|  | 97ccdacb18 | ||
|  | e96af7906d | ||
|  | 0d1e401922 | ||
|  | 8b599c1ed7 | ||
|  | a852532c95 | ||
|  | 8e57a3958d | ||
|  | 86046ae9c3 | ||
|  | c0096932a6 | 
| @@ -1,6 +0,0 @@ | ||||
| > 0.2% | ||||
| > 0.2% in US | ||||
| last 2 versions | ||||
| Firefox ESR | ||||
| not dead | ||||
| Chrome 26  # similar to PhantomJS | ||||
| @@ -1,151 +1,145 @@ | ||||
| # See https://zulip.readthedocs.io/en/latest/testing/continuous-integration.html for | ||||
| #   high-level documentation on our CircleCI setup. | ||||
| # See CircleCI upstream's docs on this config format: | ||||
| #   https://circleci.com/docs/2.0/language-python/ | ||||
| # | ||||
| version: 2.0 | ||||
| aliases: | ||||
|   - &create_cache_directories | ||||
|     run: | ||||
|       name: create cache directories | ||||
|       command: | | ||||
|           dirs=(/srv/zulip-{npm,venv}-cache) | ||||
|           sudo mkdir -p "${dirs[@]}" | ||||
|           sudo chown -R circleci "${dirs[@]}" | ||||
|  | ||||
|   - &restore_cache_package_json | ||||
|     restore_cache: | ||||
|       keys: | ||||
|       - v1-npm-base.{{ .Environment.CIRCLE_JOB }}-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} | ||||
|  | ||||
|   - &restore_cache_requirements | ||||
|     restore_cache: | ||||
|       keys: | ||||
|       - v1-venv-base.{{ .Environment.CIRCLE_JOB }}-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }} | ||||
|  | ||||
|   - &install_dependencies | ||||
|     run: | ||||
|       name: install dependencies | ||||
|       command: | | ||||
|         sudo apt-get update | ||||
|         # Install moreutils so we can use `ts` and `mispipe` in the following. | ||||
|         sudo apt-get install -y moreutils | ||||
|  | ||||
|         # CircleCI sets the following in Git config at clone time: | ||||
|         #   url.ssh://git@github.com.insteadOf https://github.com | ||||
|         # This breaks the Git clones in the NVM `install.sh` we run | ||||
|         # in `install-node`. | ||||
|         # TODO: figure out why that breaks, and whether we want it. | ||||
|         #   (Is it an optimization?) | ||||
|         rm -f /home/circleci/.gitconfig | ||||
|  | ||||
|         # This is the main setup job for the test suite | ||||
|         mispipe "tools/ci/setup-backend" ts | ||||
|  | ||||
|         # Cleaning caches is mostly unnecessary in Circle, because | ||||
|         # most builds don't get to write to the cache. | ||||
|         # mispipe "scripts/lib/clean-unused-caches --verbose --threshold 0" ts | ||||
|  | ||||
|   - &save_cache_package_json | ||||
|     save_cache: | ||||
|       paths: | ||||
|         - /srv/zulip-npm-cache | ||||
|       key: v1-npm-base.{{ .Environment.CIRCLE_JOB }}-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} | ||||
|  | ||||
|   - &save_cache_requirements | ||||
|     save_cache: | ||||
|       paths: | ||||
|         - /srv/zulip-venv-cache | ||||
|       key: v1-venv-base.{{ .Environment.CIRCLE_JOB }}-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }} | ||||
|     # TODO: in Travis we also cache ~/zulip-emoji-cache, ~/node, ~/misc | ||||
|  | ||||
|   - &run_backend_tests | ||||
|     run: | ||||
|       name: run backend tests | ||||
|       command: | | ||||
|         . /srv/zulip-py3-venv/bin/activate | ||||
|         mispipe ./tools/ci/backend ts | ||||
|  | ||||
|   - &run_frontend_tests | ||||
|     run: | ||||
|       name: run frontend tests | ||||
|       command: | | ||||
|         . /srv/zulip-py3-venv/bin/activate | ||||
|         mispipe ./tools/ci/frontend ts | ||||
|  | ||||
|   - &upload_coverage_report | ||||
|     run: | ||||
|      name: upload coverage report | ||||
|      command: | | ||||
|        . /srv/zulip-py3-venv/bin/activate | ||||
|        pip install codecov && codecov \ | ||||
|          || echo "Error in uploading coverage reports to codecov.io." | ||||
|  | ||||
| version: 2 | ||||
| jobs: | ||||
|   "xenial-backend-frontend-python3.5": | ||||
|   "trusty-python-3.4": | ||||
|     docker: | ||||
|       # This is built from tools/circleci/images/xenial/Dockerfile . | ||||
|       # Xenial ships with Python 3.5. | ||||
|       - image: gregprice/circleci:xenial-python-4.test | ||||
|       # This is built from tools/circleci/images/trusty/Dockerfile . | ||||
|       - image: gregprice/circleci:trusty-python-4.test | ||||
|  | ||||
|     working_directory: ~/zulip | ||||
|  | ||||
|     steps: | ||||
|       - checkout | ||||
|  | ||||
|       - *create_cache_directories | ||||
|       - *restore_cache_package_json | ||||
|       - *restore_cache_requirements | ||||
|       - *install_dependencies | ||||
|       - *save_cache_package_json | ||||
|       - *save_cache_requirements | ||||
|       - *run_backend_tests | ||||
|       - *run_frontend_tests | ||||
|       # We only need to upload coverage reports on whichever platform | ||||
|       # runs the frontend tests. | ||||
|       - *upload_coverage_report | ||||
|  | ||||
|       - store_artifacts: | ||||
|           path: ./var/casper/ | ||||
|           destination: casper | ||||
|  | ||||
|       - store_artifacts:     | ||||
|           path: ../../../tmp/zulip-test-event-log/ | ||||
|           destination: test-reports | ||||
|  | ||||
|       - store_test_results: | ||||
|             path: ./var/xunit-test-results/casper/ | ||||
|  | ||||
|   "bionic-backend-python3.6": | ||||
|     docker: | ||||
|       # This is built from tools/circleci/images/bionic/Dockerfile . | ||||
|       # Bionic ships with Python 3.6. | ||||
|       - image: gregprice/circleci:bionic-python-1.test | ||||
|  | ||||
|     working_directory: ~/zulip | ||||
|  | ||||
|     steps: | ||||
|       - checkout | ||||
|  | ||||
|       - *create_cache_directories | ||||
|  | ||||
|       - run: | ||||
|           name: do Bionic hack | ||||
|           name: create cache directories | ||||
|           command: | | ||||
|               # 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 | ||||
|               dirs=(/srv/zulip-{npm,venv}-cache) | ||||
|               sudo mkdir -p "${dirs[@]}" | ||||
|               sudo chown -R circleci "${dirs[@]}" | ||||
|  | ||||
|       - *restore_cache_package_json | ||||
|       - *restore_cache_requirements | ||||
|       - *install_dependencies | ||||
|       - *save_cache_package_json | ||||
|       - *save_cache_requirements | ||||
|       - *run_backend_tests | ||||
|       - restore_cache: | ||||
|           keys: | ||||
|           - v1-npm-base.trusty-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} | ||||
|       - restore_cache: | ||||
|           keys: | ||||
|           - v1-venv-base.trusty-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }} | ||||
|  | ||||
|       - run: | ||||
|           name: install dependencies | ||||
|           command: | | ||||
|             # Install moreutils so we can use `ts` and `mispipe` in the following. | ||||
|             sudo apt-get install -y moreutils | ||||
|  | ||||
|             # CircleCI sets the following in Git config at clone time: | ||||
|             #   url.ssh://git@github.com.insteadOf https://github.com | ||||
|             # This breaks the Git clones in the NVM `install.sh` we run | ||||
|             # in `install-node`. | ||||
|             # TODO: figure out why that breaks, and whether we want it. | ||||
|             #   (Is it an optimization?) | ||||
|             rm -f /home/circleci/.gitconfig | ||||
|  | ||||
|             # This is the main setup job for the test suite | ||||
|             mispipe "tools/travis/setup-backend" ts | ||||
|  | ||||
|             # Cleaning caches is mostly unnecessary in Circle, because | ||||
|             # most builds don't get to write to the cache. | ||||
|             # mispipe "scripts/lib/clean-unused-caches --verbose --threshold 0" ts | ||||
|  | ||||
|       - save_cache: | ||||
|           paths: | ||||
|             - /srv/zulip-npm-cache | ||||
|           key: v1-npm-base.trusty-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} | ||||
|       - save_cache: | ||||
|           paths: | ||||
|             - /srv/zulip-venv-cache | ||||
|           key: v1-venv-base.trusty-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }} | ||||
|       # TODO: in Travis we also cache ~/zulip-emoji-cache, ~/node, ~/misc | ||||
|  | ||||
|       # The moment of truth!  Run the tests. | ||||
|  | ||||
|       - run: | ||||
|           name: run backend tests | ||||
|           command: | | ||||
|             . /srv/zulip-py3-venv/bin/activate | ||||
|             mispipe ./tools/travis/backend ts | ||||
|  | ||||
|       - run: | ||||
|           name: run frontend tests | ||||
|           command: | | ||||
|             . /srv/zulip-py3-venv/bin/activate | ||||
|             mispipe ./tools/travis/frontend ts | ||||
|  | ||||
|       -  run: | ||||
|           name: upload coverage report | ||||
|           command: | | ||||
|             . /srv/zulip-py3-venv/bin/activate | ||||
|             pip install codecov && codecov \ | ||||
|               || echo "Error in uploading coverage reports to codecov.io." | ||||
|  | ||||
|       # - store_artifacts:  # TODO | ||||
|       #     path: var/casper/ | ||||
|       #     # also /tmp/zulip-test-event-log/ | ||||
|       #     destination: test-reports | ||||
|  | ||||
|   "xenial-python-3.5": | ||||
|     docker: | ||||
|       # This is built from tools/circleci/images/xenial/Dockerfile . | ||||
|       - image: gregprice/circleci:xenial-python-3.test | ||||
|  | ||||
|     working_directory: ~/zulip | ||||
|  | ||||
|     steps: | ||||
|       - checkout | ||||
|  | ||||
|       - run: | ||||
|           name: create cache directories | ||||
|           command: | | ||||
|               dirs=(/srv/zulip-{npm,venv}-cache) | ||||
|               sudo mkdir -p "${dirs[@]}" | ||||
|               sudo chown -R circleci "${dirs[@]}" | ||||
|  | ||||
|       - restore_cache: | ||||
|           keys: | ||||
|           - v1-npm-base.xenial-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} | ||||
|       - restore_cache: | ||||
|           keys: | ||||
|           - v1-venv-base.xenial-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }} | ||||
|  | ||||
|       - run: | ||||
|           name: install dependencies | ||||
|           command: | | ||||
|             sudo apt-get install -y moreutils | ||||
|             rm -f /home/circleci/.gitconfig | ||||
|             mispipe "tools/travis/setup-backend" ts | ||||
|  | ||||
|       - save_cache: | ||||
|           paths: | ||||
|             - /srv/zulip-npm-cache | ||||
|           key: v1-npm-base.xenial-{{ checksum "package.json" }}-{{ checksum "yarn.lock" }} | ||||
|       - save_cache: | ||||
|           paths: | ||||
|             - /srv/zulip-venv-cache | ||||
|           key: v1-venv-base.xenial-{{ checksum "requirements/thumbor.txt" }}-{{ checksum "requirements/dev.txt" }} | ||||
|  | ||||
|       - run: | ||||
|           name: run backend tests | ||||
|           command: | | ||||
|             . /srv/zulip-py3-venv/bin/activate | ||||
|             mispipe ./tools/travis/backend ts | ||||
|  | ||||
|       -  run: | ||||
|           name: upload coverage report | ||||
|           command: | | ||||
|             . /srv/zulip-py3-venv/bin/activate | ||||
|             pip install codecov && codecov \ | ||||
|               || echo "Error in uploading coverage reports to codecov.io." | ||||
|  | ||||
| workflows: | ||||
|   version: 2 | ||||
|   build: | ||||
|     jobs: | ||||
|       - "xenial-backend-frontend-python3.5" | ||||
|       - "bionic-backend-python3.6" | ||||
|       - "trusty-python-3.4" | ||||
|       - "xenial-python-3.5" | ||||
|   | ||||
| @@ -5,8 +5,6 @@ coverage: | ||||
|     project: | ||||
|       default: | ||||
|         target: auto | ||||
|         # Codecov has the tendency to report a lot of false negatives, | ||||
|         # so we basically suppress comments completely. | ||||
|         threshold: 50% | ||||
|         threshold: 0.50 | ||||
|         base: auto | ||||
|     patch: off | ||||
|   | ||||
| @@ -6,20 +6,14 @@ charset = utf-8 | ||||
| trim_trailing_whitespace = true | ||||
| insert_final_newline = true | ||||
|  | ||||
| [*.{sh,py,pyi,js,ts,json,yml,xml,css,md,markdown,handlebars,html}] | ||||
| [*.{sh,py,pyi,js,json,yml,xml,css,md,markdown,handlebars,html}] | ||||
| indent_style = space | ||||
| indent_size = 4 | ||||
|  | ||||
| [*.py] | ||||
| max_line_length = 110 | ||||
|  | ||||
| [*.{js,ts}] | ||||
| max_line_length = 100 | ||||
|  | ||||
| [*.{svg,rb,pp,pl}] | ||||
| indent_style = space | ||||
| indent_size = 2 | ||||
|  | ||||
| [*.cfg] | ||||
| [*.{cfg}] | ||||
| indent_style = space | ||||
| indent_size = 8 | ||||
|   | ||||
| @@ -1,3 +1,2 @@ | ||||
| static/js/blueslip.js | ||||
| static/webpack-bundles | ||||
| static/js/js_typings/zulip/index.d.ts | ||||
|   | ||||
							
								
								
									
										599
									
								
								.eslintrc.json
									
									
									
									
									
								
							
							
						
						
									
										599
									
								
								.eslintrc.json
									
									
									
									
									
								
							| @@ -4,217 +4,176 @@ | ||||
|         "es6": true | ||||
|     }, | ||||
|     "parserOptions": { | ||||
|         "warnOnUnsupportedTypeScriptVersion": false, | ||||
|         "sourceType": "module" | ||||
|     }, | ||||
|     "globals": { | ||||
|         "$": false, | ||||
|         "ClipboardJS": false, | ||||
|         "Dict": false, | ||||
|         "FetchStatus": false, | ||||
|         "Filter": false, | ||||
|         "Handlebars": false, | ||||
|         "LightboxCanvas": false, | ||||
|         "MessageListData": false, | ||||
|         "MessageListView": false, | ||||
|         "Plotly": false, | ||||
|         "SockJS": false, | ||||
|         "Socket": false, | ||||
|         "Sortable": false, | ||||
|         "WinChan": false, | ||||
|         "XDate": false, | ||||
|         "_": false, | ||||
|         "activity": false, | ||||
|         "admin": false, | ||||
|         "alert_words": false, | ||||
|         "alert_words_ui": false, | ||||
|         "attachments_ui": false, | ||||
|         "avatar": false, | ||||
|         "billing": false, | ||||
|         "blueslip": false, | ||||
|         "bot_data": false, | ||||
|         "bridge": false, | ||||
|         "buddy_data": false, | ||||
|         "buddy_list": false, | ||||
|         "channel": false, | ||||
|         "click_handlers": false, | ||||
|         "color_data": false, | ||||
|         "colorspace": false, | ||||
|         "common": false, | ||||
|         "components": false, | ||||
|         "compose": false, | ||||
|         "compose_actions": false, | ||||
|         "compose_fade": false, | ||||
|         "compose_pm_pill": false, | ||||
|         "compose_state": false, | ||||
|         "compose_ui": false, | ||||
|         "composebox_typeahead": false, | ||||
|         "condense": false, | ||||
|         "confirm_dialog": false, | ||||
|         "copy_and_paste": false, | ||||
|         "csrf_token": false, | ||||
|         "current_msg_list": true, | ||||
|         "drafts": false, | ||||
|         "echo": false, | ||||
|         "emoji": false, | ||||
|         "emoji_codes": false, | ||||
|         "emoji_picker": false, | ||||
|         "favicon": false, | ||||
|         "feature_flags": false, | ||||
|         "feedback_widget": false, | ||||
|         "fenced_code": false, | ||||
|         "flatpickr": false, | ||||
|         "floating_recipient_bar": false, | ||||
|         "gear_menu": false, | ||||
|         "hash_util": false, | ||||
|         "hashchange": false, | ||||
|         "helpers": false, | ||||
|         "history": false, | ||||
|         "home_msg_list": false, | ||||
|         "hotspots": false, | ||||
|         "i18n": false, | ||||
|         "info_overlay": false, | ||||
|         "input_pill": false, | ||||
|         "invite": false, | ||||
|         "jQuery": false, | ||||
|         "katex": false, | ||||
|         "keydown_util": false, | ||||
|         "lightbox": false, | ||||
|         "list_cursor": false, | ||||
|         "list_render": false, | ||||
|         "list_util": false, | ||||
|         "loading": false, | ||||
|         "localStorage": false, | ||||
|         "local_message": false, | ||||
|         "localstorage": false, | ||||
|         "location": false, | ||||
|         "markdown": false, | ||||
|         "Spinner": false, | ||||
|         "Handlebars": false, | ||||
|         "XDate": false, | ||||
|         "zxcvbn": false, | ||||
|         "LazyLoad": false, | ||||
|         "SockJS": false, | ||||
|         "marked": false, | ||||
|         "md5": false, | ||||
|         "message_edit": false, | ||||
|         "message_events": false, | ||||
|         "message_fetch": false, | ||||
|         "message_flags": false, | ||||
|         "message_list": false, | ||||
|         "message_live_update": false, | ||||
|         "message_scroll": false, | ||||
|         "message_store": false, | ||||
|         "message_util": false, | ||||
|         "message_viewport": false, | ||||
|         "moment": false, | ||||
|         "muting": false, | ||||
|         "muting_ui": false, | ||||
|         "narrow": false, | ||||
|         "narrow_state": false, | ||||
|         "navigate": false, | ||||
|         "night_mode": false, | ||||
|         "notifications": false, | ||||
|         "overlays": false, | ||||
|         "padded_widget": false, | ||||
|         "i18n": false, | ||||
|         "DynamicText": false, | ||||
|         "LightboxCanvas": false, | ||||
|         "bridge": false, | ||||
|         "page_params": false, | ||||
|         "panels": false, | ||||
|         "people": false, | ||||
|         "pm_conversations": false, | ||||
|         "pm_list": false, | ||||
|         "pointer": false, | ||||
|         "popovers": false, | ||||
|         "presence": false, | ||||
|         "attachments_ui": false, | ||||
|         "csrf_token": false, | ||||
|         "typeahead_helper": false, | ||||
|         "pygments_data": false, | ||||
|         "reactions": false, | ||||
|         "realm_icon": false, | ||||
|         "realm_logo": false, | ||||
|         "realm_night_logo": false, | ||||
|         "recent_senders": false, | ||||
|         "reload": false, | ||||
|         "reload_state": false, | ||||
|         "reminder": false, | ||||
|         "resize": false, | ||||
|         "rows": false, | ||||
|         "rtl": false, | ||||
|         "run_test": false, | ||||
|         "schema": false, | ||||
|         "scroll_bar": false, | ||||
|         "scroll_util": false, | ||||
|         "search": false, | ||||
|         "search_pill": false, | ||||
|         "search_pill_widget": false, | ||||
|         "search_suggestion": false, | ||||
|         "search_util": false, | ||||
|         "sent_messages": false, | ||||
|         "popovers": false, | ||||
|         "server_events": false, | ||||
|         "server_events_dispatch": false, | ||||
|         "settings": false, | ||||
|         "settings_account": false, | ||||
|         "settings_bots": false, | ||||
|         "settings_display": false, | ||||
|         "settings_emoji": false, | ||||
|         "settings_exports": false, | ||||
|         "settings_linkifiers": false, | ||||
|         "settings_invites": false, | ||||
|         "settings_muting": false, | ||||
|         "settings_notifications": false, | ||||
|         "settings_org": false, | ||||
|         "settings_panel_menu": false, | ||||
|         "settings_profile_fields": false, | ||||
|         "settings_sections": false, | ||||
|         "settings_streams": false, | ||||
|         "settings_toggle": false, | ||||
|         "settings_ui": false, | ||||
|         "settings_user_groups": false, | ||||
|         "settings_users": false, | ||||
|         "starred_messages": false, | ||||
|         "stream_color": false, | ||||
|         "stream_create": false, | ||||
|         "stream_data": false, | ||||
|         "stream_edit": false, | ||||
|         "stream_events": false, | ||||
|         "stream_list": false, | ||||
|         "stream_muting": false, | ||||
|         "stream_popover": false, | ||||
|         "stream_sort": false, | ||||
|         "stream_ui_updates": false, | ||||
|         "StripeCheckout": false, | ||||
|         "submessage": false, | ||||
|         "subs": false, | ||||
|         "tab_bar": false, | ||||
|         "templates": false, | ||||
|         "tictactoe_widget": false, | ||||
|         "timerender": false, | ||||
|         "toMarkdown": false, | ||||
|         "todo_widget": false, | ||||
|         "top_left_corner": false, | ||||
|         "topic_data": false, | ||||
|         "topic_generator": false, | ||||
|         "topic_list": false, | ||||
|         "topic_zoom": false, | ||||
|         "transmit": false, | ||||
|         "tutorial": false, | ||||
|         "typeahead_helper": false, | ||||
|         "typing": false, | ||||
|         "typing_data": false, | ||||
|         "typing_events": false, | ||||
|         "message_scroll": false, | ||||
|         "keydown_util": false, | ||||
|         "info_overlay": false, | ||||
|         "ui": false, | ||||
|         "ui_init": false, | ||||
|         "ui_report": false, | ||||
|         "night_mode": false, | ||||
|         "ui_util": false, | ||||
|         "unread": false, | ||||
|         "unread_ops": false, | ||||
|         "unread_ui": false, | ||||
|         "upgrade": false, | ||||
|         "upload": false, | ||||
|         "upload_widget": false, | ||||
|         "user_events": false, | ||||
|         "user_groups": false, | ||||
|         "lightbox": false, | ||||
|         "input_pill": false, | ||||
|         "user_pill": false, | ||||
|         "user_search": false, | ||||
|         "user_status": false, | ||||
|         "user_status_ui": false, | ||||
|         "compose_pm_pill": false, | ||||
|         "stream_color": false, | ||||
|         "people": false, | ||||
|         "user_groups": false, | ||||
|         "navigate": false, | ||||
|         "toMarkdown": false, | ||||
|         "settings_account": false, | ||||
|         "settings_display": false, | ||||
|         "settings_notifications": false, | ||||
|         "settings_muting": false, | ||||
|         "settings_lab": false, | ||||
|         "settings_bots": false, | ||||
|         "settings_sections": false, | ||||
|         "settings_emoji": false, | ||||
|         "settings_org": false, | ||||
|         "settings_ui": false, | ||||
|         "settings_users": false, | ||||
|         "settings_streams": false, | ||||
|         "settings_filters": false, | ||||
|         "settings_invites": false, | ||||
|         "settings_user_groups": false, | ||||
|         "settings_profile_fields": false, | ||||
|         "settings": false, | ||||
|         "resize": false, | ||||
|         "loading": false, | ||||
|         "typing": false, | ||||
|         "typing_events": false, | ||||
|         "typing_data": false, | ||||
|         "typing_status": false, | ||||
|         "sent_messages": false, | ||||
|         "transmit": false, | ||||
|         "compose": false, | ||||
|         "compose_actions": false, | ||||
|         "compose_state": false, | ||||
|         "compose_fade": false, | ||||
|         "overlays": false, | ||||
|         "stream_create": false, | ||||
|         "stream_edit": false, | ||||
|         "subs": false, | ||||
|         "stream_muting": false, | ||||
|         "stream_events": false, | ||||
|         "timerender": false, | ||||
|         "message_live_update": false, | ||||
|         "message_edit": false, | ||||
|         "reload": false, | ||||
|         "composebox_typeahead": false, | ||||
|         "search": false, | ||||
|         "topic_list": false, | ||||
|         "topic_generator": false, | ||||
|         "gear_menu": false, | ||||
|         "hashchange": false, | ||||
|         "hash_util": false, | ||||
|         "FetchStatus": false, | ||||
|         "message_list": false, | ||||
|         "Filter": false, | ||||
|         "flatpickr": false, | ||||
|         "pointer": false, | ||||
|         "util": false, | ||||
|         "poll_widget": false, | ||||
|         "widgetize": false, | ||||
|         "zcommand": false, | ||||
|         "zform": false, | ||||
|         "zxcvbn": false | ||||
|         "MessageListView": false, | ||||
|         "blueslip": false, | ||||
|         "rows": false, | ||||
|         "WinChan": false, | ||||
|         "muting_ui": false, | ||||
|         "Socket": false, | ||||
|         "channel": false, | ||||
|         "components": false, | ||||
|         "message_viewport": false, | ||||
|         "upload_widget": false, | ||||
|         "avatar": false, | ||||
|         "realm_icon": false, | ||||
|         "feature_flags": false, | ||||
|         "search_suggestion": false, | ||||
|         "notifications": false, | ||||
|         "message_flags": false, | ||||
|         "bot_data": false, | ||||
|         "top_left_corner": false, | ||||
|         "stream_sort": false, | ||||
|         "stream_list": false, | ||||
|         "stream_popover": false, | ||||
|         "narrow_state": false, | ||||
|         "narrow": false, | ||||
|         "admin_sections": false, | ||||
|         "admin": false, | ||||
|         "stream_data": false, | ||||
|         "topic_data": false, | ||||
|         "list_util": false, | ||||
|         "muting": false, | ||||
|         "Dict": false, | ||||
|         "unread": false, | ||||
|         "alert_words_ui": false, | ||||
|         "message_store": false, | ||||
|         "message_util": false, | ||||
|         "message_events": false, | ||||
|         "message_fetch": false, | ||||
|         "favicon": false, | ||||
|         "condense": false, | ||||
|         "list_render": false, | ||||
|         "floating_recipient_bar": false, | ||||
|         "tab_bar": false, | ||||
|         "emoji": false, | ||||
|         "presence": false, | ||||
|         "activity": false, | ||||
|         "invite": false, | ||||
|         "colorspace": false, | ||||
|         "reactions": false, | ||||
|         "tutorial": false, | ||||
|         "templates": false, | ||||
|         "alert_words": false, | ||||
|         "fenced_code": false, | ||||
|         "markdown": false, | ||||
|         "echo": false, | ||||
|         "localstorage": false, | ||||
|         "localStorage": false, | ||||
|         "current_msg_list": true, | ||||
|         "home_msg_list": false, | ||||
|         "pm_list": false, | ||||
|         "pm_conversations": false, | ||||
|         "recent_senders": false, | ||||
|         "unread_ui": false, | ||||
|         "unread_ops": false, | ||||
|         "upload": false, | ||||
|         "user_events": false, | ||||
|         "Plotly": false, | ||||
|         "emoji_codes": false, | ||||
|         "drafts": false, | ||||
|         "katex": false, | ||||
|         "ClipboardJS": false, | ||||
|         "emoji_picker": false, | ||||
|         "hotspots": false, | ||||
|         "compose_ui": false, | ||||
|         "common": false, | ||||
|         "panels": false, | ||||
|         "PerfectScrollbar": false | ||||
|     }, | ||||
|     "plugins": [ | ||||
|         "eslint-plugin-empty-returns" | ||||
| @@ -223,9 +182,9 @@ | ||||
|         "array-callback-return": "error", | ||||
|         "array-bracket-spacing": "error", | ||||
|         "arrow-spacing": [ "error", { "before": true, "after": true } ], | ||||
|         "block-scoped-var": "error", | ||||
|         "block-scoped-var": 2, | ||||
|         "brace-style": [ "error", "1tbs", { "allowSingleLine": true } ], | ||||
|         "camelcase": "off", | ||||
|         "camelcase": 0, | ||||
|         "comma-dangle": [ "error", | ||||
|             { | ||||
|                 "arrays": "always-multiline", | ||||
| @@ -235,35 +194,14 @@ | ||||
|                 "functions": "never" | ||||
|             } | ||||
|         ], | ||||
|         "comma-spacing": [ "error", | ||||
|             { | ||||
|                 "before": false, | ||||
|                 "after": true | ||||
|             } | ||||
|         ], | ||||
|         "complexity": [ "off", 4 ], | ||||
|         "curly": "error", | ||||
|         "complexity": [ 0, 4 ], | ||||
|         "curly": 2, | ||||
|         "dot-notation": [ "error", { "allowKeywords": true } ], | ||||
|         "empty-returns/main": "error", | ||||
|         "eol-last": [ "error", "always" ], | ||||
|         "eqeqeq": "error", | ||||
|         "eqeqeq": 2, | ||||
|         "func-style": [ "off", "expression" ], | ||||
|         "guard-for-in": "error", | ||||
|         "indent": ["error", 4, { | ||||
|             "ArrayExpression": "first", | ||||
|             "outerIIFEBody": 0, | ||||
|             "ObjectExpression": "first", | ||||
|             "SwitchCase": 0, | ||||
|             "CallExpression": {"arguments": "first"}, | ||||
|             "FunctionExpression": {"parameters": "first"}, | ||||
|             "FunctionDeclaration": {"parameters": "first"} | ||||
|         }], | ||||
|         "key-spacing": [ "error", | ||||
|             { | ||||
|                 "beforeColon": false, | ||||
|                 "afterColon": true | ||||
|             } | ||||
|         ], | ||||
|         "guard-for-in": 2, | ||||
|         "keyword-spacing": [ "error", | ||||
|             { | ||||
|                 "before": true, | ||||
| @@ -275,7 +213,7 @@ | ||||
|                 } | ||||
|             } | ||||
|         ], | ||||
|         "max-depth": [ "off", 4 ], | ||||
|         "max-depth": [ 0, 4 ], | ||||
|         "max-len": [ "error", 100, 2, | ||||
|             { | ||||
|                 "ignoreUrls": true, | ||||
| @@ -285,76 +223,75 @@ | ||||
|                 "ignoreTemplateLiterals": true | ||||
|             } | ||||
|         ], | ||||
|         "max-params": [ "off", 3 ], | ||||
|         "max-statements": [ "off", 10 ], | ||||
|         "max-params": [ 0, 3 ], | ||||
|         "max-statements": [ 0, 10 ], | ||||
|         "new-cap": [ "error", | ||||
|             { | ||||
|                 "newIsCap": true, | ||||
|                 "capIsNew": false | ||||
|             } | ||||
|         ], | ||||
|         "new-parens": "error", | ||||
|         "newline-per-chained-call": "off", | ||||
|         "no-alert": "error", | ||||
|         "new-parens": 2, | ||||
|         "newline-per-chained-call": 0, | ||||
|         "no-alert": 2, | ||||
|         "no-array-constructor": "error", | ||||
|         "no-bitwise": "error", | ||||
|         "no-caller": "error", | ||||
|         "no-bitwise": 2, | ||||
|         "no-caller": 2, | ||||
|         "no-case-declarations": "error", | ||||
|         "no-catch-shadow": "error", | ||||
|         "no-console": "off", | ||||
|         "no-catch-shadow": 2, | ||||
|         "no-console": 0, | ||||
|         "no-const-assign": "error", | ||||
|         "no-control-regex": "error", | ||||
|         "no-debugger": "error", | ||||
|         "no-delete-var": "error", | ||||
|         "no-div-regex": "error", | ||||
|         "no-control-regex": 2, | ||||
|         "no-debugger": 2, | ||||
|         "no-delete-var": 2, | ||||
|         "no-div-regex": 2, | ||||
|         "no-dupe-class-members": "error", | ||||
|         "no-dupe-keys": "error", | ||||
|         "no-dupe-keys": 2, | ||||
|         "no-duplicate-imports": "error", | ||||
|         "no-else-return": "error", | ||||
|         "no-empty": "error", | ||||
|         "no-empty-character-class": "error", | ||||
|         "no-eq-null": "error", | ||||
|         "no-eval": "error", | ||||
|         "no-ex-assign": "error", | ||||
|         "no-extra-parens": ["error", "all"], | ||||
|         "no-extra-semi": "error", | ||||
|         "no-fallthrough": "error", | ||||
|         "no-floating-decimal": "error", | ||||
|         "no-func-assign": "error", | ||||
|         "no-implied-eval": "error", | ||||
|         "no-else-return": 2, | ||||
|         "no-empty": 2, | ||||
|         "no-empty-character-class": 2, | ||||
|         "no-eq-null": 2, | ||||
|         "no-eval": 2, | ||||
|         "no-ex-assign": 2, | ||||
|         "no-extra-parens": [ "error", "functions" ], | ||||
|         "no-extra-semi": 2, | ||||
|         "no-fallthrough": 2, | ||||
|         "no-floating-decimal": 2, | ||||
|         "no-func-assign": 2, | ||||
|         "no-implied-eval": 2, | ||||
|         "no-iterator": "error", | ||||
|         "no-label-var": "error", | ||||
|         "no-labels": "error", | ||||
|         "no-loop-func": "error", | ||||
|         "no-mixed-requires": [ "off", false ], | ||||
|         "no-multi-str": "error", | ||||
|         "no-native-reassign": "error", | ||||
|         "no-nested-ternary": "off", | ||||
|         "no-label-var": 2, | ||||
|         "no-labels": 2, | ||||
|         "no-loop-func": 2, | ||||
|         "no-mixed-requires": [ 0, false ], | ||||
|         "no-multi-str": 2, | ||||
|         "no-native-reassign": 2, | ||||
|         "no-nested-ternary": 0, | ||||
|         "no-new-func": "error", | ||||
|         "no-new-object": "error", | ||||
|         "no-new-wrappers": "error", | ||||
|         "no-obj-calls": "error", | ||||
|         "no-octal": "error", | ||||
|         "no-octal-escape": "error", | ||||
|         "no-param-reassign": "off", | ||||
|         "no-plusplus": "error", | ||||
|         "no-proto": "error", | ||||
|         "no-redeclare": "error", | ||||
|         "no-regex-spaces": "error", | ||||
|         "no-restricted-syntax": "off", | ||||
|         "no-return-assign": "error", | ||||
|         "no-script-url": "error", | ||||
|         "no-self-compare": "error", | ||||
|         "no-shadow": "off", | ||||
|         "no-sync": "error", | ||||
|         "no-ternary": "off", | ||||
|         "no-trailing-spaces": "error", | ||||
|         "no-new-object": 2, | ||||
|         "no-new-wrappers": 2, | ||||
|         "no-obj-calls": 2, | ||||
|         "no-octal": 2, | ||||
|         "no-octal-escape": 2, | ||||
|         "no-param-reassign": 0, | ||||
|         "no-plusplus": 2, | ||||
|         "no-proto": 2, | ||||
|         "no-redeclare": 2, | ||||
|         "no-regex-spaces": 2, | ||||
|         "no-restricted-syntax": 0, | ||||
|         "no-return-assign": 2, | ||||
|         "no-script-url": 2, | ||||
|         "no-self-compare": 2, | ||||
|         "no-shadow": 0, | ||||
|         "no-sync": 2, | ||||
|         "no-ternary": 0, | ||||
|         "no-undef": "error", | ||||
|         "no-undef-init": "error", | ||||
|         "no-underscore-dangle": "off", | ||||
|         "no-undef-init": 2, | ||||
|         "no-underscore-dangle": 0, | ||||
|         "no-unneeded-ternary": [ "error", { "defaultAssignment": false } ], | ||||
|         "no-unreachable": "error", | ||||
|         "no-unused-expressions": "error", | ||||
|         "no-unreachable": 2, | ||||
|         "no-unused-expressions": 2, | ||||
|         "no-unused-vars": [ "error", | ||||
|             { | ||||
|                 "vars": "local", | ||||
| @@ -362,18 +299,17 @@ | ||||
|                 "varsIgnorePattern": "print_elapsed_time|check_duplicate_ids" | ||||
|             } | ||||
|         ], | ||||
|         "no-use-before-define": "error", | ||||
|         "no-use-before-define": 2, | ||||
|         "no-useless-constructor": "error", | ||||
|         // The Zulip codebase complies partially with the "no-useless-escape" | ||||
|         // rule; only regex expressions haven't been updated yet. | ||||
|         // Updated regex expressions are currently being tested in casper | ||||
|         // files and will decide about a potential future enforcement of this rule. | ||||
|         "no-useless-escape": "off", | ||||
|         "space-unary-ops": "error", | ||||
|         "no-whitespace-before-property": "error", | ||||
|         "no-with": "error", | ||||
|         "no-useless-escape": 0, | ||||
|         "no-whitespace-before-property": 0, | ||||
|         "no-with": 2, | ||||
|         "one-var": [ "error", "never" ], | ||||
|         "padded-blocks": "off", | ||||
|         "padded-blocks": 0, | ||||
|         "prefer-const": [ "error", | ||||
|             { | ||||
|                 "destructuring": "any", | ||||
| @@ -387,11 +323,10 @@ | ||||
|                 "numbers": false | ||||
|             } | ||||
|         ], | ||||
|         "quotes": [ "off", "single" ], | ||||
|         "radix": "error", | ||||
|         "semi": "error", | ||||
|         "semi-spacing": ["error", {"before": false, "after": true}], | ||||
|         "space-before-blocks": "error", | ||||
|         "quotes": [ 0, "single" ], | ||||
|         "radix": 2, | ||||
|         "semi": 2, | ||||
|         "space-before-blocks": 2, | ||||
|         "space-before-function-paren": [ "error", | ||||
|             { | ||||
|                 "anonymous": "always", | ||||
| @@ -399,88 +334,16 @@ | ||||
|                 "asyncArrow": "always" | ||||
|             } | ||||
|         ], | ||||
|         "space-in-parens": "error", | ||||
|         "space-infix-ops": "error", | ||||
|         "spaced-comment": "off", | ||||
|         "strict": "off", | ||||
|         "space-in-parens": 2, | ||||
|         "space-infix-ops": 0, | ||||
|         "spaced-comment": 0, | ||||
|         "strict": 0, | ||||
|         "template-curly-spacing": "error", | ||||
|         "unnecessary-strict": "off", | ||||
|         "use-isnan": "error", | ||||
|         "unnecessary-strict": 0, | ||||
|         "use-isnan": 2, | ||||
|         "valid-typeof": [ "error", { "requireStringLiterals": true } ], | ||||
|         "wrap-iife": [ "error", "outside", { "functionPrototypeMethods": false } ], | ||||
|         "wrap-regex": "off", | ||||
|         "yoda": "error" | ||||
|     }, | ||||
|     "overrides": [ | ||||
|         { | ||||
|             "files": ["**/*.ts"], | ||||
|             "parser": "@typescript-eslint/parser", | ||||
|             "parserOptions": { | ||||
|                 "project": "static/js/tsconfig.json" | ||||
|             }, | ||||
|             "plugins": ["@typescript-eslint"], | ||||
|             "rules": { | ||||
|                 // Disable base rule to avoid conflict | ||||
|                 "empty-returns/main": "off", | ||||
|                 "indent": "off", | ||||
|                 "func-call-spacing": "off", | ||||
|                 "no-magic-numbers": "off", | ||||
|                 "semi": "off", | ||||
|                 "no-unused-vars": "off", | ||||
|  | ||||
|                 "@typescript-eslint/adjacent-overload-signatures": "error", | ||||
|                 "@typescript-eslint/array-type": "error", | ||||
|                 "@typescript-eslint/await-thenable": "error", | ||||
|                 "@typescript-eslint/ban-types": "error", | ||||
|                 "@typescript-eslint/ban-ts-ignore": "off", | ||||
|                 "@typescript-eslint/camelcase": "off", | ||||
|                 "@typescript-eslint/class-name-casing": "error", | ||||
|                 "@typescript-eslint/explicit-function-return-type": ["error", { "allowExpressions": true }], | ||||
|                 "@typescript-eslint/explicit-member-accessibility": "off", | ||||
|                 "@typescript-eslint/func-call-spacing": "error", | ||||
|                 "@typescript-eslint/generic-type-naming": "off", | ||||
|                 "@typescript-eslint/indent": "error", | ||||
|                 "@typescript-eslint/interface-name-prefix": "off", | ||||
|                 "@typescript-eslint/member-delimiter-style": "error", | ||||
|                 "@typescript-eslint/member-naming": ["error", { "private": "^_" } ], | ||||
|                 "@typescript-eslint/member-ordering": "error", | ||||
|                 "@typescript-eslint/no-angle-bracket-type-assertion": "error", | ||||
|                 "@typescript-eslint/no-array-constructor": "error", | ||||
|                 "@typescript-eslint/no-empty-interface": "error", | ||||
|                 "@typescript-eslint/no-explicit-any": "off", | ||||
|                 "@typescript-eslint/no-extraneous-class": "error", | ||||
|                 "@typescript-eslint/no-for-in-array": "off", | ||||
|                 "@typescript-eslint/no-inferrable-types": "error", | ||||
|                 "@typescript-eslint/no-magic-numbers": "error", | ||||
|                 "@typescript-eslint/no-misused-new": "error", | ||||
|                 "@typescript-eslint/no-namespace": "error", | ||||
|                 "@typescript-eslint/no-non-null-assertion": "off", | ||||
|                 "@typescript-eslint/no-object-literal-type-assertion": "error", | ||||
|                 "@typescript-eslint/no-parameter-properties": "error", | ||||
|                 "@typescript-eslint/no-require-imports": "off", | ||||
|                 "@typescript-eslint/no-this-alias": "off", | ||||
|                 "@typescript-eslint/no-triple-slash-reference": "error", | ||||
|                 "@typescript-eslint/no-type-alias": "off", | ||||
|                 "@typescript-eslint/no-unnecessary-qualifier": "error", | ||||
|                 "@typescript-eslint/no-unnecessary-type-assertion": "error", | ||||
|                 "@typescript-eslint/no-unused-vars": ["error", { "varsIgnorePattern": "^_" } ], | ||||
|                 "@typescript-eslint/no-use-before-define": "error", | ||||
|                 "@typescript-eslint/no-useless-constructor": "error", | ||||
|                 "@typescript-eslint/no-var-requires": "off", | ||||
|                 "@typescript-eslint/prefer-for-of": "off", | ||||
|                 "@typescript-eslint/prefer-function-type": "off", | ||||
|                 "@typescript-eslint/prefer-includes": "error", | ||||
|                 "@typescript-eslint/prefer-interface": "off", | ||||
|                 "@typescript-eslint/prefer-namespace-keyword": "error", | ||||
|                 "@typescript-eslint/prefer-regexp-exec": "error", | ||||
|                 "@typescript-eslint/prefer-string-starts-ends-with": "error", | ||||
|                 "@typescript-eslint/promise-function-async": "error", | ||||
|                 "@typescript-eslint/restrict-plus-operands": "off", | ||||
|                 "@typescript-eslint/semi": "error", | ||||
|                 "@typescript-eslint/type-annotation-spacing": "error", | ||||
|                 "@typescript-eslint/unbound-method": "off", | ||||
|                 "@typescript-eslint/unified-signatures": "error" | ||||
|             } | ||||
|         } | ||||
|     ] | ||||
|         "wrap-regex": 0, | ||||
|         "yoda": 2 | ||||
|     } | ||||
| } | ||||
|   | ||||
							
								
								
									
										2
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								.gitattributes
									
									
									
									
										vendored
									
									
								
							| @@ -10,4 +10,4 @@ | ||||
| *.png binary | ||||
| *.otf binary | ||||
| *.tif binary | ||||
| *.ogg binary | ||||
| yarn.lock binary | ||||
|   | ||||
							
								
								
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										9
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @@ -29,17 +29,9 @@ package-lock.json | ||||
| /.vagrant | ||||
| /var | ||||
|  | ||||
| /.dmypy.json | ||||
|  | ||||
| # Dockerfiles generated for CircleCI | ||||
| /tools/circleci/images | ||||
|  | ||||
| # Generated i18n data | ||||
| /locale/en | ||||
| /locale/language_options.json | ||||
| /locale/language_name_map.json | ||||
| /locale/*/mobile.json | ||||
|  | ||||
| # Static build | ||||
| *.mo | ||||
| npm-debug.log | ||||
| @@ -48,7 +40,6 @@ npm-debug.log | ||||
| /staticfiles.json | ||||
| /webpack-stats-production.json | ||||
| /yarn-error.log | ||||
| zulip-git-version | ||||
|  | ||||
| # Test / analysis tools | ||||
| .coverage | ||||
|   | ||||
							
								
								
									
										67
									
								
								.stylelintrc
									
									
									
									
									
								
							
							
						
						
									
										67
									
								
								.stylelintrc
									
									
									
									
									
								
							| @@ -1,67 +0,0 @@ | ||||
| { | ||||
|     "rules": { | ||||
|         # Stylistic rules for CSS. | ||||
|         "function-comma-space-after": "always", | ||||
|         "function-comma-space-before": "never", | ||||
|         "function-max-empty-lines": 0, | ||||
|         "function-whitespace-after": "always", | ||||
|  | ||||
|         "value-keyword-case": "lower", | ||||
|         "value-list-comma-newline-after": "always-multi-line", | ||||
|         "value-list-comma-space-after": "always-single-line", | ||||
|         "value-list-comma-space-before": "never", | ||||
|         "value-list-max-empty-lines": 0, | ||||
|  | ||||
|         "unit-case": "lower", | ||||
|         "property-case": "lower", | ||||
|         "color-hex-case": "lower", | ||||
|  | ||||
|         "declaration-bang-space-before": "always", | ||||
|         "declaration-colon-newline-after": "always-multi-line", | ||||
|         "declaration-colon-space-after": "always-single-line", | ||||
|         "declaration-colon-space-before": "never", | ||||
|         "declaration-block-semicolon-newline-after": "always", | ||||
|         "declaration-block-semicolon-space-before": "never", | ||||
|         "declaration-block-trailing-semicolon": "always", | ||||
|  | ||||
|         "block-closing-brace-empty-line-before": "never", | ||||
|         "block-closing-brace-newline-after": "always", | ||||
|         "block-closing-brace-newline-before": "always", | ||||
|         "block-opening-brace-newline-after": "always", | ||||
|         "block-opening-brace-space-before": "always", | ||||
|  | ||||
|         "selector-attribute-brackets-space-inside": "never", | ||||
|         "selector-attribute-operator-space-after": "never", | ||||
|         "selector-attribute-operator-space-before": "never", | ||||
|         "selector-combinator-space-after": "always", | ||||
|         "selector-combinator-space-before": "always", | ||||
|         "selector-descendant-combinator-no-non-space": true, | ||||
|         "selector-pseudo-class-parentheses-space-inside": "never", | ||||
|         "selector-pseudo-element-case": "lower", | ||||
|         "selector-pseudo-element-colon-notation": "double", | ||||
|         "selector-type-case": "lower", | ||||
|         "selector-list-comma-newline-after": "always", | ||||
|         "selector-list-comma-space-before": "never", | ||||
|  | ||||
|         "media-feature-colon-space-after": "always", | ||||
|         "media-feature-colon-space-before": "never", | ||||
|         "media-feature-name-case": "lower", | ||||
|         "media-feature-parentheses-space-inside": "never", | ||||
|         "media-feature-range-operator-space-after": "always", | ||||
|         "media-feature-range-operator-space-before": "always", | ||||
|         "media-query-list-comma-newline-after": "always", | ||||
|         "media-query-list-comma-space-before": "never", | ||||
|  | ||||
|         "at-rule-name-case": "lower", | ||||
|         "at-rule-name-space-after": "always", | ||||
|         "at-rule-semicolon-newline-after": "always", | ||||
|         "at-rule-semicolon-space-before": "never", | ||||
|  | ||||
|         "comment-whitespace-inside": "always", | ||||
|         "indentation": 4, | ||||
|          | ||||
|         # Limit language features | ||||
|         "color-no-hex": true, | ||||
|         "color-named": "never", | ||||
|     } | ||||
| } | ||||
							
								
								
									
										19
									
								
								.travis.yml
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								.travis.yml
									
									
									
									
									
								
							| @@ -1,6 +1,7 @@ | ||||
| # See https://zulip.readthedocs.io/en/latest/testing/continuous-integration.html for | ||||
| # See https://zulip.readthedocs.io/en/latest/testing/travis.html for | ||||
| # high-level documentation on our Travis CI setup. | ||||
| dist: xenial | ||||
| dist: trusty | ||||
| group: deprecated-2017Q4 | ||||
| install: | ||||
|   # Disable sometimes-broken sources.list in Travis base images | ||||
|   - sudo rm -vf /etc/apt/sources.list.d/* | ||||
| @@ -14,7 +15,7 @@ install: | ||||
|   - mispipe "pip install codecov" ts || mispipe "pip install codecov" ts | ||||
|  | ||||
|   # This is the main setup job for the test suite | ||||
|   - mispipe "tools/ci/setup-$TEST_SUITE" ts | ||||
|   - mispipe "tools/travis/setup-$TEST_SUITE" ts | ||||
|  | ||||
|   # Clean any caches that are not in use to avoid our cache | ||||
|   # becoming huge. | ||||
| @@ -25,7 +26,7 @@ script: | ||||
|   # broken running their system puppet with Ruby.  See | ||||
|   # https://travis-ci.org/zulip/zulip/jobs/240120991 for an example traceback. | ||||
|   - unset GEM_PATH | ||||
|   - mispipe "./tools/ci/$TEST_SUITE" ts | ||||
|   - mispipe "./tools/travis/$TEST_SUITE" ts | ||||
| cache: | ||||
|   yarn: true | ||||
|   apt: false | ||||
| @@ -37,17 +38,17 @@ cache: | ||||
|     - $HOME/misc | ||||
| env: | ||||
|   global: | ||||
|     - BOTO_CONFIG=/nonexistent | ||||
|     - BOTO_CONFIG=/tmp/nowhere | ||||
| language: python | ||||
| # Our test suites generally run on Python 3.5, the version in | ||||
| # Ubuntu 16.04 xenial, which is the oldest OS release we support. | ||||
| # Our test suites generally run on Python 3.4, the version in | ||||
| # Ubuntu 14.04 trusty, which is the oldest OS release we support. | ||||
| matrix: | ||||
|   include: | ||||
|     # Travis will actually run the jobs in the order they're listed here; | ||||
|     # that doesn't seem to be documented, but it's what we see empirically. | ||||
|     # We only get 4 jobs running at a time, so we try to make the first few | ||||
|     # the most likely to break. | ||||
|     - python: "3.5" | ||||
|     - python: "3.4" | ||||
|       env: TEST_SUITE=production | ||||
|     # Other suites moved to CircleCI -- see .circleci/. | ||||
| sudo: required | ||||
| @@ -58,7 +59,7 @@ addons: | ||||
|       # debugging test flakes. | ||||
|       - $(ls var/casper/* | tr "\n" ":") | ||||
|       - $(ls /tmp/zulip-test-event-log/* | tr "\n" ":") | ||||
|   postgresql: "9.5" | ||||
|   postgresql: "9.3" | ||||
|   apt: | ||||
|     packages: | ||||
|       - moreutils | ||||
|   | ||||
							
								
								
									
										22
									
								
								.tx/config
									
									
									
									
									
								
							
							
						
						
									
										22
									
								
								.tx/config
									
									
									
									
									
								
							| @@ -3,31 +3,31 @@ host = https://www.transifex.com | ||||
| lang_map = zh-Hans: zh_Hans, zh-Hant: zh_Hant | ||||
|  | ||||
| [zulip.djangopo] | ||||
| file_filter = locale/<lang>/LC_MESSAGES/django.po | ||||
| source_file = locale/en/LC_MESSAGES/django.po | ||||
| source_file = static/locale/en/LC_MESSAGES/django.po | ||||
| source_lang = en | ||||
| type = PO | ||||
| file_filter = static/locale/<lang>/LC_MESSAGES/django.po | ||||
|  | ||||
| [zulip.translationsjson] | ||||
| file_filter = locale/<lang>/translations.json | ||||
| source_file = locale/en/translations.json | ||||
| source_file = static/locale/en/translations.json | ||||
| source_lang = en | ||||
| type = KEYVALUEJSON | ||||
| file_filter = static/locale/<lang>/translations.json | ||||
|  | ||||
| [zulip.mobile] | ||||
| file_filter = locale/<lang>/mobile.json | ||||
| source_file = locale/en/mobile.json | ||||
| [zulip.messages] | ||||
| source_file = static/locale/en/mobile.json | ||||
| source_lang = en | ||||
| type = KEYVALUEJSON | ||||
| file_filter = static/locale/<lang>/mobile.json | ||||
|  | ||||
| [zulip-test.djangopo] | ||||
| file_filter = locale/<lang>/LC_MESSAGES/django.po | ||||
| source_file = locale/en/LC_MESSAGES/django.po | ||||
| source_file = static/locale/en/LC_MESSAGES/django.po | ||||
| source_lang = en | ||||
| type = PO | ||||
| file_filter = static/locale/<lang>/LC_MESSAGES/django.po | ||||
|  | ||||
| [zulip-test.translationsjson] | ||||
| file_filter = locale/<lang>/translations.json | ||||
| source_file = locale/en/translations.json | ||||
| source_file = static/locale/en/translations.json | ||||
| source_lang = en | ||||
| type = KEYVALUEJSON | ||||
| file_filter = static/locale/<lang>/translations.json | ||||
|   | ||||
| @@ -78,7 +78,7 @@ something you can do while a violation is happening, do it. A lot of the | ||||
| harms of harassment and other violations can be mitigated by the victim | ||||
| knowing that the other people present are on their side. | ||||
|  | ||||
| All reports will be kept confidential. In some cases, we may determine that a | ||||
| All reports will be kept confidential. In some cases we may determine that a | ||||
| public statement will need to be made. In such cases, the identities of all | ||||
| victims and reporters will remain confidential unless those individuals | ||||
| instruct us otherwise. | ||||
|   | ||||
| @@ -13,8 +13,7 @@ user, or anything else. Make sure to read the | ||||
| 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 or our | ||||
| [Twitter](https://twitter.com/zulip) account for a lower traffic (~1 | ||||
| You can subscribe to zulip-devel@googlegroups.com for a lower traffic (~1 | ||||
| email/month) way to hear about things like mentorship opportunities with Google | ||||
| Code-in, in-person sprints at conferences, and other opportunities to | ||||
| contribute. | ||||
| @@ -29,10 +28,10 @@ needs doing: | ||||
|   [backend](https://github.com/zulip/zulip), web | ||||
|   [frontend](https://github.com/zulip/zulip), React Native | ||||
|   [mobile app](https://github.com/zulip/zulip-mobile), or Electron | ||||
|   [desktop app](https://github.com/zulip/zulip-desktop). | ||||
|   [desktop app](https://github.com/zulip/zulip-electron). | ||||
| * Building out our | ||||
|   [Python API and bots](https://github.com/zulip/python-zulip-api) framework. | ||||
| * [Writing an integration](https://zulipchat.com/api/integrations-overview). | ||||
| * [Writing an integration](https://zulipchat.com/api/integration-guide). | ||||
| * Improving our [user](https://zulipchat.com/help/) or | ||||
|   [developer](https://zulip.readthedocs.io/en/latest/) documentation. | ||||
| * [Reviewing code](https://zulip.readthedocs.io/en/latest/contributing/code-reviewing.html) | ||||
| @@ -59,22 +58,21 @@ to help. | ||||
|   [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 | ||||
|   [#new members](https://chat.zulip.org/#narrow/stream/new.20members), using | ||||
|   your name as the topic. Bonus: tell us about your first impressions of | ||||
|   Zulip, and anything that felt confusing/broken as you started using the | ||||
|   product. | ||||
| * 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 | ||||
|   [#development help](https://chat.zulip.org/#narrow/stream/49-development-help) | ||||
|   [#development help](https://chat.zulip.org/#narrow/stream/development.20help) | ||||
|   if you run into any troubles. | ||||
| * 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). | ||||
|   and do the Git tutorial (coming soon) if you are unfamiliar with Git, | ||||
|   getting help in | ||||
|   [#git help](https://chat.zulip.org/#narrow/stream/git.20help) if you run | ||||
|   into any troubles. | ||||
| * Sign the | ||||
|   [Dropbox Contributor License Agreement](https://opensource.dropbox.com/cla/). | ||||
|  | ||||
| @@ -86,53 +84,43 @@ on. | ||||
|  | ||||
| * 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), | ||||
|   [desktop](https://github.com/zulip/zulip-electron/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". | ||||
|   [#mobile](https://chat.zulip.org/#narrow/stream/mobile), | ||||
|   [#electron](https://chat.zulip.org/#narrow/stream/electron), or | ||||
|   [#bots](https://chat.zulip.org/#narrow/stream/bots). | ||||
| * For the main server and web repository, start by looking through issues | ||||
|   with the label | ||||
|   [good first issue](https://github.com/zulip/zulip/issues?q=is%3Aopen+is%3Aissue+label%3A"good+first+issue"). | ||||
|   These are smaller projects particularly suitable for a first contribution. | ||||
| * 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. | ||||
| * If the lists of issues are overwhelming, post in | ||||
|   [#new members](https://chat.zulip.org/#narrow/stream/95-new-members) with a | ||||
|   [#new members](https://chat.zulip.org/#narrow/stream/new.20members) 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 | ||||
|   frontend (JavaScript), mobile (React Native), desktop (Electron), | ||||
|   documentation (English) or visual design (JavaScript + CSS) issue, and a | ||||
|   bit about your programming experience and available time. | ||||
|  | ||||
| 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 | ||||
| [#new members](https://chat.zulip.org/#narrow/stream/new.20members) with the | ||||
| feature idea and the problem that you're hoping to solve. | ||||
|  | ||||
| Other notes: | ||||
| * 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 | ||||
| * The full list of issues looking for a contributor can be found with the | ||||
|   [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. | ||||
|   label. | ||||
| * 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. | ||||
| @@ -144,12 +132,6 @@ 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: | ||||
|  | ||||
| * 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 | ||||
| @@ -204,9 +186,9 @@ bugs, feel free to just open an issue on the relevant project on GitHub. | ||||
|  | ||||
| If you have a feature request or are not yet sure what the underlying bug | ||||
| 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 | ||||
| [#issues](https://chat.zulip.org/#narrow/stream/issues) (or | ||||
| [#mobile](https://chat.zulip.org/#narrow/stream/mobile) or | ||||
| [#electron](https://chat.zulip.org/#narrow/stream/electron)) on the | ||||
| [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 | ||||
| @@ -319,7 +301,7 @@ 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 | ||||
|   [desktop](https://github.com/zulip/zulip-electron), and | ||||
|   [Python API](https://github.com/zulip/python-zulip-api). | ||||
| * [Follow us](https://twitter.com/zulip) on Twitter. | ||||
|  | ||||
|   | ||||
							
								
								
									
										17
									
								
								Dockerfile-dev
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								Dockerfile-dev
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| FROM ubuntu:trusty | ||||
|  | ||||
| EXPOSE 9991 | ||||
|  | ||||
| RUN apt-get update && apt-get install -y wget | ||||
|  | ||||
| RUN locale-gen en_US.UTF-8 | ||||
|  | ||||
| RUN useradd -d /home/zulip -m zulip && echo 'zulip ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers | ||||
|  | ||||
| USER zulip | ||||
|  | ||||
| RUN ln -nsf /srv/zulip ~/zulip | ||||
|  | ||||
| RUN echo 'export LC_ALL="en_US.UTF-8" LANG="en_US.UTF-8" LANGUAGE="en_US.UTF-8"' >> ~zulip/.bashrc | ||||
|  | ||||
| WORKDIR /srv/zulip | ||||
| @@ -1,23 +0,0 @@ | ||||
| # To build run `docker build -f Dockerfile-postgresql .` from the root of the | ||||
| # zulip repo. | ||||
|  | ||||
| # Currently the postgres images do not support automatic upgrading of | ||||
| # the on-disk data in volumes. So the base image can not currently be upgraded | ||||
| # without users needing a manual pgdump and restore. | ||||
|  | ||||
| # Install hunspell, zulip stop words, and run zulip database | ||||
| # init. | ||||
| FROM postgres:10 | ||||
| COPY puppet/zulip/files/postgresql/zulip_english.stop /usr/share/postgresql/$PG_MAJOR/tsearch_data/zulip_english.stop | ||||
| COPY scripts/setup/create-db.sql /docker-entrypoint-initdb.d/zulip-create-db.sql | ||||
| COPY scripts/setup/create-pgroonga.sql /docker-entrypoint-initdb.d/zulip-create-pgroonga.sql | ||||
| COPY scripts/setup/pgroonga-debian.asc /tmp | ||||
| RUN apt-key add /tmp/pgroonga-debian.asc \ | ||||
|     && echo "deb http://packages.groonga.org/debian/ stretch main" > /etc/apt/sources.list.d/zulip.list \ | ||||
|     && apt-get update \ | ||||
|     && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ | ||||
|        hunspell-en-us \ | ||||
|        postgresql-${PG_MAJOR}-pgroonga \ | ||||
|     && ln -sf /var/cache/postgresql/dicts/en_us.dict "/usr/share/postgresql/$PG_MAJOR/tsearch_data/en_us.dict" \ | ||||
|     && ln -sf /var/cache/postgresql/dicts/en_us.affix "/usr/share/postgresql/$PG_MAJOR/tsearch_data/en_us.affix" \ | ||||
|     && rm -rf /var/lib/apt/lists/* | ||||
							
								
								
									
										47
									
								
								LICENSE
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								LICENSE
									
									
									
									
									
								
							| @@ -1,4 +1,24 @@ | ||||
| Copyright 2011-2018 Dropbox, Inc., Kandra Labs, Inc., and contributors | ||||
| Copyright 2011-2017 Dropbox, Inc., Kandra Labs, Inc., and contributors | ||||
|  | ||||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| you may not use this file except in compliance with the License. | ||||
| You may obtain a copy of the License at | ||||
|  | ||||
|     http://www.apache.org/licenses/LICENSE-2.0 | ||||
|  | ||||
| Unless required by applicable law or agreed to in writing, software | ||||
| distributed under the License is distributed on an "AS IS" BASIS, | ||||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| See the License for the specific language governing permissions and | ||||
| limitations under the License. | ||||
|  | ||||
| The software includes some works released by third parties under other | ||||
| free and open source licenses. Those works are redistributed under the | ||||
| license terms under which the works were received. For more details, | ||||
| see the ``docs/THIRDPARTY`` file included with this distribution. | ||||
|  | ||||
|  | ||||
| -------------------------------------------------------------------------------- | ||||
|  | ||||
|                                  Apache License | ||||
|                            Version 2.0, January 2004 | ||||
| @@ -176,28 +196,3 @@ Copyright 2011-2018 Dropbox, Inc., Kandra Labs, Inc., and contributors | ||||
|       of your accepting any such warranty or additional liability. | ||||
|  | ||||
|    END OF TERMS AND CONDITIONS | ||||
|  | ||||
|    APPENDIX: How to apply the Apache License to your work. | ||||
|  | ||||
|       To apply the Apache License to your work, attach the following | ||||
|       boilerplate notice, with the fields enclosed by brackets "[]" | ||||
|       replaced with your own identifying information. (Don't include | ||||
|       the brackets!)  The text should be enclosed in the appropriate | ||||
|       comment syntax for the file format. We also recommend that a | ||||
|       file or class name and description of purpose be included on the | ||||
|       same "printed page" as the copyright notice for easier | ||||
|       identification within third-party archives. | ||||
|  | ||||
|    Copyright [yyyy] [name of copyright owner] | ||||
|  | ||||
|    Licensed under the Apache License, Version 2.0 (the "License"); | ||||
|    you may not use this file except in compliance with the License. | ||||
|    You may obtain a copy of the License at | ||||
|  | ||||
|        http://www.apache.org/licenses/LICENSE-2.0 | ||||
|  | ||||
|    Unless required by applicable law or agreed to in writing, software | ||||
|    distributed under the License is distributed on an "AS IS" BASIS, | ||||
|    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
|    See the License for the specific language governing permissions and | ||||
|    limitations under the License. | ||||
|   | ||||
							
								
								
									
										16
									
								
								NOTICE
									
									
									
									
									
								
							
							
						
						
									
										16
									
								
								NOTICE
									
									
									
									
									
								
							| @@ -1,16 +0,0 @@ | ||||
| Licensed under the Apache License, Version 2.0 (the "License"); | ||||
| you may not use this project except in compliance with the License. | ||||
| You may obtain a copy of the License at | ||||
|  | ||||
|     http://www.apache.org/licenses/LICENSE-2.0 | ||||
|  | ||||
| Unless required by applicable law or agreed to in writing, software | ||||
| distributed under the License is distributed on an "AS IS" BASIS, | ||||
| WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||||
| See the License for the specific language governing permissions and | ||||
| limitations under the License. | ||||
|  | ||||
| The software includes some works released by third parties under other | ||||
| free and open source licenses. Those works are redistributed under the | ||||
| license terms under which the works were received. For more details, | ||||
| see the ``docs/THIRDPARTY`` file included with this distribution. | ||||
							
								
								
									
										19
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										19
									
								
								README.md
									
									
									
									
									
								
							| @@ -5,13 +5,13 @@ 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 500 contributors merging over 500 commits a month, Zulip is also the | ||||
| over 300 contributors merging over 500 commits a month, Zulip is also the | ||||
| largest and fastest growing open source group chat project. | ||||
|  | ||||
| [](https://circleci.com/gh/zulip/zulip/tree/master) | ||||
| [](https://codecov.io/gh/zulip/zulip/branch/master) | ||||
| [](https://circleci.com/gh/zulip/zulip) | ||||
| [](https://travis-ci.org/zulip/zulip) | ||||
| [](https://codecov.io/gh/zulip/zulip) | ||||
| [][mypy-coverage] | ||||
| [](https://github.com/zulip/zulip/releases/latest) | ||||
| [](https://zulip.readthedocs.io/en/latest/) | ||||
| [](https://chat.zulip.org) | ||||
| [](https://twitter.com/zulip) | ||||
| @@ -55,11 +55,12 @@ You might be interested in: | ||||
|   [companies](https://zulipchat.com/for/companies/), or Zulip for | ||||
|   [working groups and part time communities](https://zulipchat.com/for/working-groups-and-communities/). | ||||
|  | ||||
| * **Running a Zulip server**. Use a preconfigured [Digital Ocean 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://zulipchat.com/plans> for details. | ||||
| * **Running a Zulip server**. Setting up a server takes just a couple of | ||||
|   minutes. Zulip runs on Ubuntu 16.04 Xenial and Ubuntu 14.04 Trusty. The | ||||
|   installation process is | ||||
|   [documented here](https://zulip.readthedocs.io/en/1.7.1/prod.html). | ||||
|   Commercial support is available; see <https://zulipchat.com/plans> for | ||||
|   details. | ||||
|  | ||||
| * **Using Zulip without setting up a server**. <https://zulipchat.com> offers | ||||
|   free and commercial hosting. | ||||
|   | ||||
							
								
								
									
										135
									
								
								Vagrantfile
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										135
									
								
								Vagrantfile
									
									
									
									
										vendored
									
									
								
							| @@ -19,6 +19,43 @@ if Vagrant::VERSION == "1.8.7" then | ||||
|     end | ||||
| end | ||||
|  | ||||
| # Workaround: the lxc-config in vagrant-lxc is incompatible with changes in | ||||
| # LXC 2.1.0, found in Ubuntu 17.10 artful.  LXC 2.1.1 (in 18.04 LTS bionic) | ||||
| # ignores the old config key, so this will only be needed for artful. | ||||
| # | ||||
| # vagrant-lxc upstream has an attempted fix: | ||||
| #   https://github.com/fgrehm/vagrant-lxc/issues/445 | ||||
| # but it didn't work in our testing.  This is a temporary issue, so we just | ||||
| # hack in a fix: we patch the skeleton `lxc-config` file right in the | ||||
| # distribution of the vagrant-lxc "box" we use.  If the user doesn't yet | ||||
| # have the box (e.g. on first setup), Vagrant would download it but too | ||||
| # late for us to patch it like this; so we prompt them to explicitly add it | ||||
| # first and then rerun. | ||||
| if ['up', 'provision'].include? ARGV[0] | ||||
|   if command? "lxc-ls" | ||||
|     LXC_VERSION = `lxc-ls --version`.strip unless defined? LXC_VERSION | ||||
|     if LXC_VERSION == "2.1.0" | ||||
|       lxc_config_file = ENV['HOME'] + "/.vagrant.d/boxes/fgrehm-VAGRANTSLASH-trusty64-lxc/1.2.0/lxc/lxc-config" | ||||
|       if File.file?(lxc_config_file) | ||||
|         lines = File.readlines(lxc_config_file) | ||||
|         deprecated_line = "lxc.pivotdir = lxc_putold\n" | ||||
|         if lines[1] == deprecated_line | ||||
|           lines[1] = "# #{deprecated_line}" | ||||
|           File.open(lxc_config_file, 'w') do |f| | ||||
|             f.puts(lines) | ||||
|           end | ||||
|         end | ||||
|       else | ||||
|         puts 'You are running LXC 2.1.0, and fgrehm/trusty64-lxc box is incompatible '\ | ||||
|             "with it by default. First add the box by doing:\n"\ | ||||
|             "  vagrant box add  https://vagrantcloud.com/fgrehm/trusty64-lxc\n"\ | ||||
|             'Once this command succeeds, do "vagrant up" again.' | ||||
|         exit | ||||
|       end | ||||
|     end | ||||
|   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 | ||||
| @@ -29,38 +66,24 @@ 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| | ||||
|  | ||||
|   # For LXC. VirtualBox hosts use a different box, described below. | ||||
|   config.vm.box = "fgrehm/trusty64-lxc" | ||||
|  | ||||
|   # The Zulip development environment runs on 9991 on the guest. | ||||
|   host_port = 9991 | ||||
|   http_proxy = https_proxy = no_proxy = nil | ||||
|   host_ip_addr = "127.0.0.1" | ||||
|  | ||||
|   # System settings for the virtual machine. | ||||
|   vm_num_cpus = "2" | ||||
|   vm_memory = "2048" | ||||
|  | ||||
|   ubuntu_mirror = "" | ||||
|  | ||||
|   config.vm.synced_folder ".", "/vagrant", disabled: true | ||||
|   config.vm.synced_folder ".", "/srv/zulip" | ||||
|   if (/darwin/ =~ RUBY_PLATFORM) != nil | ||||
|     config.vm.synced_folder ".", "/srv/zulip", type: "nfs", | ||||
|         linux__nfs_options: ['rw'] | ||||
|     config.vm.network "private_network", type: "dhcp" | ||||
|   else | ||||
|     config.vm.synced_folder ".", "/srv/zulip" | ||||
|   end | ||||
|  | ||||
|   vagrant_config_file = ENV['HOME'] + "/.zulip-vagrant-config" | ||||
|   if File.file?(vagrant_config_file) | ||||
| @@ -74,9 +97,6 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| | ||||
|       when "NO_PROXY"; no_proxy = value | ||||
|       when "HOST_PORT"; host_port = value.to_i | ||||
|       when "HOST_IP_ADDR"; host_ip_addr = value | ||||
|       when "GUEST_CPUS"; vm_num_cpus = value | ||||
|       when "GUEST_MEMORY_MB"; vm_memory = value | ||||
|       when "UBUNTU_MIRROR"; ubuntu_mirror = value | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| @@ -101,29 +121,32 @@ Vagrant.configure(VAGRANTFILE_API_VERSION) do |config| | ||||
|   end | ||||
|  | ||||
|   config.vm.network "forwarded_port", guest: 9991, host: host_port, host_ip: host_ip_addr | ||||
|   config.vm.network "forwarded_port", guest: 9994, host: host_port + 3, host_ip: host_ip_addr | ||||
|   # Specify Docker provider before VirtualBox provider so it's preferred. | ||||
|   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 !ubuntu_mirror.empty? | ||||
|       d.build_args += ["--build-arg", "UBUNTU_MIRROR=#{ubuntu_mirror}"] | ||||
|   # Specify LXC provider before VirtualBox provider so it's preferred. | ||||
|   config.vm.provider "lxc" do |lxc| | ||||
|     if command? "lxc-ls" | ||||
|       LXC_VERSION = `lxc-ls --version`.strip unless defined? LXC_VERSION | ||||
|       if LXC_VERSION >= "1.1.0" | ||||
|         # Allow start without AppArmor, otherwise Box will not Start on Ubuntu 14.10 | ||||
|         # see https://github.com/fgrehm/vagrant-lxc/issues/333 | ||||
|         lxc.customize 'aa_allow_incomplete', 1 | ||||
|       end | ||||
|       if LXC_VERSION >= "2.0.0" | ||||
|         lxc.backingstore = 'dir' | ||||
|       end | ||||
|     end | ||||
|     d.has_ssh = true | ||||
|     d.create_args = ["--ulimit", "nofile=1024:65536"] | ||||
|   end | ||||
|  | ||||
|   config.vm.provider "virtualbox" do |vb, override| | ||||
|     override.vm.box = "ubuntu/bionic64" | ||||
|     # An unnecessary log file gets generated when running vagrant up for the | ||||
|     # first time with the Ubuntu Bionic box. This looks like it is being | ||||
|     # caused upstream by the base box containing a Vagrantfile with a similar | ||||
|     # line to the one below. | ||||
|     # see https://github.com/hashicorp/vagrant/issues/9425 | ||||
|     vb.customize [ "modifyvm", :id, "--uartmode1", "disconnected" ] | ||||
|     override.vm.box = "ubuntu/trusty64" | ||||
|     # It's possible we can get away with just 1.5GB; more testing needed | ||||
|     vb.memory = vm_memory | ||||
|     vb.cpus = vm_num_cpus | ||||
|     vb.memory = 2048 | ||||
|     vb.cpus = 2 | ||||
|   end | ||||
|  | ||||
|   config.vm.provider "vmware_fusion" do |vb, override| | ||||
|     override.vm.box = "puphpet/ubuntu1404-x64" | ||||
|     vb.vmx["memsize"] = "2048" | ||||
|     vb.vmx["numvcpus"] = "2" | ||||
|   end | ||||
|  | ||||
| $provision_script = <<SCRIPT | ||||
| @@ -135,15 +158,19 @@ set -o pipefail | ||||
| # 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' | ||||
| sudo rm -f /etc/update-motd.d/* | ||||
| sudo bash -c 'cat << EndOfMessage > /etc/motd | ||||
| Welcome to the Zulip development environment!  Popular commands: | ||||
| * tools/provision - Update the development environment | ||||
| * tools/run-dev.py - Run the development server | ||||
| * tools/lint - Run the linter (quick and catches many problmes) | ||||
| * tools/test-* - Run tests (use --help to learn about options) | ||||
|  | ||||
| Read https://zulip.readthedocs.io/en/latest/testing/testing.html to learn | ||||
| how to run individual test suites so that you can get a fast debug cycle. | ||||
|  | ||||
| EndOfMessage' | ||||
|  | ||||
| # If the host is running SELinux remount the /sys/fs/selinux directory as read only, | ||||
| # needed for apt-get to work. | ||||
| @@ -171,7 +198,7 @@ if [ ! -w /srv/zulip ]; then | ||||
|     # 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 '    sudo chown -R 1000:$(whoami) /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" | ||||
|   | ||||
| @@ -2,14 +2,14 @@ import time | ||||
| from collections import OrderedDict, defaultdict | ||||
| from datetime import datetime, timedelta | ||||
| import logging | ||||
| from typing import Callable, Dict, List, \ | ||||
|     Optional, Tuple, Type, Union | ||||
| from typing import Any, Callable, Dict, List, \ | ||||
|     Optional, Text, Tuple, Type, Union | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.db import connection | ||||
| from django.db import connection, models | ||||
| from django.db.models import F | ||||
|  | ||||
| from analytics.models import BaseCount, \ | ||||
| from analytics.models import Anomaly, BaseCount, \ | ||||
|     FillState, InstallationCount, RealmCount, StreamCount, \ | ||||
|     UserCount, installation_epoch, last_successful_fill | ||||
| from zerver.lib.logging_util import log_to_file | ||||
| @@ -48,7 +48,7 @@ class CountStat: | ||||
|         else:  # frequency == CountStat.DAY | ||||
|             self.interval = timedelta(days=1) | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|     def __str__(self) -> Text: | ||||
|         return "<CountStat: %s>" % (self.property,) | ||||
|  | ||||
| class LoggingCountStat(CountStat): | ||||
| @@ -226,6 +226,7 @@ def do_drop_all_analytics_tables() -> None: | ||||
|     RealmCount.objects.all().delete() | ||||
|     InstallationCount.objects.all().delete() | ||||
|     FillState.objects.all().delete() | ||||
|     Anomaly.objects.all().delete() | ||||
|  | ||||
| def do_drop_single_stat(property: str) -> None: | ||||
|     UserCount.objects.filter(property=property).delete() | ||||
| @@ -295,8 +296,8 @@ count_message_by_user_query = """ | ||||
|         zerver_userprofile.id = zerver_message.sender_id | ||||
|     WHERE | ||||
|         zerver_userprofile.date_joined < %%(time_end)s AND | ||||
|         zerver_message.date_sent >= %%(time_start)s AND | ||||
|         zerver_message.date_sent < %%(time_end)s | ||||
|         zerver_message.pub_date >= %%(time_start)s AND | ||||
|         zerver_message.pub_date < %%(time_end)s | ||||
|     GROUP BY zerver_userprofile.id %(group_by_clause)s | ||||
| """ | ||||
|  | ||||
| @@ -322,8 +323,8 @@ count_message_type_by_user_query = """ | ||||
|         JOIN zerver_message | ||||
|         ON | ||||
|             zerver_userprofile.id = zerver_message.sender_id AND | ||||
|             zerver_message.date_sent >= %%(time_start)s AND | ||||
|             zerver_message.date_sent < %%(time_end)s | ||||
|             zerver_message.pub_date >= %%(time_start)s AND | ||||
|             zerver_message.pub_date < %%(time_end)s | ||||
|         JOIN zerver_recipient | ||||
|         ON | ||||
|             zerver_message.recipient_id = zerver_recipient.id | ||||
| @@ -359,8 +360,8 @@ count_message_by_stream_query = """ | ||||
|     WHERE | ||||
|         zerver_stream.date_created < %%(time_end)s AND | ||||
|         zerver_recipient.type = 2 AND | ||||
|         zerver_message.date_sent >= %%(time_start)s AND | ||||
|         zerver_message.date_sent < %%(time_end)s | ||||
|         zerver_message.pub_date >= %%(time_start)s AND | ||||
|         zerver_message.pub_date < %%(time_end)s | ||||
|     GROUP BY zerver_stream.id %(group_by_clause)s | ||||
| """ | ||||
|  | ||||
| @@ -385,7 +386,7 @@ count_user_by_realm_query = """ | ||||
|  | ||||
| # Currently hardcodes the query needed for active_users_audit:is_bot:day. | ||||
| # Assumes that a user cannot have two RealmAuditLog entries with the same event_time and | ||||
| # event_type in [RealmAuditLog.USER_CREATED, USER_DEACTIVATED, etc]. | ||||
| # event_type in ['user_created', 'user_deactivated', etc]. | ||||
| # In particular, it's important to ensure that migrations don't cause that to happen. | ||||
| check_realmauditlog_by_user_query = """ | ||||
|     INSERT INTO analytics_usercount | ||||
| @@ -397,7 +398,7 @@ check_realmauditlog_by_user_query = """ | ||||
|         SELECT modified_user_id, max(event_time) AS max_event_time | ||||
|         FROM zerver_realmauditlog | ||||
|         WHERE | ||||
|             event_type in ({user_created}, {user_activated}, {user_deactivated}, {user_reactivated}) AND | ||||
|             event_type in ('user_created', 'user_deactivated', 'user_activated', 'user_reactivated') AND | ||||
|             event_time < %%(time_end)s | ||||
|         GROUP BY modified_user_id | ||||
|     ) ral2 | ||||
| @@ -408,11 +409,8 @@ check_realmauditlog_by_user_query = """ | ||||
|     ON | ||||
|         ral1.modified_user_id = zerver_userprofile.id | ||||
|     WHERE | ||||
|         ral1.event_type in ({user_created}, {user_activated}, {user_reactivated}) | ||||
| """.format(user_created=RealmAuditLog.USER_CREATED, | ||||
|            user_activated=RealmAuditLog.USER_ACTIVATED, | ||||
|            user_deactivated=RealmAuditLog.USER_DEACTIVATED, | ||||
|            user_reactivated=RealmAuditLog.USER_REACTIVATED) | ||||
|         ral1.event_type in ('user_created', 'user_activated', 'user_reactivated') | ||||
| """ | ||||
|  | ||||
| check_useractivityinterval_by_user_query = """ | ||||
|     INSERT INTO analytics_usercount | ||||
| @@ -515,9 +513,6 @@ count_stats_ = [ | ||||
|     # User Activity stats | ||||
|     # Stats that measure user activity in the UserActivityInterval sense. | ||||
|  | ||||
|     CountStat('1day_actives::day', | ||||
|               sql_data_collector(UserCount, check_useractivityinterval_by_user_query, None), | ||||
|               CountStat.DAY, interval=timedelta(days=1)-UserActivityInterval.MIN_INTERVAL_LENGTH), | ||||
|     CountStat('15day_actives::day', | ||||
|               sql_data_collector(UserCount, check_useractivityinterval_by_user_query, None), | ||||
|               CountStat.DAY, interval=timedelta(days=15)-UserActivityInterval.MIN_INTERVAL_LENGTH), | ||||
|   | ||||
| @@ -15,7 +15,7 @@ def compute_stats(log_level: int) -> None: | ||||
|     one_week_ago = timestamp_to_datetime(time.time()) - datetime.timedelta(weeks=1) | ||||
|     mit_query = Message.objects.filter(sender__realm__string_id="zephyr", | ||||
|                                        recipient__type=Recipient.STREAM, | ||||
|                                        date_sent__gt=one_week_ago) | ||||
|                                        pub_date__gt=one_week_ago) | ||||
|     for bot_sender_start in ["imap.", "rcmd.", "sys."]: | ||||
|         mit_query = mit_query.exclude(sender__email__startswith=(bot_sender_start)) | ||||
|     # Filtering for "/" covers tabbott/extra@ and all the daemon/foo bots. | ||||
|   | ||||
| @@ -1,9 +1,10 @@ | ||||
| from argparse import ArgumentParser | ||||
| from datetime import timedelta | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from analytics.models import installation_epoch, \ | ||||
| from analytics.models import InstallationCount, installation_epoch, \ | ||||
|     last_successful_fill | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat | ||||
| from zerver.lib.timestamp import floor_to_hour, floor_to_day, verify_UTC, \ | ||||
| @@ -11,6 +12,8 @@ from zerver.lib.timestamp import floor_to_hour, floor_to_day, verify_UTC, \ | ||||
| from zerver.models import Realm | ||||
|  | ||||
| import os | ||||
| import subprocess | ||||
| import sys | ||||
| import time | ||||
| from typing import Any, Dict | ||||
|  | ||||
| @@ -37,7 +40,7 @@ class Command(BaseCommand): | ||||
|         with open(state_file_tmp, "w") as f: | ||||
|             f.write("%s|%s|%s|%s\n" % ( | ||||
|                 int(time.time()), status, states[status], message)) | ||||
|         os.rename(state_file_tmp, state_file_path) | ||||
|         subprocess.check_call(["mv", state_file_tmp, state_file_path]) | ||||
|  | ||||
|     def get_fill_state(self) -> Dict[str, Any]: | ||||
|         if not Realm.objects.exists(): | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import sys | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from analytics.lib.counts import do_drop_all_analytics_tables | ||||
|  | ||||
| @@ -17,4 +18,5 @@ class Command(BaseCommand): | ||||
|         if options['force']: | ||||
|             do_drop_all_analytics_tables() | ||||
|         else: | ||||
|             raise CommandError("Would delete all data from analytics tables (!); use --force to do so.") | ||||
|             print("Would delete all data from analytics tables (!); use --force to do so.") | ||||
|             sys.exit(1) | ||||
|   | ||||
| @@ -1,7 +1,8 @@ | ||||
| import sys | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| from django.core.management.base import BaseCommand | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, do_drop_single_stat | ||||
|  | ||||
| @@ -19,8 +20,10 @@ class Command(BaseCommand): | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|         property = options['property'] | ||||
|         if property not in COUNT_STATS: | ||||
|             raise CommandError("Invalid property: %s" % (property,)) | ||||
|             print("Invalid property: %s" % (property,)) | ||||
|             sys.exit(1) | ||||
|         if not options['force']: | ||||
|             raise CommandError("No action taken. Use --force.") | ||||
|             print("No action taken. Use --force.") | ||||
|             sys.exit(1) | ||||
|  | ||||
|         do_drop_single_stat(property) | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import datetime | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any, Optional | ||||
| from typing import Any | ||||
|  | ||||
| from django.db.models import Count, QuerySet | ||||
| from django.utils.timezone import now as timezone_now | ||||
| @@ -56,7 +56,7 @@ Usage examples: | ||||
|             print("%25s %15d" % (count[1], count[0])) | ||||
|         print("Total:", total) | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Optional[str]) -> None: | ||||
|     def handle(self, *args: Any, **options: str) -> None: | ||||
|         realm = self.get_realm(options) | ||||
|         if options["user"] is None: | ||||
|             if options["target"] == "server" and realm is None: | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| from datetime import timedelta | ||||
| from typing import Any, Dict, List, Mapping, Optional, Type | ||||
| import mock | ||||
|  | ||||
| from datetime import datetime, timedelta | ||||
| from typing import Any, Dict, List, Mapping, Optional, Text, Type, Union | ||||
|  | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils.timezone import now as timezone_now | ||||
| @@ -9,13 +9,10 @@ from analytics.lib.counts import COUNT_STATS, \ | ||||
|     CountStat, do_drop_all_analytics_tables | ||||
| from analytics.lib.fixtures import generate_time_series_data | ||||
| from analytics.lib.time_utils import time_range | ||||
| from analytics.models import BaseCount, FillState, RealmCount, UserCount, \ | ||||
|     StreamCount, InstallationCount | ||||
| from zerver.lib.actions import do_change_is_admin, STREAM_ASSIGNMENT_COLORS | ||||
| from zerver.lib.create_user import create_user | ||||
| from analytics.models import BaseCount, FillState, RealmCount, UserCount, StreamCount | ||||
| from zerver.lib.timestamp import floor_to_day | ||||
| from zerver.models import Realm, Stream, Client, \ | ||||
|     Recipient, Subscription | ||||
| from zerver.models import Realm, UserProfile, Stream, Message, Client, \ | ||||
|     RealmAuditLog, Recipient | ||||
|  | ||||
| class Command(BaseCommand): | ||||
|     help = """Populates analytics tables with randomly generated data.""" | ||||
| @@ -23,6 +20,20 @@ class Command(BaseCommand): | ||||
|     DAYS_OF_DATA = 100 | ||||
|     random_seed = 26 | ||||
|  | ||||
|     def create_user(self, email: Text, | ||||
|                     full_name: Text, | ||||
|                     is_staff: bool, | ||||
|                     date_joined: datetime, | ||||
|                     realm: Realm) -> UserProfile: | ||||
|         user = UserProfile.objects.create( | ||||
|             email=email, full_name=full_name, is_staff=is_staff, | ||||
|             realm=realm, short_name=full_name, pointer=-1, last_pointer_updater='none', | ||||
|             api_key='42', date_joined=date_joined) | ||||
|         RealmAuditLog.objects.create( | ||||
|             realm=realm, modified_user=user, event_type='user_created', | ||||
|             event_time=user.date_joined) | ||||
|         return user | ||||
|  | ||||
|     def generate_fixture_data(self, stat: CountStat, business_hours_base: float, | ||||
|                               non_business_hours_base: float, growth: float, | ||||
|                               autocorrelation: float, spikiness: float, | ||||
| @@ -35,51 +46,24 @@ class Command(BaseCommand): | ||||
|             frequency=stat.frequency, partial_sum=partial_sum, random_seed=self.random_seed) | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|         # TODO: This should arguably only delete the objects | ||||
|         # associated with the "analytics" realm. | ||||
|         do_drop_all_analytics_tables() | ||||
|  | ||||
|         # This also deletes any objects with this realm as a foreign key | ||||
|         # I believe this also deletes any objects with this realm as a foreign key | ||||
|         Realm.objects.filter(string_id='analytics').delete() | ||||
|  | ||||
|         # Because we just deleted a bunch of objects in the database | ||||
|         # directly (rather than deleting individual objects in Django, | ||||
|         # in which case our post_save hooks would have flushed the | ||||
|         # individual objects from memcached for us), we need to flush | ||||
|         # memcached in order to ensure deleted objects aren't still | ||||
|         # present in the memcached cache. | ||||
|         from zerver.apps import flush_cache | ||||
|         flush_cache(None) | ||||
|  | ||||
|         installation_time = timezone_now() - timedelta(days=self.DAYS_OF_DATA) | ||||
|         last_end_time = floor_to_day(timezone_now()) | ||||
|         realm = Realm.objects.create( | ||||
|             string_id='analytics', name='Analytics', date_created=installation_time) | ||||
|         with mock.patch("zerver.lib.create_user.timezone_now", return_value=installation_time): | ||||
|             shylock = create_user('shylock@analytics.ds', 'Shylock', realm, | ||||
|                                   full_name='Shylock', short_name='shylock', | ||||
|                                   is_realm_admin=True) | ||||
|         do_change_is_admin(shylock, True) | ||||
|         shylock = self.create_user('shylock@analytics.ds', 'Shylock', True, installation_time, realm) | ||||
|         stream = Stream.objects.create( | ||||
|             name='all', realm=realm, date_created=installation_time) | ||||
|         recipient = Recipient.objects.create(type_id=stream.id, type=Recipient.STREAM) | ||||
|  | ||||
|         # Subscribe shylock to the stream to avoid invariant failures. | ||||
|         # TODO: This should use subscribe_users_to_streams from populate_db. | ||||
|         subs = [ | ||||
|             Subscription(recipient=recipient, | ||||
|                          user_profile=shylock, | ||||
|                          color=STREAM_ASSIGNMENT_COLORS[0]), | ||||
|         ] | ||||
|         Subscription.objects.bulk_create(subs) | ||||
|         Recipient.objects.create(type_id=stream.id, type=Recipient.STREAM) | ||||
|  | ||||
|         def insert_fixture_data(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])) | ||||
|             if table == InstallationCount: | ||||
|                 id_args = {}  # type: Dict[str, Any] | ||||
|             if table == RealmCount: | ||||
|                 id_args = {'realm': realm} | ||||
|             if table == UserCount: | ||||
| @@ -93,39 +77,11 @@ class Command(BaseCommand): | ||||
|                           value=value, **id_args) | ||||
|                     for end_time, value in zip(end_times, values) if value != 0]) | ||||
|  | ||||
|         stat = COUNT_STATS['1day_actives::day'] | ||||
|         realm_data = { | ||||
|             None: self.generate_fixture_data(stat, .08, .02, 3, .3, 6, partial_sum=True), | ||||
|         }  # type: Mapping[Optional[str], List[int]] | ||||
|         insert_fixture_data(stat, realm_data, RealmCount) | ||||
|         installation_data = { | ||||
|             None: self.generate_fixture_data(stat, .8, .2, 4, .3, 6, partial_sum=True), | ||||
|         }  # type: Mapping[Optional[str], List[int]] | ||||
|         insert_fixture_data(stat, installation_data, InstallationCount) | ||||
|         FillState.objects.create(property=stat.property, end_time=last_end_time, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         realm_data = { | ||||
|             None: self.generate_fixture_data(stat, .1, .03, 3, .5, 3, partial_sum=True), | ||||
|         } | ||||
|         }  # type: Mapping[Optional[str], List[int]] | ||||
|         insert_fixture_data(stat, realm_data, RealmCount) | ||||
|         installation_data = { | ||||
|             None: self.generate_fixture_data(stat, 1, .3, 4, .5, 3, partial_sum=True), | ||||
|         } | ||||
|         insert_fixture_data(stat, installation_data, InstallationCount) | ||||
|         FillState.objects.create(property=stat.property, end_time=last_end_time, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
|         stat = COUNT_STATS['active_users_audit:is_bot:day'] | ||||
|         realm_data = { | ||||
|             'false': self.generate_fixture_data(stat, .1, .03, 3.5, .8, 2, partial_sum=True), | ||||
|         } | ||||
|         insert_fixture_data(stat, realm_data, RealmCount) | ||||
|         installation_data = { | ||||
|             'false': self.generate_fixture_data(stat, 1, .3, 6, .8, 2, partial_sum=True), | ||||
|         } | ||||
|         insert_fixture_data(stat, installation_data, InstallationCount) | ||||
|         FillState.objects.create(property=stat.property, end_time=last_end_time, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
| @@ -136,9 +92,6 @@ class Command(BaseCommand): | ||||
|         realm_data = {'false': self.generate_fixture_data(stat, 35, 15, 6, .6, 4), | ||||
|                       'true': self.generate_fixture_data(stat, 15, 15, 3, .4, 2)} | ||||
|         insert_fixture_data(stat, realm_data, RealmCount) | ||||
|         installation_data = {'false': self.generate_fixture_data(stat, 350, 150, 6, .6, 4), | ||||
|                              'true': self.generate_fixture_data(stat, 150, 150, 3, .4, 2)} | ||||
|         insert_fixture_data(stat, installation_data, InstallationCount) | ||||
|         FillState.objects.create(property=stat.property, end_time=last_end_time, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
| @@ -154,12 +107,6 @@ class Command(BaseCommand): | ||||
|             'private_message': self.generate_fixture_data(stat, 13, 5, 5, .6, 4), | ||||
|             'huddle_message': self.generate_fixture_data(stat, 6, 3, 3, .6, 4)} | ||||
|         insert_fixture_data(stat, realm_data, RealmCount) | ||||
|         installation_data = { | ||||
|             'public_stream': self.generate_fixture_data(stat, 300, 80, 5, .6, 4), | ||||
|             'private_stream': self.generate_fixture_data(stat, 70, 70, 5, .6, 4), | ||||
|             'private_message': self.generate_fixture_data(stat, 130, 50, 5, .6, 4), | ||||
|             'huddle_message': self.generate_fixture_data(stat, 60, 30, 3, .6, 4)} | ||||
|         insert_fixture_data(stat, installation_data, InstallationCount) | ||||
|         FillState.objects.create(property=stat.property, end_time=last_end_time, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
| @@ -189,17 +136,6 @@ class Command(BaseCommand): | ||||
|             unused.id: self.generate_fixture_data(stat, 0, 0, 0, 0, 0), | ||||
|             long_webhook.id: self.generate_fixture_data(stat, 5, 5, 2, .6, 3)} | ||||
|         insert_fixture_data(stat, realm_data, RealmCount) | ||||
|         installation_data = { | ||||
|             website.id: self.generate_fixture_data(stat, 300, 200, 5, .6, 3), | ||||
|             old_desktop.id: self.generate_fixture_data(stat, 50, 30, 8, .6, 3), | ||||
|             android.id: self.generate_fixture_data(stat, 50, 50, 2, .6, 3), | ||||
|             iOS.id: self.generate_fixture_data(stat, 50, 50, 2, .6, 3), | ||||
|             react_native.id: self.generate_fixture_data(stat, 5, 5, 10, .6, 3), | ||||
|             API.id: self.generate_fixture_data(stat, 50, 50, 5, .6, 3), | ||||
|             zephyr_mirror.id: self.generate_fixture_data(stat, 10, 10, 3, .6, 3), | ||||
|             unused.id: self.generate_fixture_data(stat, 0, 0, 0, 0, 0), | ||||
|             long_webhook.id: self.generate_fixture_data(stat, 50, 50, 2, .6, 3)} | ||||
|         insert_fixture_data(stat, installation_data, InstallationCount) | ||||
|         FillState.objects.create(property=stat.property, end_time=last_end_time, | ||||
|                                  state=FillState.DONE) | ||||
|  | ||||
|   | ||||
| @@ -2,7 +2,8 @@ import datetime | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any, List | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| import pytz | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.db.models import Count | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| @@ -33,32 +34,32 @@ class Command(BaseCommand): | ||||
|  | ||||
|     def messages_sent_by(self, user: UserProfile, days_ago: int) -> int: | ||||
|         sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) | ||||
|         return human_messages.filter(sender=user, date_sent__gt=sent_time_cutoff).count() | ||||
|         return human_messages.filter(sender=user, pub_date__gt=sent_time_cutoff).count() | ||||
|  | ||||
|     def total_messages(self, realm: Realm, days_ago: int) -> int: | ||||
|         sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) | ||||
|         return Message.objects.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff).count() | ||||
|         return Message.objects.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).count() | ||||
|  | ||||
|     def human_messages(self, realm: Realm, days_ago: int) -> int: | ||||
|         sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) | ||||
|         return human_messages.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff).count() | ||||
|         return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).count() | ||||
|  | ||||
|     def api_messages(self, realm: Realm, days_ago: int) -> int: | ||||
|         return (self.total_messages(realm, days_ago) - self.human_messages(realm, days_ago)) | ||||
|  | ||||
|     def stream_messages(self, realm: Realm, days_ago: int) -> int: | ||||
|         sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) | ||||
|         return human_messages.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff, | ||||
|         return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff, | ||||
|                                      recipient__type=Recipient.STREAM).count() | ||||
|  | ||||
|     def private_messages(self, realm: Realm, days_ago: int) -> int: | ||||
|         sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) | ||||
|         return human_messages.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff).exclude( | ||||
|         return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).exclude( | ||||
|             recipient__type=Recipient.STREAM).exclude(recipient__type=Recipient.HUDDLE).count() | ||||
|  | ||||
|     def group_private_messages(self, realm: Realm, days_ago: int) -> int: | ||||
|         sent_time_cutoff = timezone_now() - datetime.timedelta(days=days_ago) | ||||
|         return human_messages.filter(sender__realm=realm, date_sent__gt=sent_time_cutoff).exclude( | ||||
|         return human_messages.filter(sender__realm=realm, pub_date__gt=sent_time_cutoff).exclude( | ||||
|             recipient__type=Recipient.STREAM).exclude(recipient__type=Recipient.PERSONAL).count() | ||||
|  | ||||
|     def report_percentage(self, numerator: float, denominator: float, text: str) -> None: | ||||
| @@ -73,7 +74,8 @@ class Command(BaseCommand): | ||||
|             try: | ||||
|                 realms = [get_realm(string_id) for string_id in options['realms']] | ||||
|             except Realm.DoesNotExist as e: | ||||
|                 raise CommandError(e) | ||||
|                 print(e) | ||||
|                 exit(1) | ||||
|         else: | ||||
|             realms = Realm.objects.all() | ||||
|  | ||||
| @@ -130,7 +132,7 @@ class Command(BaseCommand): | ||||
|                 user_profile__in=user_profiles, active=True) | ||||
|  | ||||
|             # Streams not in home view | ||||
|             non_home_view = active_user_subs.filter(is_muted=True).values( | ||||
|             non_home_view = active_user_subs.filter(in_home_view=False).values( | ||||
|                 "user_profile").annotate(count=Count("user_profile")) | ||||
|             print("%d users have %d streams not in home view" % ( | ||||
|                 len(non_home_view), sum([elt["count"] for elt in non_home_view]))) | ||||
| @@ -143,7 +145,7 @@ class Command(BaseCommand): | ||||
|                 len(markup_messages), sum([elt["count"] for elt in markup_messages]))) | ||||
|  | ||||
|             # Notifications for stream messages | ||||
|             notifications = active_user_subs.filter(desktop_notifications=True).values( | ||||
|             notifications = active_user_subs.filter(notifications=True).values( | ||||
|                 "user_profile").annotate(count=Count("user_profile")) | ||||
|             print("%d users receive desktop notifications for %d streams" % ( | ||||
|                 len(notifications), sum([elt["count"] for elt in notifications]))) | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.db.models import Q | ||||
|  | ||||
| from zerver.models import Message, Realm, \ | ||||
| @@ -19,38 +19,26 @@ class Command(BaseCommand): | ||||
|             try: | ||||
|                 realms = [get_realm(string_id) for string_id in options['realms']] | ||||
|             except Realm.DoesNotExist as e: | ||||
|                 raise CommandError(e) | ||||
|                 print(e) | ||||
|                 exit(1) | ||||
|         else: | ||||
|             realms = Realm.objects.all() | ||||
|  | ||||
|         for realm in realms: | ||||
|             print(realm.string_id) | ||||
|             print("------------") | ||||
|             print("%25s %15s %10s" % ("stream", "subscribers", "messages")) | ||||
|             streams = Stream.objects.filter(realm=realm).exclude(Q(name__istartswith="tutorial-")) | ||||
|             # private stream count | ||||
|             private_count = 0 | ||||
|             # public stream count | ||||
|             public_count = 0 | ||||
|             invite_only_count = 0 | ||||
|             for stream in streams: | ||||
|                 if stream.invite_only: | ||||
|                     private_count += 1 | ||||
|                 else: | ||||
|                     public_count += 1 | ||||
|             print("------------") | ||||
|             print(realm.string_id, end=' ') | ||||
|             print("%10s %d public streams and" % ("(", public_count), end=' ') | ||||
|             print("%d private streams )" % (private_count,)) | ||||
|             print("------------") | ||||
|             print("%25s %15s %10s %12s" % ("stream", "subscribers", "messages", "type")) | ||||
|  | ||||
|             for stream in streams: | ||||
|                 if stream.invite_only: | ||||
|                     stream_type = 'private' | ||||
|                 else: | ||||
|                     stream_type = 'public' | ||||
|                     invite_only_count += 1 | ||||
|                     continue | ||||
|                 print("%25s" % (stream.name,), end=' ') | ||||
|                 recipient = Recipient.objects.filter(type=Recipient.STREAM, type_id=stream.id) | ||||
|                 print("%10d" % (len(Subscription.objects.filter(recipient=recipient, | ||||
|                                                                 active=True)),), end=' ') | ||||
|                 num_messages = len(Message.objects.filter(recipient=recipient)) | ||||
|                 print("%12d" % (num_messages,), end=' ') | ||||
|                 print("%15s" % (stream_type,)) | ||||
|                 print("%12d" % (num_messages,)) | ||||
|             print("%d invite-only streams" % (invite_only_count,)) | ||||
|             print("") | ||||
|   | ||||
| @@ -11,7 +11,6 @@ from django.utils.timezone import utc as timezone_utc | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, logger, process_count_stat | ||||
| from scripts.lib.zulip_tools import ENDC, WARNING | ||||
| from zerver.lib.remote_server import send_analytics_to_remote_server | ||||
| from zerver.lib.timestamp import floor_to_hour | ||||
| from zerver.models import Realm | ||||
|  | ||||
| @@ -85,6 +84,3 @@ class Command(BaseCommand): | ||||
|             print("Finished updating analytics counts through %s in %.3fs" % | ||||
|                   (fill_to_time, time.time() - start)) | ||||
|         logger.info("Finished updating analytics counts through %s" % (fill_to_time,)) | ||||
|  | ||||
|         if settings.PUSH_NOTIFICATION_BOUNCER_URL and settings.SUBMIT_USAGE_STATISTICS: | ||||
|             send_analytics_to_remote_server() | ||||
|   | ||||
| @@ -2,7 +2,7 @@ import datetime | ||||
| from argparse import ArgumentParser | ||||
| from typing import Any | ||||
|  | ||||
| from django.core.management.base import BaseCommand, CommandError | ||||
| from django.core.management.base import BaseCommand | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from zerver.models import Message, Realm, Stream, UserProfile, get_realm | ||||
| @@ -17,14 +17,15 @@ class Command(BaseCommand): | ||||
|     def messages_sent_by(self, user: UserProfile, week: int) -> int: | ||||
|         start = timezone_now() - datetime.timedelta(days=(week + 1)*7) | ||||
|         end = timezone_now() - datetime.timedelta(days=week*7) | ||||
|         return Message.objects.filter(sender=user, date_sent__gt=start, date_sent__lte=end).count() | ||||
|         return Message.objects.filter(sender=user, pub_date__gt=start, pub_date__lte=end).count() | ||||
|  | ||||
|     def handle(self, *args: Any, **options: Any) -> None: | ||||
|         if options['realms']: | ||||
|             try: | ||||
|                 realms = [get_realm(string_id) for string_id in options['realms']] | ||||
|             except Realm.DoesNotExist as e: | ||||
|                 raise CommandError(e) | ||||
|                 print(e) | ||||
|                 exit(1) | ||||
|         else: | ||||
|             realms = Realm.objects.all() | ||||
|  | ||||
| @@ -37,5 +38,5 @@ class Command(BaseCommand): | ||||
|             for user_profile in user_profiles: | ||||
|                 print("%35s" % (user_profile.email,), end=' ') | ||||
|                 for week in range(10): | ||||
|                     print("%5d" % (self.messages_sent_by(user_profile, week),), end=' ') | ||||
|                     print("%5d" % (self.messages_sent_by(user_profile, week)), end=' ') | ||||
|                 print("") | ||||
|   | ||||
| @@ -3,6 +3,8 @@ import django.db.models.deletion | ||||
| from django.conf import settings | ||||
| from django.db import migrations, models | ||||
|  | ||||
| import zerver.lib.str_utils | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -17,7 +19,7 @@ class Migration(migrations.Migration): | ||||
|                 ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), | ||||
|                 ('info', models.CharField(max_length=1000)), | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='HuddleCount', | ||||
| @@ -31,7 +33,7 @@ class Migration(migrations.Migration): | ||||
|                 ('value', models.BigIntegerField()), | ||||
|                 ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='InstallationCount', | ||||
| @@ -43,7 +45,7 @@ class Migration(migrations.Migration): | ||||
|                 ('value', models.BigIntegerField()), | ||||
|                 ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='RealmCount', | ||||
| @@ -57,7 +59,7 @@ class Migration(migrations.Migration): | ||||
|                 ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), | ||||
|  | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='StreamCount', | ||||
| @@ -71,7 +73,7 @@ class Migration(migrations.Migration): | ||||
|                 ('value', models.BigIntegerField()), | ||||
|                 ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='UserCount', | ||||
| @@ -85,7 +87,7 @@ class Migration(migrations.Migration): | ||||
|                 ('value', models.BigIntegerField()), | ||||
|                 ('anomaly', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.Anomaly', null=True)), | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|         migrations.AlterUniqueTogether( | ||||
|             name='usercount', | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations | ||||
| from django.db import migrations, models | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|   | ||||
| @@ -1,6 +1,8 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations, models | ||||
|  | ||||
| import zerver.lib.str_utils | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
| @@ -17,6 +19,6 @@ class Migration(migrations.Migration): | ||||
|                 ('state', models.PositiveSmallIntegerField()), | ||||
|                 ('last_modified', models.DateTimeField(auto_now=True)), | ||||
|             ], | ||||
|             bases=(models.Model,), | ||||
|             bases=(zerver.lib.str_utils.ModelReprMixin, models.Model), | ||||
|         ), | ||||
|     ] | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| from django.db import migrations | ||||
| from django.db import migrations, models | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.10.4 on 2017-01-16 20:50 | ||||
| from django.conf import settings | ||||
| from django.db import migrations | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|   | ||||
| @@ -1,34 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.18 on 2019-02-02 02:47 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('analytics', '0012_add_on_delete'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='installationcount', | ||||
|             name='anomaly', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='realmcount', | ||||
|             name='anomaly', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='streamcount', | ||||
|             name='anomaly', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='usercount', | ||||
|             name='anomaly', | ||||
|         ), | ||||
|         migrations.DeleteModel( | ||||
|             name='Anomaly', | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,13 +1,13 @@ | ||||
| import datetime | ||||
| from typing import Optional | ||||
| from typing import Any, Dict, Optional, Text, Tuple, Union | ||||
|  | ||||
| from django.db import models | ||||
|  | ||||
| from zerver.lib.timestamp import floor_to_day | ||||
| from zerver.models import Realm, Stream, UserProfile | ||||
| from zerver.models import Realm, Recipient, Stream, UserProfile | ||||
|  | ||||
| class FillState(models.Model): | ||||
|     property = models.CharField(max_length=40, unique=True)  # type: str | ||||
|     property = models.CharField(max_length=40, unique=True)  # type: Text | ||||
|     end_time = models.DateTimeField()  # type: datetime.datetime | ||||
|  | ||||
|     # Valid states are {DONE, STARTED} | ||||
| @@ -17,7 +17,7 @@ class FillState(models.Model): | ||||
|  | ||||
|     last_modified = models.DateTimeField(auto_now=True)  # type: datetime.datetime | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|     def __str__(self) -> Text: | ||||
|         return "<FillState: %s %s %s>" % (self.property, self.end_time, self.state) | ||||
|  | ||||
| # The earliest/starting end_time in FillState | ||||
| @@ -34,14 +34,22 @@ def last_successful_fill(property: str) -> Optional[datetime.datetime]: | ||||
|         return fillstate.end_time | ||||
|     return fillstate.end_time - datetime.timedelta(hours=1) | ||||
|  | ||||
| # would only ever make entries here by hand | ||||
| class Anomaly(models.Model): | ||||
|     info = models.CharField(max_length=1000)  # type: Text | ||||
|  | ||||
|     def __str__(self) -> Text: | ||||
|         return "<Anomaly: %s... %s>" % (self.info, self.id) | ||||
|  | ||||
| class BaseCount(models.Model): | ||||
|     # Note: When inheriting from BaseCount, you may want to rearrange | ||||
|     # the order of the columns in the migration to make sure they | ||||
|     # match how you'd like the table to be arranged. | ||||
|     property = models.CharField(max_length=32)  # type: str | ||||
|     subgroup = models.CharField(max_length=16, null=True)  # type: Optional[str] | ||||
|     property = models.CharField(max_length=32)  # type: Text | ||||
|     subgroup = models.CharField(max_length=16, null=True)  # type: Optional[Text] | ||||
|     end_time = models.DateTimeField()  # type: datetime.datetime | ||||
|     value = models.BigIntegerField()  # type: int | ||||
|     anomaly = models.ForeignKey(Anomaly, on_delete=models.SET_NULL, null=True)  # type: Optional[Anomaly] | ||||
|  | ||||
|     class Meta: | ||||
|         abstract = True | ||||
| @@ -51,7 +59,7 @@ class InstallationCount(BaseCount): | ||||
|     class Meta: | ||||
|         unique_together = ("property", "subgroup", "end_time") | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|     def __str__(self) -> Text: | ||||
|         return "<InstallationCount: %s %s %s>" % (self.property, self.subgroup, self.value) | ||||
|  | ||||
| class RealmCount(BaseCount): | ||||
| @@ -61,7 +69,7 @@ class RealmCount(BaseCount): | ||||
|         unique_together = ("realm", "property", "subgroup", "end_time") | ||||
|         index_together = ["property", "end_time"] | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|     def __str__(self) -> Text: | ||||
|         return "<RealmCount: %s %s %s %s>" % (self.realm, self.property, self.subgroup, self.value) | ||||
|  | ||||
| class UserCount(BaseCount): | ||||
| @@ -74,7 +82,7 @@ class UserCount(BaseCount): | ||||
|         # aggregating from users to realms | ||||
|         index_together = ["property", "realm", "end_time"] | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|     def __str__(self) -> Text: | ||||
|         return "<UserCount: %s %s %s %s>" % (self.user, self.property, self.subgroup, self.value) | ||||
|  | ||||
| class StreamCount(BaseCount): | ||||
| @@ -87,6 +95,6 @@ class StreamCount(BaseCount): | ||||
|         # aggregating from streams to realms | ||||
|         index_together = ["property", "realm", "end_time"] | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|     def __str__(self) -> Text: | ||||
|         return "<StreamCount: %s %s %s %s %s>" % ( | ||||
|             self.stream, self.property, self.subgroup, self.value, self.id) | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| from datetime import datetime, timedelta | ||||
| from typing import Any, Dict, List, Optional, Tuple, Type | ||||
|  | ||||
| import mock | ||||
| from datetime import datetime, timedelta | ||||
| from typing import Any, Dict, List, Optional, Text, Tuple, Type, Union | ||||
|  | ||||
| import ujson | ||||
| from django.apps import apps | ||||
| from django.db import models | ||||
| @@ -10,21 +10,19 @@ from django.test import TestCase | ||||
| from django.utils.timezone import now as timezone_now | ||||
| from django.utils.timezone import utc as timezone_utc | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat, \ | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat, DataCollector, \ | ||||
|     DependentCountStat, LoggingCountStat, do_aggregate_to_summary_table, \ | ||||
|     do_drop_all_analytics_tables, do_drop_single_stat, \ | ||||
|     do_fill_count_stat_at_hour, do_increment_logging_stat, \ | ||||
|     process_count_stat, sql_data_collector | ||||
| from analytics.models import BaseCount, \ | ||||
| from analytics.models import Anomaly, BaseCount, \ | ||||
|     FillState, InstallationCount, RealmCount, StreamCount, \ | ||||
|     UserCount, installation_epoch | ||||
|     UserCount, installation_epoch, last_successful_fill | ||||
| from zerver.lib.actions import do_activate_user, do_create_user, \ | ||||
|     do_deactivate_user, do_reactivate_user, update_user_activity_interval, \ | ||||
|     do_invite_users, do_revoke_user_invite, do_resend_user_invite_email, \ | ||||
|     InvitationError | ||||
| from zerver.lib.create_user import create_user | ||||
| from zerver.lib.timestamp import TimezoneNotUTCException, floor_to_day | ||||
| from zerver.lib.topic import DB_TOPIC_NAME | ||||
| from zerver.models import Client, Huddle, Message, Realm, \ | ||||
|     RealmAuditLog, Recipient, Stream, UserActivityInterval, \ | ||||
|     UserProfile, get_client, get_user, PreregistrationUser | ||||
| @@ -52,21 +50,13 @@ class AnalyticsTestCase(TestCase): | ||||
|             'date_joined': self.TIME_LAST_HOUR, | ||||
|             'full_name': 'full_name', | ||||
|             'short_name': 'short_name', | ||||
|             'is_active': True, | ||||
|             'is_bot': False, | ||||
|             'realm': self.default_realm} | ||||
|             'pointer': -1, | ||||
|             'last_pointer_updater': 'seems unused?', | ||||
|             'realm': self.default_realm, | ||||
|             'api_key': '42'} | ||||
|         for key, value in defaults.items(): | ||||
|             kwargs[key] = kwargs.get(key, value) | ||||
|         kwargs['delivery_email'] = kwargs['email'] | ||||
|         with mock.patch("zerver.lib.create_user.timezone_now", return_value=kwargs['date_joined']): | ||||
|             pass_kwargs = {}  # type: Dict[str, Any] | ||||
|             if kwargs['is_bot']: | ||||
|                 pass_kwargs['bot_type'] = UserProfile.DEFAULT_BOT | ||||
|                 pass_kwargs['bot_owner'] = None | ||||
|             return create_user(kwargs['email'], 'password', kwargs['realm'], | ||||
|                                active=kwargs['is_active'], | ||||
|                                full_name=kwargs['full_name'], short_name=kwargs['short_name'], | ||||
|                                is_realm_admin=True, **pass_kwargs) | ||||
|         return UserProfile.objects.create(**kwargs) | ||||
|  | ||||
|     def create_stream_with_recipient(self, **kwargs: Any) -> Tuple[Stream, Recipient]: | ||||
|         self.name_counter += 1 | ||||
| @@ -92,17 +82,17 @@ class AnalyticsTestCase(TestCase): | ||||
|         defaults = { | ||||
|             'sender': sender, | ||||
|             'recipient': recipient, | ||||
|             DB_TOPIC_NAME: 'subject', | ||||
|             'subject': 'subject', | ||||
|             'content': 'hi', | ||||
|             'date_sent': self.TIME_LAST_HOUR, | ||||
|             'pub_date': self.TIME_LAST_HOUR, | ||||
|             'sending_client': get_client("website")} | ||||
|         for key, value in defaults.items(): | ||||
|             kwargs[key] = kwargs.get(key, value) | ||||
|         return Message.objects.create(**kwargs) | ||||
|  | ||||
|     # kwargs should only ever be a UserProfile or Stream. | ||||
|     def assertCountEquals(self, table: Type[BaseCount], value: int, property: Optional[str]=None, | ||||
|                           subgroup: Optional[str]=None, end_time: datetime=TIME_ZERO, | ||||
|     def assertCountEquals(self, table: Type[BaseCount], value: int, property: Optional[Text]=None, | ||||
|                           subgroup: Optional[Text]=None, end_time: datetime=TIME_ZERO, | ||||
|                           realm: Optional[Realm]=None, **kwargs: models.Model) -> None: | ||||
|         if property is None: | ||||
|             property = self.current_property | ||||
| @@ -324,7 +314,7 @@ class TestCountStats(AnalyticsTestCase): | ||||
|             recipient = self.create_stream_with_recipient( | ||||
|                 name='stream %s' % (minutes_ago,), realm=self.second_realm, | ||||
|                 date_created=creation_time)[1] | ||||
|             self.create_message(user, recipient, date_sent=creation_time) | ||||
|             self.create_message(user, recipient, pub_date=creation_time) | ||||
|         self.hourly_user = get_user('user-1@second.analytics', self.second_realm) | ||||
|         self.daily_user = get_user('user-61@second.analytics', self.second_realm) | ||||
|  | ||||
| @@ -369,8 +359,8 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         bot = self.create_user(is_bot=True) | ||||
|         human1 = self.create_user() | ||||
|         human2 = self.create_user() | ||||
|         recipient_human1 = Recipient.objects.get(type_id=human1.id, | ||||
|                                                  type=Recipient.PERSONAL) | ||||
|         recipient_human1 = Recipient.objects.create(type_id=human1.id, | ||||
|                                                     type=Recipient.PERSONAL) | ||||
|  | ||||
|         recipient_stream = self.create_stream_with_recipient()[1] | ||||
|         recipient_huddle = self.create_huddle_with_recipient()[1] | ||||
| @@ -424,9 +414,9 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         self.create_message(user2, recipient_huddle2) | ||||
|  | ||||
|         # private messages | ||||
|         recipient_user1 = Recipient.objects.get(type_id=user1.id, type=Recipient.PERSONAL) | ||||
|         recipient_user2 = Recipient.objects.get(type_id=user2.id, type=Recipient.PERSONAL) | ||||
|         recipient_user3 = Recipient.objects.get(type_id=user3.id, type=Recipient.PERSONAL) | ||||
|         recipient_user1 = Recipient.objects.create(type_id=user1.id, type=Recipient.PERSONAL) | ||||
|         recipient_user2 = Recipient.objects.create(type_id=user2.id, type=Recipient.PERSONAL) | ||||
|         recipient_user3 = Recipient.objects.create(type_id=user3.id, type=Recipient.PERSONAL) | ||||
|         self.create_message(user1, recipient_user2) | ||||
|         self.create_message(user2, recipient_user1) | ||||
|         self.create_message(user3, recipient_user3) | ||||
| @@ -458,7 +448,7 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         self.current_property = stat.property | ||||
|  | ||||
|         user = self.create_user(id=1000) | ||||
|         user_recipient = Recipient.objects.get(type_id=user.id, type=Recipient.PERSONAL) | ||||
|         user_recipient = Recipient.objects.create(type_id=user.id, type=Recipient.PERSONAL) | ||||
|         stream_recipient = self.create_stream_with_recipient(id=1000)[1] | ||||
|         huddle_recipient = self.create_huddle_with_recipient(id=1000)[1] | ||||
|  | ||||
| @@ -478,7 +468,7 @@ class TestCountStats(AnalyticsTestCase): | ||||
|  | ||||
|         user1 = self.create_user(is_bot=True) | ||||
|         user2 = self.create_user() | ||||
|         recipient_user2 = Recipient.objects.get(type_id=user2.id, type=Recipient.PERSONAL) | ||||
|         recipient_user2 = Recipient.objects.create(type_id=user2.id, type=Recipient.PERSONAL) | ||||
|  | ||||
|         recipient_stream = self.create_stream_with_recipient()[1] | ||||
|         recipient_huddle = self.create_huddle_with_recipient()[1] | ||||
| @@ -514,7 +504,7 @@ class TestCountStats(AnalyticsTestCase): | ||||
|         bot = self.create_user(is_bot=True) | ||||
|         human1 = self.create_user() | ||||
|         human2 = self.create_user() | ||||
|         recipient_human1 = Recipient.objects.get(type_id=human1.id, type=Recipient.PERSONAL) | ||||
|         recipient_human1 = Recipient.objects.create(type_id=human1.id, type=Recipient.PERSONAL) | ||||
|  | ||||
|         stream1, recipient_stream1 = self.create_stream_with_recipient() | ||||
|         stream2, recipient_stream2 = self.create_stream_with_recipient() | ||||
| @@ -550,49 +540,6 @@ class TestCountStats(AnalyticsTestCase): | ||||
|             user_profile=user, start=self.TIME_ZERO-start_offset, | ||||
|             end=self.TIME_ZERO-end_offset) | ||||
|  | ||||
|     def test_1day_actives(self) -> None: | ||||
|         stat = COUNT_STATS['1day_actives::day'] | ||||
|         self.current_property = stat.property | ||||
|  | ||||
|         _1day = 1*self.DAY - UserActivityInterval.MIN_INTERVAL_LENGTH | ||||
|  | ||||
|         # Outside time range, should not appear. Also tests upper boundary. | ||||
|         user1 = self.create_user() | ||||
|         self.create_interval(user1, _1day + self.DAY, _1day + timedelta(seconds=1)) | ||||
|         self.create_interval(user1, timedelta(0), -self.HOUR) | ||||
|  | ||||
|         # On lower boundary, should appear | ||||
|         user2 = self.create_user() | ||||
|         self.create_interval(user2, _1day + self.DAY, _1day) | ||||
|  | ||||
|         # Multiple intervals, including one outside boundary | ||||
|         user3 = self.create_user() | ||||
|         self.create_interval(user3, 2*self.DAY, 1*self.DAY) | ||||
|         self.create_interval(user3, 20*self.HOUR, 19*self.HOUR) | ||||
|         self.create_interval(user3, 20*self.MINUTE, 19*self.MINUTE) | ||||
|  | ||||
|         # Intervals crossing boundary | ||||
|         user4 = self.create_user() | ||||
|         self.create_interval(user4, 1.5*self.DAY, 0.5*self.DAY) | ||||
|         user5 = self.create_user() | ||||
|         self.create_interval(user5, self.MINUTE, -self.MINUTE) | ||||
|  | ||||
|         # Interval subsuming time range | ||||
|         user6 = self.create_user() | ||||
|         self.create_interval(user6, 2*self.DAY, -2*self.DAY) | ||||
|  | ||||
|         # Second realm | ||||
|         user7 = self.create_user(realm=self.second_realm) | ||||
|         self.create_interval(user7, 20*self.MINUTE, 19*self.MINUTE) | ||||
|  | ||||
|         do_fill_count_stat_at_hour(stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['value', 'user'], | ||||
|                               [[1, user2], [1, user3], [1, user4], [1, user5], [1, user6], [1, user7]]) | ||||
|         self.assertTableState(RealmCount, ['value', 'realm'], | ||||
|                               [[5, self.default_realm], [1, self.second_realm]]) | ||||
|         self.assertTableState(InstallationCount, ['value'], [[6]]) | ||||
|         self.assertTableState(StreamCount, [], []) | ||||
|  | ||||
|     def test_15day_actives(self) -> None: | ||||
|         stat = COUNT_STATS['15day_actives::day'] | ||||
|         self.current_property = stat.property | ||||
| @@ -852,6 +799,7 @@ class TestDeleteStats(AnalyticsTestCase): | ||||
|         RealmCount.objects.create(realm=user.realm, **count_args) | ||||
|         InstallationCount.objects.create(**count_args) | ||||
|         FillState.objects.create(property='test', end_time=self.TIME_ZERO, state=FillState.DONE) | ||||
|         Anomaly.objects.create(info='test anomaly') | ||||
|  | ||||
|         analytics = apps.get_app_config('analytics') | ||||
|         for table in list(analytics.models.values()): | ||||
| @@ -874,6 +822,7 @@ class TestDeleteStats(AnalyticsTestCase): | ||||
|             InstallationCount.objects.create(**count_args) | ||||
|         FillState.objects.create(property='to_delete', end_time=self.TIME_ZERO, state=FillState.DONE) | ||||
|         FillState.objects.create(property='to_save', end_time=self.TIME_ZERO, state=FillState.DONE) | ||||
|         Anomaly.objects.create(info='test anomaly') | ||||
|  | ||||
|         analytics = apps.get_app_config('analytics') | ||||
|         for table in list(analytics.models.values()): | ||||
| @@ -881,8 +830,11 @@ class TestDeleteStats(AnalyticsTestCase): | ||||
|  | ||||
|         do_drop_single_stat('to_delete') | ||||
|         for table in list(analytics.models.values()): | ||||
|             self.assertFalse(table.objects.filter(property='to_delete').exists()) | ||||
|             self.assertTrue(table.objects.filter(property='to_save').exists()) | ||||
|             if table._meta.db_table == 'analytics_anomaly': | ||||
|                 self.assertTrue(table.objects.exists()) | ||||
|             else: | ||||
|                 self.assertFalse(table.objects.filter(property='to_delete').exists()) | ||||
|                 self.assertTrue(table.objects.filter(property='to_save').exists()) | ||||
|  | ||||
| class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|     def setUp(self) -> None: | ||||
| @@ -891,7 +843,7 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|         self.stat = COUNT_STATS['active_users_audit:is_bot:day'] | ||||
|         self.current_property = self.stat.property | ||||
|  | ||||
|     def add_event(self, event_type: int, days_offset: float, | ||||
|     def add_event(self, event_type: str, days_offset: float, | ||||
|                   user: Optional[UserProfile]=None) -> None: | ||||
|         hours_offset = int(24*days_offset) | ||||
|         if user is None: | ||||
| @@ -901,49 +853,49 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|             event_time=self.TIME_ZERO - hours_offset*self.HOUR) | ||||
|  | ||||
|     def test_user_deactivated_in_future(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 0) | ||||
|         self.add_event('user_created', 1) | ||||
|         self.add_event('user_deactivated', 0) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     def test_user_reactivated_in_future(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_REACTIVATED, 0) | ||||
|         self.add_event('user_deactivated', 1) | ||||
|         self.add_event('user_reactivated', 0) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, [], []) | ||||
|  | ||||
|     def test_user_active_then_deactivated_same_day(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, .5) | ||||
|         self.add_event('user_created', 1) | ||||
|         self.add_event('user_deactivated', .5) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, [], []) | ||||
|  | ||||
|     def test_user_unactive_then_activated_same_day(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_REACTIVATED, .5) | ||||
|         self.add_event('user_deactivated', 1) | ||||
|         self.add_event('user_reactivated', .5) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     # Arguably these next two tests are duplicates of the _in_future tests, but are | ||||
|     # a guard against future refactorings where they may no longer be duplicates | ||||
|     def test_user_active_then_deactivated_with_day_gap(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 2) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 1) | ||||
|         self.add_event('user_created', 2) | ||||
|         self.add_event('user_deactivated', 1) | ||||
|         process_count_stat(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup', 'end_time'], | ||||
|                               [['false', self.TIME_ZERO - self.DAY]]) | ||||
|  | ||||
|     def test_user_deactivated_then_reactivated_with_day_gap(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 2) | ||||
|         self.add_event(RealmAuditLog.USER_REACTIVATED, 1) | ||||
|         self.add_event('user_deactivated', 2) | ||||
|         self.add_event('user_reactivated', 1) | ||||
|         process_count_stat(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     def test_event_types(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 4) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, 3) | ||||
|         self.add_event(RealmAuditLog.USER_ACTIVATED, 2) | ||||
|         self.add_event(RealmAuditLog.USER_REACTIVATED, 1) | ||||
|         self.add_event('user_created', 4) | ||||
|         self.add_event('user_deactivated', 3) | ||||
|         self.add_event('user_activated', 2) | ||||
|         self.add_event('user_reactivated', 1) | ||||
|         for i in range(4): | ||||
|             do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO - i*self.DAY) | ||||
|         self.assertTableState(UserCount, ['subgroup', 'end_time'], | ||||
| @@ -958,7 +910,7 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|         user3 = self.create_user(realm=second_realm) | ||||
|         user4 = self.create_user(realm=second_realm, is_bot=True) | ||||
|         for user in [user1, user2, user3, user4]: | ||||
|             self.add_event(RealmAuditLog.USER_CREATED, 1, user=user) | ||||
|             self.add_event('user_created', 1, user=user) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup', 'user'], | ||||
|                               [['false', user1], ['false', user2], ['false', user3], ['true', user4]]) | ||||
| @@ -976,7 +928,7 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|     # CountStat.HOUR from CountStat.DAY, this will fail, while many of the | ||||
|     # tests above will not. | ||||
|     def test_update_from_two_days_ago(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 2) | ||||
|         self.add_event('user_created', 2) | ||||
|         process_count_stat(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup', 'end_time'], | ||||
|                               [['false', self.TIME_ZERO], ['false', self.TIME_ZERO-self.DAY]]) | ||||
| @@ -985,22 +937,22 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|     # doesn't go through do_create_user. Mainly just want to make sure that | ||||
|     # that situation doesn't throw an error. | ||||
|     def test_empty_realm_or_user_with_no_relevant_activity(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, 1) | ||||
|         self.add_event('unrelated', 1) | ||||
|         self.create_user()  # also test a user with no RealmAuditLog entries | ||||
|         Realm.objects.create(string_id='moo', name='moo') | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, [], []) | ||||
|  | ||||
|     def test_max_audit_entry_is_unrelated(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, .5) | ||||
|         self.add_event('user_created', 1) | ||||
|         self.add_event('unrelated', .5) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
|     # Simultaneous related audit entries should not be allowed, and so not testing for that. | ||||
|     def test_simultaneous_unrelated_audit_entry(self) -> None: | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1) | ||||
|         self.add_event(RealmAuditLog.USER_SOFT_ACTIVATED, 1) | ||||
|         self.add_event('user_created', 1) | ||||
|         self.add_event('unrelated', 1) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['subgroup'], [['false']]) | ||||
|  | ||||
| @@ -1008,10 +960,10 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|         user1 = self.create_user() | ||||
|         user2 = self.create_user() | ||||
|         user3 = self.create_user() | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, .5, user=user1) | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, .5, user=user2) | ||||
|         self.add_event(RealmAuditLog.USER_CREATED, 1, user=user3) | ||||
|         self.add_event(RealmAuditLog.USER_DEACTIVATED, .5, user=user3) | ||||
|         self.add_event('user_created', .5, user=user1) | ||||
|         self.add_event('user_created', .5, user=user2) | ||||
|         self.add_event('user_created', 1, user=user3) | ||||
|         self.add_event('user_deactivated', .5, user=user3) | ||||
|         do_fill_count_stat_at_hour(self.stat, self.TIME_ZERO) | ||||
|         self.assertTableState(UserCount, ['user', 'subgroup'], | ||||
|                               [[user1, 'false'], [user2, 'false']]) | ||||
| @@ -1030,7 +982,7 @@ class TestActiveUsersAudit(AnalyticsTestCase): | ||||
|             self.assertTrue(UserCount.objects.filter( | ||||
|                 user=user, property=self.current_property, subgroup='false', | ||||
|                 end_time=end_time, value=1).exists()) | ||||
|         self.assertFalse(UserCount.objects.filter(user=user2, end_time=end_time).exists()) | ||||
|         self.assertFalse(UserCount.objects.filter(user=user2).exists()) | ||||
|  | ||||
| class TestRealmActiveHumans(AnalyticsTestCase): | ||||
|     def setUp(self) -> None: | ||||
|   | ||||
| @@ -1,16 +1,15 @@ | ||||
| from datetime import datetime, timedelta | ||||
| from typing import List, Optional | ||||
| from typing import Dict, List, Optional | ||||
|  | ||||
| import mock | ||||
| from django.utils.timezone import utc | ||||
| from django.http import HttpResponse | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat | ||||
| from analytics.lib.time_utils import time_range | ||||
| from analytics.models import FillState, \ | ||||
|     RealmCount, UserCount, last_successful_fill | ||||
| from analytics.views import rewrite_client_arrays, \ | ||||
|     sort_by_totals, sort_client_labels | ||||
| from analytics.views import get_chart_data, rewrite_client_arrays, \ | ||||
|     sort_by_totals, sort_client_labels, stats | ||||
| from zerver.lib.test_classes import ZulipTestCase | ||||
| from zerver.lib.timestamp import ceiling_to_day, \ | ||||
|     ceiling_to_hour, datetime_to_timestamp | ||||
| @@ -25,48 +24,6 @@ class TestStatsEndpoint(ZulipTestCase): | ||||
|         # Check that we get something back | ||||
|         self.assert_in_response("Zulip analytics for", result) | ||||
|  | ||||
|     def test_guest_user_cant_access_stats(self) -> None: | ||||
|         self.user = self.example_user('polonius') | ||||
|         self.login(self.user.email) | ||||
|         result = self.client_get('/stats') | ||||
|         self.assert_json_error(result, "Not allowed for guest users", 400) | ||||
|  | ||||
|         result = self.client_get('/json/analytics/chart_data') | ||||
|         self.assert_json_error(result, "Not allowed for guest users", 400) | ||||
|  | ||||
|     def test_stats_for_realm(self) -> None: | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         self.login(user_profile.email) | ||||
|  | ||||
|         result = self.client_get('/stats/realm/zulip/') | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|  | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         user_profile.is_staff = True | ||||
|         user_profile.save(update_fields=['is_staff']) | ||||
|  | ||||
|         result = self.client_get('/stats/realm/not_existing_realm/') | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|  | ||||
|         result = self.client_get('/stats/realm/zulip/') | ||||
|         self.assertEqual(result.status_code, 200) | ||||
|         self.assert_in_response("Zulip analytics for", result) | ||||
|  | ||||
|     def test_stats_for_installation(self) -> None: | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         self.login(user_profile.email) | ||||
|  | ||||
|         result = self.client_get('/stats/installation') | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|  | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         user_profile.is_staff = True | ||||
|         user_profile.save(update_fields=['is_staff']) | ||||
|  | ||||
|         result = self.client_get('/stats/installation') | ||||
|         self.assertEqual(result.status_code, 200) | ||||
|         self.assert_in_response("Zulip analytics for", result) | ||||
|  | ||||
| class TestGetChartData(ZulipTestCase): | ||||
|     def setUp(self) -> None: | ||||
|         self.realm = get_realm('zulip') | ||||
| @@ -102,10 +59,6 @@ class TestGetChartData(ZulipTestCase): | ||||
|     def test_number_of_humans(self) -> None: | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         self.insert_data(stat, [None], []) | ||||
|         stat = COUNT_STATS['1day_actives::day'] | ||||
|         self.insert_data(stat, [None], []) | ||||
|         stat = COUNT_STATS['active_users_audit:is_bot:day'] | ||||
|         self.insert_data(stat, ['false'], []) | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_success(result) | ||||
| @@ -114,7 +67,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|             'msg': '', | ||||
|             'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day], | ||||
|             'frequency': CountStat.DAY, | ||||
|             'everyone': {'_1day': self.data(100), '_15day': self.data(100), 'all_time': self.data(100)}, | ||||
|             'realm': {'human': self.data(100)}, | ||||
|             'display_order': None, | ||||
|             'result': 'success', | ||||
|         }) | ||||
| @@ -130,7 +83,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|             'msg': '', | ||||
|             'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_hour], | ||||
|             'frequency': CountStat.HOUR, | ||||
|             'everyone': {'bot': self.data(100), 'human': self.data(101)}, | ||||
|             'realm': {'bot': self.data(100), 'human': self.data(101)}, | ||||
|             'user': {'bot': self.data(0), 'human': self.data(200)}, | ||||
|             'display_order': None, | ||||
|             'result': 'success', | ||||
| @@ -148,8 +101,8 @@ class TestGetChartData(ZulipTestCase): | ||||
|             'msg': '', | ||||
|             'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day], | ||||
|             'frequency': CountStat.DAY, | ||||
|             'everyone': {'Public streams': self.data(100), 'Private streams': self.data(0), | ||||
|                          'Private messages': self.data(101), 'Group private messages': self.data(0)}, | ||||
|             'realm': {'Public streams': self.data(100), 'Private streams': self.data(0), | ||||
|                       'Private messages': self.data(101), 'Group private messages': self.data(0)}, | ||||
|             'user': {'Public streams': self.data(200), 'Private streams': self.data(201), | ||||
|                      'Private messages': self.data(0), 'Group private messages': self.data(0)}, | ||||
|             'display_order': ['Private messages', 'Public streams', 'Private streams', 'Group private messages'], | ||||
| @@ -172,8 +125,8 @@ class TestGetChartData(ZulipTestCase): | ||||
|             'msg': '', | ||||
|             'end_times': [datetime_to_timestamp(dt) for dt in self.end_times_day], | ||||
|             'frequency': CountStat.DAY, | ||||
|             'everyone': {'client 4': self.data(100), 'client 3': self.data(101), | ||||
|                          'client 2': self.data(102)}, | ||||
|             'realm': {'client 4': self.data(100), 'client 3': self.data(101), | ||||
|                       'client 2': self.data(102)}, | ||||
|             'user': {'client 3': self.data(200), 'client 1': self.data(201)}, | ||||
|             'display_order': ['client 1', 'client 2', 'client 3', 'client 4'], | ||||
|             'result': 'success', | ||||
| @@ -187,7 +140,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         self.assertEqual(data['everyone'], {"_1day": [0], "_15day": [0], "all_time": [0]}) | ||||
|         self.assertEqual(data['realm'], {'human': [0]}) | ||||
|         self.assertFalse('user' in data) | ||||
|  | ||||
|         FillState.objects.create( | ||||
| @@ -197,7 +150,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|                                  {'chart_name': 'messages_sent_over_time'}) | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         self.assertEqual(data['everyone'], {'human': [0], 'bot': [0]}) | ||||
|         self.assertEqual(data['realm'], {'human': [0], 'bot': [0]}) | ||||
|         self.assertEqual(data['user'], {'human': [0], 'bot': [0]}) | ||||
|  | ||||
|         FillState.objects.create( | ||||
| @@ -207,7 +160,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|                                  {'chart_name': 'messages_sent_by_message_type'}) | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         self.assertEqual(data['everyone'], { | ||||
|         self.assertEqual(data['realm'], { | ||||
|             'Public streams': [0], 'Private streams': [0], | ||||
|             'Private messages': [0], 'Group private messages': [0]}) | ||||
|         self.assertEqual(data['user'], { | ||||
| @@ -221,16 +174,12 @@ class TestGetChartData(ZulipTestCase): | ||||
|                                  {'chart_name': 'messages_sent_by_client'}) | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         self.assertEqual(data['everyone'], {}) | ||||
|         self.assertEqual(data['realm'], {}) | ||||
|         self.assertEqual(data['user'], {}) | ||||
|  | ||||
|     def test_start_and_end(self) -> None: | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         self.insert_data(stat, [None], []) | ||||
|         stat = COUNT_STATS['1day_actives::day'] | ||||
|         self.insert_data(stat, [None], []) | ||||
|         stat = COUNT_STATS['active_users_audit:is_bot:day'] | ||||
|         self.insert_data(stat, ['false'], []) | ||||
|         end_time_timestamps = [datetime_to_timestamp(dt) for dt in self.end_times_day] | ||||
|  | ||||
|         # valid start and end | ||||
| @@ -241,7 +190,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         self.assertEqual(data['end_times'], end_time_timestamps[1:3]) | ||||
|         self.assertEqual(data['everyone'], {'_1day': [0, 100], '_15day': [0, 100], 'all_time': [0, 100]}) | ||||
|         self.assertEqual(data['realm'], {'human': [0, 100]}) | ||||
|  | ||||
|         # start later then end | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
| @@ -253,10 +202,6 @@ class TestGetChartData(ZulipTestCase): | ||||
|     def test_min_length(self) -> None: | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         self.insert_data(stat, [None], []) | ||||
|         stat = COUNT_STATS['1day_actives::day'] | ||||
|         self.insert_data(stat, [None], []) | ||||
|         stat = COUNT_STATS['active_users_audit:is_bot:day'] | ||||
|         self.insert_data(stat, ['false'], []) | ||||
|         # test min_length is too short to change anything | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'number_of_humans', | ||||
| @@ -264,7 +209,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|         self.assert_json_success(result) | ||||
|         data = result.json() | ||||
|         self.assertEqual(data['end_times'], [datetime_to_timestamp(dt) for dt in self.end_times_day]) | ||||
|         self.assertEqual(data['everyone'], {'_1day': self.data(100), '_15day': self.data(100), 'all_time': self.data(100)}) | ||||
|         self.assertEqual(data['realm'], {'human': self.data(100)}) | ||||
|         # test min_length larger than filled data | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
|                                  {'chart_name': 'number_of_humans', | ||||
| @@ -273,7 +218,7 @@ class TestGetChartData(ZulipTestCase): | ||||
|         data = result.json() | ||||
|         end_times = [ceiling_to_day(self.realm.date_created) + timedelta(days=i) for i in range(-1, 4)] | ||||
|         self.assertEqual(data['end_times'], [datetime_to_timestamp(dt) for dt in end_times]) | ||||
|         self.assertEqual(data['everyone'], {'_1day': [0]+self.data(100), '_15day': [0]+self.data(100), 'all_time': [0]+self.data(100)}) | ||||
|         self.assertEqual(data['realm'], {'human': [0]+self.data(100)}) | ||||
|  | ||||
|     def test_non_existent_chart(self) -> None: | ||||
|         result = self.client_get('/json/analytics/chart_data', | ||||
| @@ -288,192 +233,6 @@ class TestGetChartData(ZulipTestCase): | ||||
|                                      {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_error_contains(result, 'No analytics data available') | ||||
|  | ||||
|     def test_get_chart_data_for_realm(self) -> None: | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         self.login(user_profile.email) | ||||
|  | ||||
|         result = self.client_get('/json/analytics/chart_data/realm/zulip/', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_error(result, "Must be an server administrator", 400) | ||||
|  | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         user_profile.is_staff = True | ||||
|         user_profile.save(update_fields=['is_staff']) | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         self.insert_data(stat, [None], []) | ||||
|  | ||||
|         result = self.client_get('/json/analytics/chart_data/realm/not_existing_realm', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_error(result, 'Invalid organization', 400) | ||||
|  | ||||
|         result = self.client_get('/json/analytics/chart_data/realm/zulip', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_success(result) | ||||
|  | ||||
|     def test_get_chart_data_for_installation(self) -> None: | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         self.login(user_profile.email) | ||||
|  | ||||
|         result = self.client_get('/json/analytics/chart_data/installation', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_error(result, "Must be an server administrator", 400) | ||||
|  | ||||
|         user_profile = self.example_user('hamlet') | ||||
|         user_profile.is_staff = True | ||||
|         user_profile.save(update_fields=['is_staff']) | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         self.insert_data(stat, [None], []) | ||||
|  | ||||
|         result = self.client_get('/json/analytics/chart_data/installation', | ||||
|                                  {'chart_name': 'number_of_humans'}) | ||||
|         self.assert_json_success(result) | ||||
|  | ||||
| class TestSupportEndpoint(ZulipTestCase): | ||||
|     def test_search(self) -> None: | ||||
|         def check_hamlet_user_query_result(result: HttpResponse) -> None: | ||||
|             self.assert_in_success_response(['<span class="label">user</span>\n', '<h3>King Hamlet</h3>', | ||||
|                                              '<b>Email</b>: hamlet@zulip.com', '<b>Is active</b>: True<br>', | ||||
|                                              '<b>Admins</b>: iago@zulip.com\n', | ||||
|                                              'class="copy-button" data-admin-emails="iago@zulip.com"' | ||||
|                                              ], result) | ||||
|  | ||||
|         def check_zulip_realm_query_result(result: HttpResponse) -> None: | ||||
|             zulip_realm = get_realm("zulip") | ||||
|             self.assert_in_success_response(['<input type="hidden" name="realm_id" value="%s"' % (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(['<input type="hidden" name="realm_id" value="%s"' % (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"'], result) | ||||
|  | ||||
|         cordelia_email = self.example_email("cordelia") | ||||
|         self.login(cordelia_email) | ||||
|  | ||||
|         result = self.client_get("/activity/support") | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|         self.assertEqual(result["Location"], "/login/") | ||||
|  | ||||
|         iago_email = self.example_email("iago") | ||||
|         self.login(iago_email) | ||||
|  | ||||
|         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": "hamlet@zulip.com"}) | ||||
|         check_hamlet_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": "lear, Hamlet <hamlet@zulip.com>"}) | ||||
|         check_hamlet_user_query_result(result) | ||||
|         check_zulip_realm_query_result(result) | ||||
|         check_lear_realm_query_result(result) | ||||
|  | ||||
|     def test_change_plan_type(self) -> None: | ||||
|         cordelia = self.example_user("cordelia") | ||||
|         self.login(cordelia.email) | ||||
|  | ||||
|         result = self.client_post("/activity/support", {"realm_id": "%s" % (cordelia.realm_id,), "plan_type": "2"}) | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|         self.assertEqual(result["Location"], "/login/") | ||||
|  | ||||
|         iago = self.example_user("iago") | ||||
|         self.login(iago.email) | ||||
|  | ||||
|         with mock.patch("analytics.views.do_change_plan_type") as m: | ||||
|             result = self.client_post("/activity/support", {"realm_id": "%s" % (iago.realm_id,), "plan_type": "2"}) | ||||
|             m.assert_called_once_with(get_realm("zulip"), 2) | ||||
|             self.assert_in_success_response(["Plan type of Zulip Dev changed from self hosted to limited"], result) | ||||
|  | ||||
|     def test_attach_discount(self) -> None: | ||||
|         lear_realm = get_realm("lear") | ||||
|         cordelia_email = self.example_email("cordelia") | ||||
|         self.login(cordelia_email) | ||||
|  | ||||
|         result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "discount": "25"}) | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|         self.assertEqual(result["Location"], "/login/") | ||||
|  | ||||
|         iago_email = self.example_email("iago") | ||||
|         self.login(iago_email) | ||||
|  | ||||
|         with mock.patch("analytics.views.attach_discount_to_realm") as m: | ||||
|             result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "discount": "25"}) | ||||
|             m.assert_called_once_with(get_realm("lear"), 25) | ||||
|             self.assert_in_success_response(["Discount of Lear & Co. changed to 25 from None"], result) | ||||
|  | ||||
|     def test_activate_or_deactivate_realm(self) -> None: | ||||
|         lear_realm = get_realm("lear") | ||||
|         cordelia_email = self.example_email("cordelia") | ||||
|         self.login(cordelia_email) | ||||
|  | ||||
|         result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "status": "deactivated"}) | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|         self.assertEqual(result["Location"], "/login/") | ||||
|  | ||||
|         iago_email = self.example_email("iago") | ||||
|         self.login(iago_email) | ||||
|  | ||||
|         with mock.patch("analytics.views.do_deactivate_realm") as m: | ||||
|             result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "status": "deactivated"}) | ||||
|             m.assert_called_once_with(lear_realm, self.example_user("iago")) | ||||
|             self.assert_in_success_response(["Lear & Co. deactivated"], result) | ||||
|  | ||||
|         with mock.patch("analytics.views.do_reactivate_realm") as m: | ||||
|             result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "status": "active"}) | ||||
|             m.assert_called_once_with(lear_realm) | ||||
|             self.assert_in_success_response(["Lear & Co. reactivated."], result) | ||||
|  | ||||
|     def test_scrub_realm(self) -> None: | ||||
|         lear_realm = get_realm("lear") | ||||
|         cordelia_email = self.example_email("cordelia") | ||||
|         self.login(cordelia_email) | ||||
|  | ||||
|         result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "discount": "25"}) | ||||
|         self.assertEqual(result.status_code, 302) | ||||
|         self.assertEqual(result["Location"], "/login/") | ||||
|  | ||||
|         iago_email = self.example_email("iago") | ||||
|         self.login(iago_email) | ||||
|  | ||||
|         with mock.patch("analytics.views.do_scrub_realm") as m: | ||||
|             result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,), "scrub_realm": "scrub_realm"}) | ||||
|             m.assert_called_once_with(lear_realm) | ||||
|             self.assert_in_success_response(["Lear & Co. scrubbed"], result) | ||||
|  | ||||
|         with mock.patch("analytics.views.do_scrub_realm") as m: | ||||
|             result = self.client_post("/activity/support", {"realm_id": "%s" % (lear_realm.id,)}) | ||||
|             m.assert_not_called() | ||||
|  | ||||
| class TestGetChartDataHelpers(ZulipTestCase): | ||||
|     # last_successful_fill is in analytics/models.py, but get_chart_data is | ||||
|     # the only function that uses it at the moment | ||||
| @@ -494,7 +253,7 @@ class TestGetChartDataHelpers(ZulipTestCase): | ||||
|         self.assertEqual(sort_by_totals(value_arrays), ['a', 'b', 'c', 'd']) | ||||
|  | ||||
|     def test_sort_client_labels(self) -> None: | ||||
|         data = {'everyone': {'a': [16], 'c': [15], 'b': [14], 'e': [13], 'd': [12], 'h': [11]}, | ||||
|         data = {'realm': {'a': [16], 'c': [15], 'b': [14], 'e': [13], 'd': [12], 'h': [11]}, | ||||
|                 'user': {'a': [6], 'b': [5], 'd': [4], 'e': [3], 'f': [2], 'g': [1]}} | ||||
|         self.assertEqual(sort_client_labels(data), ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']) | ||||
|  | ||||
|   | ||||
| @@ -7,24 +7,11 @@ i18n_urlpatterns = [ | ||||
|     # Server admin (user_profile.is_staff) visible stats pages | ||||
|     url(r'^activity$', analytics.views.get_activity, | ||||
|         name='analytics.views.get_activity'), | ||||
|     url(r'^activity/support$', analytics.views.support, | ||||
|         name='analytics.views.support'), | ||||
|     url(r'^realm_activity/(?P<realm_str>[\S]+)/$', analytics.views.get_realm_activity, | ||||
|         name='analytics.views.get_realm_activity'), | ||||
|     url(r'^user_activity/(?P<email>[\S]+)/$', analytics.views.get_user_activity, | ||||
|         name='analytics.views.get_user_activity'), | ||||
|  | ||||
|     url(r'^stats/realm/(?P<realm_str>[\S]+)/$', analytics.views.stats_for_realm, | ||||
|         name='analytics.views.stats_for_realm'), | ||||
|     url(r'^stats/installation$', analytics.views.stats_for_installation, | ||||
|         name='analytics.views.stats_for_installation'), | ||||
|     url(r'^stats/remote/(?P<remote_server_id>[\S]+)/installation$', | ||||
|         analytics.views.stats_for_remote_installation, | ||||
|         name='analytics.views.stats_for_remote_installation'), | ||||
|     url(r'^stats/remote/(?P<remote_server_id>[\S]+)/realm/(?P<remote_realm_id>[\S]+)/$', | ||||
|         analytics.views.stats_for_remote_realm, | ||||
|         name='analytics.views.stats_for_remote_realm'), | ||||
|  | ||||
|     # User-visible stats page | ||||
|     url(r'^stats$', analytics.views.stats, | ||||
|         name='analytics.views.stats'), | ||||
| @@ -42,15 +29,6 @@ v1_api_and_json_patterns = [ | ||||
|     # get data for the graphs at /stats | ||||
|     url(r'^analytics/chart_data$', rest_dispatch, | ||||
|         {'GET': 'analytics.views.get_chart_data'}), | ||||
|     url(r'^analytics/chart_data/realm/(?P<realm_str>[\S]+)$', rest_dispatch, | ||||
|         {'GET': 'analytics.views.get_chart_data_for_realm'}), | ||||
|     url(r'^analytics/chart_data/installation$', rest_dispatch, | ||||
|         {'GET': 'analytics.views.get_chart_data_for_installation'}), | ||||
|     url(r'^analytics/chart_data/remote/(?P<remote_server_id>[\S]+)/installation$', rest_dispatch, | ||||
|         {'GET': 'analytics.views.get_chart_data_for_remote_installation'}), | ||||
|     url(r'^analytics/chart_data/remote/(?P<remote_server_id>[\S]+)/realm/(?P<remote_realm_id>[\S]+)$', | ||||
|         rest_dispatch, | ||||
|         {'GET': 'analytics.views.get_chart_data_for_remote_realm'}), | ||||
| ] | ||||
|  | ||||
| i18n_urlpatterns += [ | ||||
|   | ||||
| @@ -1,204 +1,78 @@ | ||||
|  | ||||
| import itertools | ||||
| import json | ||||
| import logging | ||||
| import re | ||||
| import time | ||||
| import urllib | ||||
| from collections import defaultdict | ||||
| from datetime import datetime, timedelta | ||||
| from decimal import Decimal | ||||
|  | ||||
| from typing import Any, Callable, Dict, List, \ | ||||
|     Optional, Set, Tuple, Type, Union | ||||
|     Optional, Set, Text, Tuple, Type, Union | ||||
|  | ||||
| import pytz | ||||
| from django.conf import settings | ||||
| from django.urls import reverse | ||||
| from django.db import connection | ||||
| from django.db.models import Sum | ||||
| from django.db.models.query import QuerySet | ||||
| from django.http import HttpRequest, HttpResponse, HttpResponseNotFound | ||||
| from django.shortcuts import render | ||||
| from django.template import loader | ||||
| from django.template import RequestContext, loader | ||||
| from django.utils.timezone import now as timezone_now, utc as timezone_utc | ||||
| from django.utils.translation import ugettext as _ | ||||
| from django.core.validators import URLValidator | ||||
| from django.core.exceptions import ValidationError | ||||
| from jinja2 import Markup as mark_safe | ||||
|  | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat | ||||
| from analytics.lib.counts import COUNT_STATS, CountStat, process_count_stat | ||||
| from analytics.lib.time_utils import time_range | ||||
| from analytics.models import BaseCount, InstallationCount, \ | ||||
|     RealmCount, StreamCount, UserCount, last_successful_fill, installation_epoch | ||||
| from zerver.decorator import require_server_admin, require_server_admin_api, \ | ||||
|     to_non_negative_int, to_utc_datetime, zulip_login_required, require_non_guest_user | ||||
|     RealmCount, StreamCount, UserCount, last_successful_fill | ||||
| from zerver.decorator import require_server_admin, \ | ||||
|     to_non_negative_int, to_utc_datetime, zulip_login_required | ||||
| from zerver.lib.exceptions import JsonableError | ||||
| from zerver.lib.request import REQ, has_request_variables | ||||
| from zerver.lib.response import json_success | ||||
| from zerver.lib.timestamp import convert_to_UTC, timestamp_to_datetime | ||||
| from zerver.lib.realm_icon import realm_icon_url | ||||
| from zerver.views.invite import get_invitee_emails_set | ||||
| from zerver.lib.subdomains import get_subdomain_from_hostname | ||||
| from zerver.lib.actions import do_change_plan_type, do_deactivate_realm, \ | ||||
|     do_reactivate_realm, do_scrub_realm | ||||
|  | ||||
| if settings.BILLING_ENABLED: | ||||
|     from corporate.lib.stripe import attach_discount_to_realm, get_discount_for_realm | ||||
|  | ||||
| from zerver.lib.timestamp import ceiling_to_day, \ | ||||
|     ceiling_to_hour, convert_to_UTC, timestamp_to_datetime | ||||
| from zerver.models import Client, get_realm, Realm, \ | ||||
|     UserActivity, UserActivityInterval, UserProfile | ||||
|  | ||||
| if settings.ZILENCER_ENABLED: | ||||
|     from zilencer.models import RemoteInstallationCount, RemoteRealmCount, \ | ||||
|         RemoteZulipServer | ||||
| else: | ||||
|     from mock import Mock | ||||
|     RemoteInstallationCount = Mock()  # type: ignore # https://github.com/JukkaL/mypy/issues/1188 | ||||
|     RemoteZulipServer = Mock()  # type: ignore # https://github.com/JukkaL/mypy/issues/1188 | ||||
|     RemoteRealmCount = Mock()  # type: ignore # https://github.com/JukkaL/mypy/issues/1188 | ||||
|  | ||||
| def render_stats(request: HttpRequest, data_url_suffix: str, target_name: str, | ||||
|                  for_installation: bool=False, remote: bool=False) -> HttpRequest: | ||||
|     page_params = dict( | ||||
|         data_url_suffix=data_url_suffix, | ||||
|         for_installation=for_installation, | ||||
|         remote=remote, | ||||
|         debug_mode=False, | ||||
|     ) | ||||
|     return render(request, | ||||
|                   'analytics/stats.html', | ||||
|                   context=dict(target_name=target_name, | ||||
|                                page_params=page_params)) | ||||
|  | ||||
| @zulip_login_required | ||||
| def stats(request: HttpRequest) -> HttpResponse: | ||||
|     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) | ||||
|     return render(request, | ||||
|                   'analytics/stats.html', | ||||
|                   context=dict(realm_name = request.user.realm.name)) | ||||
|  | ||||
| @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("Realm %s does not exist" % (realm_str,)) | ||||
|  | ||||
|     return render_stats(request, '/realm/%s' % (realm_str,), realm.name or realm.string_id) | ||||
|  | ||||
| @require_server_admin | ||||
| @has_request_variables | ||||
| def stats_for_remote_realm(request: HttpRequest, remote_server_id: str, | ||||
|                            remote_realm_id: str) -> HttpResponse: | ||||
|     server = RemoteZulipServer.objects.get(id=remote_server_id) | ||||
|     return render_stats(request, '/remote/%s/realm/%s' % (server.id, remote_realm_id), | ||||
|                         "Realm %s on server %s" % (remote_realm_id, 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: str, | ||||
|         remote_realm_id: str, **kwargs: Any) -> HttpResponse: | ||||
|     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: str) -> HttpResponse: | ||||
|     server = RemoteZulipServer.objects.get(id=remote_server_id) | ||||
|     return render_stats(request, '/remote/%s/installation' % (server.id,), | ||||
|                         'remote Installation %s' % (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: str, | ||||
|         chart_name: str=REQ(), | ||||
|         **kwargs: Any) -> HttpResponse: | ||||
|     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(), | ||||
| def get_chart_data(request: HttpRequest, user_profile: UserProfile, chart_name: Text=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: | ||||
|     if for_installation: | ||||
|         if remote: | ||||
|             aggregate_table = RemoteInstallationCount | ||||
|             assert server is not None | ||||
|         else: | ||||
|             aggregate_table = InstallationCount | ||||
|     else: | ||||
|         if remote: | ||||
|             aggregate_table = RemoteRealmCount | ||||
|             assert server is not None | ||||
|             assert remote_realm_id is not None | ||||
|         else: | ||||
|             aggregate_table = RealmCount | ||||
|  | ||||
|                    end: Optional[datetime]=REQ(converter=to_utc_datetime, default=None)) -> HttpResponse: | ||||
|     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 = { | ||||
|             stats[0]: {None: '_1day'}, | ||||
|             stats[1]: {None: '_15day'}, | ||||
|             stats[2]: {'false': 'all_time'}}  # type: Dict[CountStat, Dict[Optional[str], str]] | ||||
|         stat = COUNT_STATS['realm_active_humans::day'] | ||||
|         tables = [RealmCount] | ||||
|         subgroup_to_label = {None: 'human'}  # type: Dict[Optional[str], str] | ||||
|         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'}} | ||||
|         stat = COUNT_STATS['messages_sent:is_bot:hour'] | ||||
|         tables = [RealmCount, UserCount] | ||||
|         subgroup_to_label = {'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']) | ||||
|         stat = COUNT_STATS['messages_sent:message_type:day'] | ||||
|         tables = [RealmCount, UserCount] | ||||
|         subgroup_to_label = {'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['realm']) | ||||
|         include_empty_subgroups = True | ||||
|     elif chart_name == 'messages_sent_by_client': | ||||
|         stats = [COUNT_STATS['messages_sent:client:day']] | ||||
|         tables = [aggregate_table, UserCount] | ||||
|         stat = COUNT_STATS['messages_sent:client:day'] | ||||
|         tables = [RealmCount, 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')}} | ||||
|         subgroup_to_label = {str(id): name for id, name in Client.objects.values_list('id', 'name')} | ||||
|         labels_sort_function = sort_client_labels | ||||
|         include_empty_subgroups = False | ||||
|     else: | ||||
| @@ -214,67 +88,27 @@ def get_chart_data(request: HttpRequest, user_profile: UserProfile, chart_name: | ||||
|         raise JsonableError(_("Start time is later than end time. Start: %(start)s, End: %(end)s") % | ||||
|                             {'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 | ||||
|     realm = user_profile.realm | ||||
|     if start is None: | ||||
|         start = realm.date_created | ||||
|     if end is None: | ||||
|         end = last_successful_fill(stat.property) | ||||
|     if end is None or start > end: | ||||
|         logging.warning("User from realm %s attempted to access /stats, but the computed " | ||||
|                         "start time: %s (creation time of realm) 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.")) | ||||
|  | ||||
|     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 | ||||
|         if not aggregate_table.objects.filter(server=server).exists(): | ||||
|             raise JsonableError(_("No analytics data available. Please contact your server administrator.")) | ||||
|         if start is None: | ||||
|             start = aggregate_table.objects.filter(server=server).first().end_time | ||||
|         if end is None: | ||||
|             end = aggregate_table.objects.filter(server=server).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(last_successful_fill(stat.property) or | ||||
|                       datetime.min.replace(tzinfo=timezone_utc) for stat in stats) | ||||
|         if start > end: | ||||
|             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(set([stat.frequency for stat in stats])) == 1 | ||||
|     end_times = time_range(start, end, stats[0].frequency, min_length) | ||||
|     data = {'end_times': end_times, 'frequency': stats[0].frequency}  # type: Dict[str, Any] | ||||
|  | ||||
|     aggregation_level = { | ||||
|         InstallationCount: 'everyone', | ||||
|         RealmCount: 'everyone', | ||||
|         RemoteInstallationCount: 'everyone', | ||||
|         RemoteRealmCount: 'everyone', | ||||
|         UserCount: 'user', | ||||
|     } | ||||
|     # -1 is a placeholder value, since there is no relevant filtering on InstallationCount | ||||
|     id_value = { | ||||
|         InstallationCount: -1, | ||||
|         RealmCount: realm.id, | ||||
|         RemoteInstallationCount: server.id if server is not None else None, | ||||
|         # TODO: RemoteRealmCount logic doesn't correctly handle | ||||
|         # filtering by server_id as well. | ||||
|         RemoteRealmCount: remote_realm_id, | ||||
|         UserCount: user_profile.id, | ||||
|     } | ||||
|     end_times = time_range(start, end, stat.frequency, min_length) | ||||
|     data = {'end_times': end_times, 'frequency': stat.frequency} | ||||
|     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 table == RealmCount: | ||||
|             data['realm'] = get_time_series_by_subgroup( | ||||
|                 stat, RealmCount, realm.id, end_times, subgroup_to_label, include_empty_subgroups) | ||||
|         if table == UserCount: | ||||
|             data['user'] = get_time_series_by_subgroup( | ||||
|                 stat, UserCount, user_profile.id, end_times, subgroup_to_label, include_empty_subgroups) | ||||
|     if labels_sort_function is not None: | ||||
|         data['display_order'] = labels_sort_function(data) | ||||
|     else: | ||||
| @@ -293,7 +127,7 @@ def sort_by_totals(value_arrays: Dict[str, List[int]]) -> List[str]: | ||||
| # 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']) | ||||
|     realm_order = sort_by_totals(data['realm']) | ||||
|     user_order = sort_by_totals(data['user']) | ||||
|     label_sort_values = {}  # type: Dict[str, float] | ||||
|     for i, label in enumerate(realm_order): | ||||
| @@ -312,10 +146,6 @@ def table_filtered_to_id(table: Type[BaseCount], key_id: int) -> QuerySet: | ||||
|         return StreamCount.objects.filter(stream_id=key_id) | ||||
|     elif table == InstallationCount: | ||||
|         return InstallationCount.objects.all() | ||||
|     elif table == RemoteInstallationCount: | ||||
|         return RemoteInstallationCount.objects.filter(server_id=key_id) | ||||
|     elif table == RemoteRealmCount: | ||||
|         return RemoteRealmCount.objects.filter(realm_id=key_id) | ||||
|     else: | ||||
|         raise AssertionError("Unknown table: %s" % (table,)) | ||||
|  | ||||
| @@ -404,7 +234,7 @@ def get_realm_day_counts() -> Dict[str, Dict[str, str]]: | ||||
|     query = ''' | ||||
|         select | ||||
|             r.string_id, | ||||
|             (now()::date - date_sent::date) age, | ||||
|             (now()::date - pub_date::date) age, | ||||
|             count(*) cnt | ||||
|         from zerver_message m | ||||
|         join zerver_userprofile up on up.id = m.sender_id | ||||
| @@ -413,7 +243,7 @@ def get_realm_day_counts() -> Dict[str, Dict[str, str]]: | ||||
|         where | ||||
|             (not up.is_bot) | ||||
|         and | ||||
|             date_sent > now()::date - interval '8 day' | ||||
|             pub_date > now()::date - interval '8 day' | ||||
|         and | ||||
|             c.name not in ('zephyr_mirror', 'ZulipMonitoring') | ||||
|         group by | ||||
| @@ -456,9 +286,6 @@ def get_realm_day_counts() -> Dict[str, Dict[str, str]]: | ||||
|  | ||||
|     return result | ||||
|  | ||||
| def get_plan_name(plan_type: int) -> str: | ||||
|     return ['', 'self hosted', 'limited', 'standard', 'open source'][plan_type] | ||||
|  | ||||
| def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|     now = timezone_now() | ||||
|  | ||||
| @@ -466,7 +293,6 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|         SELECT | ||||
|             realm.string_id, | ||||
|             realm.date_created, | ||||
|             realm.plan_type, | ||||
|             coalesce(user_counts.dau_count, 0) dau_count, | ||||
|             coalesce(wau_counts.wau_count, 0) wau_count, | ||||
|             ( | ||||
| @@ -519,7 +345,7 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|                 FROM ( | ||||
|                     SELECT | ||||
|                         realm.id as realm_id, | ||||
|                         up.delivery_email | ||||
|                         up.email | ||||
|                     FROM zerver_useractivity ua | ||||
|                     JOIN zerver_userprofile up | ||||
|                         ON up.id = ua.user_profile_id | ||||
| @@ -536,7 +362,7 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|                             '/json/users/me/pointer', | ||||
|                             'update_pointer_backend' | ||||
|                         ) | ||||
|                     GROUP by realm.id, up.delivery_email | ||||
|                     GROUP by realm.id, up.email | ||||
|                     HAVING max(last_visit) > now() - interval '7 day' | ||||
|                 ) as wau_users | ||||
|                 GROUP BY realm_id | ||||
| @@ -574,14 +400,13 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|     # Fetch all the realm administrator users | ||||
|     realm_admins = defaultdict(list)  # type: Dict[str, List[str]] | ||||
|     for up in UserProfile.objects.select_related("realm").filter( | ||||
|         role=UserProfile.ROLE_REALM_ADMINISTRATOR, | ||||
|         is_realm_admin=True, | ||||
|         is_active=True | ||||
|     ): | ||||
|         realm_admins[up.realm.string_id].append(up.delivery_email) | ||||
|         realm_admins[up.realm.string_id].append(up.email) | ||||
|  | ||||
|     for row in rows: | ||||
|         row['date_created_day'] = row['date_created'].strftime('%Y-%m-%d') | ||||
|         row['plan_type_string'] = get_plan_name(row['plan_type']) | ||||
|         row['age_days'] = int((now - row['date_created']).total_seconds() | ||||
|                               / 86400) | ||||
|         row['is_new'] = row['age_days'] < 12 * 7 | ||||
| @@ -595,16 +420,6 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|         except Exception: | ||||
|             row['history'] = '' | ||||
|  | ||||
|     # estimate annual subscription revenue | ||||
|     total_amount = 0 | ||||
|     if settings.BILLING_ENABLED: | ||||
|         from corporate.lib.stripe import estimate_annual_recurring_revenue_by_realm | ||||
|         estimated_arrs = estimate_annual_recurring_revenue_by_realm() | ||||
|         for row in rows: | ||||
|             if row['string_id'] in estimated_arrs: | ||||
|                 row['amount'] = estimated_arrs[row['string_id']] | ||||
|         total_amount += sum(estimated_arrs.values()) | ||||
|  | ||||
|     # augment data with realm_minutes | ||||
|     total_hours = 0.0 | ||||
|     for row in rows: | ||||
| @@ -620,7 +435,6 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|  | ||||
|     # 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 | ||||
| @@ -640,11 +454,8 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|         total_bot_count += int(row['bot_count']) | ||||
|         total_wau_count += int(row['wau_count']) | ||||
|  | ||||
|     total_row = dict( | ||||
|     rows.append(dict( | ||||
|         string_id='Total', | ||||
|         plan_type_string="", | ||||
|         amount=total_amount, | ||||
|         stats_link = '', | ||||
|         date_created_day='', | ||||
|         realm_admin_email='', | ||||
|         dau_count=total_dau_count, | ||||
| @@ -652,9 +463,7 @@ def realm_summary_table(realm_minutes: Dict[str, float]) -> str: | ||||
|         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', | ||||
| @@ -680,15 +489,15 @@ def user_activity_intervals() -> Tuple[mark_safe, Dict[str, float]]: | ||||
|     ).only( | ||||
|         'start', | ||||
|         'end', | ||||
|         'user_profile__delivery_email', | ||||
|         'user_profile__email', | ||||
|         'user_profile__realm__string_id' | ||||
|     ).order_by( | ||||
|         'user_profile__realm__string_id', | ||||
|         'user_profile__delivery_email' | ||||
|         'user_profile__email' | ||||
|     ) | ||||
|  | ||||
|     by_string_id = lambda row: row.user_profile.realm.string_id | ||||
|     by_email = lambda row: row.user_profile.delivery_email | ||||
|     by_email = lambda row: row.user_profile.email | ||||
|  | ||||
|     realm_minutes = {} | ||||
|  | ||||
| @@ -737,7 +546,7 @@ def sent_messages_report(realm: str) -> str: | ||||
|         ) as series | ||||
|         left join ( | ||||
|             select | ||||
|                 date_sent::date date_sent, | ||||
|                 pub_date::date pub_date, | ||||
|                 count(*) cnt | ||||
|             from zerver_message m | ||||
|             join zerver_userprofile up on up.id = m.sender_id | ||||
| @@ -747,16 +556,16 @@ def sent_messages_report(realm: str) -> str: | ||||
|             and | ||||
|                 (not up.is_bot) | ||||
|             and | ||||
|                 date_sent > now() - interval '2 week' | ||||
|                 pub_date > now() - interval '2 week' | ||||
|             group by | ||||
|                 date_sent::date | ||||
|                 pub_date::date | ||||
|             order by | ||||
|                 date_sent::date | ||||
|                 pub_date::date | ||||
|         ) humans on | ||||
|             series.day = humans.date_sent | ||||
|             series.day = humans.pub_date | ||||
|         left join ( | ||||
|             select | ||||
|                 date_sent::date date_sent, | ||||
|                 pub_date::date pub_date, | ||||
|                 count(*) cnt | ||||
|             from zerver_message m | ||||
|             join zerver_userprofile up on up.id = m.sender_id | ||||
| @@ -766,13 +575,13 @@ def sent_messages_report(realm: str) -> str: | ||||
|             and | ||||
|                 up.is_bot | ||||
|             and | ||||
|                 date_sent > now() - interval '2 week' | ||||
|                 pub_date > now() - interval '2 week' | ||||
|             group by | ||||
|                 date_sent::date | ||||
|                 pub_date::date | ||||
|             order by | ||||
|                 date_sent::date | ||||
|                 pub_date::date | ||||
|         ) bots on | ||||
|             series.day = bots.date_sent | ||||
|             series.day = bots.pub_date | ||||
|     ''' | ||||
|     cursor = connection.cursor() | ||||
|     cursor.execute(query, [realm, realm]) | ||||
| @@ -782,8 +591,7 @@ def sent_messages_report(realm: str) -> str: | ||||
|     return make_table(title, cols, rows) | ||||
|  | ||||
| def ad_hoc_queries() -> List[Dict[str, str]]: | ||||
|     def get_page(query: str, cols: List[str], title: str, | ||||
|                  totals_columns: List[int]=[]) -> Dict[str, str]: | ||||
|     def get_page(query: str, cols: List[str], title: str) -> Dict[str, str]: | ||||
|         cursor = connection.cursor() | ||||
|         cursor.execute(query) | ||||
|         rows = cursor.fetchall() | ||||
| @@ -795,24 +603,11 @@ def ad_hoc_queries() -> List[Dict[str, str]]: | ||||
|             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) | ||||
|  | ||||
| @@ -962,49 +757,6 @@ def ad_hoc_queries() -> List[Dict[str, str]]: | ||||
|  | ||||
|     pages.append(get_page(query, cols, title)) | ||||
|  | ||||
|     title = 'Remote Zulip servers' | ||||
|  | ||||
|     query = ''' | ||||
|         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 | ||||
| @@ -1027,91 +779,10 @@ def get_activity(request: HttpRequest) -> HttpResponse: | ||||
|         context=dict(data=data, title=title, is_home=True), | ||||
|     ) | ||||
|  | ||||
| @require_server_admin | ||||
| def support(request: HttpRequest) -> HttpResponse: | ||||
|     context = {}  # type: Dict[str, Any] | ||||
|     if settings.BILLING_ENABLED and request.method == "POST": | ||||
|         realm_id = request.POST.get("realm_id", None) | ||||
|         realm = Realm.objects.get(id=realm_id) | ||||
|  | ||||
|         new_plan_type = request.POST.get("plan_type", None) | ||||
|         if new_plan_type is not None: | ||||
|             new_plan_type = int(new_plan_type) | ||||
|             current_plan_type = realm.plan_type | ||||
|             do_change_plan_type(realm, new_plan_type) | ||||
|             msg = "Plan type of {} changed from {} to {} ".format(realm.name, | ||||
|                                                                   get_plan_name(current_plan_type), | ||||
|                                                                   get_plan_name(new_plan_type)) | ||||
|             context["message"] = msg | ||||
|  | ||||
|         new_discount = request.POST.get("discount", None) | ||||
|         if new_discount is not None: | ||||
|             new_discount = Decimal(new_discount) | ||||
|             current_discount = get_discount_for_realm(realm) | ||||
|             attach_discount_to_realm(realm, new_discount) | ||||
|             msg = "Discount of {} changed to {} from {} ".format(realm.name, new_discount, current_discount) | ||||
|             context["message"] = msg | ||||
|  | ||||
|         status = request.POST.get("status", None) | ||||
|         if status is not None: | ||||
|             if status == "active": | ||||
|                 do_reactivate_realm(realm) | ||||
|                 context["message"] = "{} reactivated.".format(realm.name) | ||||
|             elif status == "deactivated": | ||||
|                 do_deactivate_realm(realm, request.user) | ||||
|                 context["message"] = "{} deactivated.".format(realm.name) | ||||
|  | ||||
|         scrub_realm = request.POST.get("scrub_realm", None) | ||||
|         if scrub_realm is not None: | ||||
|             if scrub_realm == "scrub_realm": | ||||
|                 do_scrub_realm(realm) | ||||
|                 context["message"] = "{} scrubbed.".format(realm.name) | ||||
|  | ||||
|     query = request.GET.get("q", None) | ||||
|     if query: | ||||
|         key_words = get_invitee_emails_set(query) | ||||
|  | ||||
|         users = UserProfile.objects.filter(delivery_email__in=key_words) | ||||
|         if users: | ||||
|             for user in users: | ||||
|                 user.realm.realm_icon_url = realm_icon_url(user.realm) | ||||
|                 user.realm.admin_emails = ", ".join( | ||||
|                     user.realm.get_human_admin_users().values_list( | ||||
|                         "delivery_email", | ||||
|                         flat=True)) | ||||
|                 user.realm.default_discount = get_discount_for_realm(user.realm) | ||||
|             context["users"] = users | ||||
|  | ||||
|         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 | ||||
|                 if parse_result.port: | ||||
|                     hostname = "{}:{}".format(hostname, parse_result.port) | ||||
|                 subdomain = get_subdomain_from_hostname(hostname) | ||||
|                 try: | ||||
|                     realms.add(get_realm(subdomain)) | ||||
|                 except Realm.DoesNotExist: | ||||
|                     pass | ||||
|             except ValidationError: | ||||
|                 pass | ||||
|  | ||||
|         if realms: | ||||
|             for realm in realms: | ||||
|                 realm.realm_icon_url = realm_icon_url(realm) | ||||
|                 realm.admin_emails = ", ".join(realm.get_human_admin_users().values_list( | ||||
|                     "delivery_email", flat=True)) | ||||
|                 realm.default_discount = get_discount_for_realm(realm) | ||||
|             context["realms"] = realms | ||||
|     return render(request, 'analytics/support.html', context=context) | ||||
|  | ||||
| def get_user_activity_records_for_realm(realm: str, is_bot: bool) -> QuerySet: | ||||
|     fields = [ | ||||
|         'user_profile__full_name', | ||||
|         'user_profile__delivery_email', | ||||
|         'user_profile__email', | ||||
|         'query', | ||||
|         'client__name', | ||||
|         'count', | ||||
| @@ -1123,7 +794,7 @@ def get_user_activity_records_for_realm(realm: str, is_bot: bool) -> QuerySet: | ||||
|         user_profile__is_active=True, | ||||
|         user_profile__is_bot=is_bot | ||||
|     ) | ||||
|     records = records.order_by("user_profile__delivery_email", "-last_visit") | ||||
|     records = records.order_by("user_profile__email", "-last_visit") | ||||
|     records = records.select_related('user_profile', 'client').only(*fields) | ||||
|     return records | ||||
|  | ||||
| @@ -1137,7 +808,7 @@ def get_user_activity_records_for_email(email: str) -> List[QuerySet]: | ||||
|     ] | ||||
|  | ||||
|     records = UserActivity.objects.filter( | ||||
|         user_profile__delivery_email=email | ||||
|         user_profile__email=email | ||||
|     ) | ||||
|     records = records.order_by("-last_visit") | ||||
|     records = records.select_related('user_profile', 'client').only(*fields) | ||||
| @@ -1230,18 +901,6 @@ def realm_activity_link(realm_str: str) -> mark_safe: | ||||
|     realm_link = '<a href="%s">%s</a>' % (url, realm_str) | ||||
|     return mark_safe(realm_link) | ||||
|  | ||||
| def realm_stats_link(realm_str: str) -> mark_safe: | ||||
|     url_name = 'analytics.views.stats_for_realm' | ||||
|     url = reverse(url_name, kwargs=dict(realm_str=realm_str)) | ||||
|     stats_link = '<a href="{}"><i class="fa fa-pie-chart"></i></a>'.format(url, realm_str) | ||||
|     return mark_safe(stats_link) | ||||
|  | ||||
| def remote_installation_stats_link(server_id: int, hostname: str) -> mark_safe: | ||||
|     url_name = 'analytics.views.stats_for_remote_installation' | ||||
|     url = reverse(url_name, kwargs=dict(remote_server_id=server_id)) | ||||
|     stats_link = '<a href="{}"><i class="fa fa-pie-chart"></i>{}</a>'.format(url, hostname) | ||||
|     return mark_safe(stats_link) | ||||
|  | ||||
| def realm_client_table(user_summaries: Dict[str, Dict[str, Dict[str, Any]]]) -> str: | ||||
|     exclude_keys = [ | ||||
|         'internal', | ||||
| @@ -1313,11 +972,11 @@ def user_activity_summary_table(user_summary: Dict[str, Dict[str, Any]]) -> str: | ||||
|     return make_table(title, cols, rows) | ||||
|  | ||||
| def realm_user_summary_table(all_records: List[QuerySet], | ||||
|                              admin_emails: Set[str]) -> Tuple[Dict[str, Dict[str, Any]], str]: | ||||
|                              admin_emails: Set[Text]) -> Tuple[Dict[str, Dict[str, Any]], str]: | ||||
|     user_records = {} | ||||
|  | ||||
|     def by_email(record: QuerySet) -> str: | ||||
|         return record.user_profile.delivery_email | ||||
|         return record.user_profile.email | ||||
|  | ||||
|     for email, records in itertools.groupby(all_records, by_email): | ||||
|         user_records[email] = get_user_activity_summary(list(records)) | ||||
| @@ -1384,11 +1043,11 @@ def get_realm_activity(request: HttpRequest, realm_str: str) -> HttpResponse: | ||||
|     all_user_records = {}  # type: Dict[str, Any] | ||||
|  | ||||
|     try: | ||||
|         admins = Realm.objects.get(string_id=realm_str).get_human_admin_users() | ||||
|         admins = Realm.objects.get(string_id=realm_str).get_admin_users() | ||||
|     except Realm.DoesNotExist: | ||||
|         return HttpResponseNotFound("Realm %s does not exist" % (realm_str,)) | ||||
|  | ||||
|     admin_emails = {admin.delivery_email for admin in admins} | ||||
|     admin_emails = {admin.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)) | ||||
|   | ||||
| @@ -1,16 +0,0 @@ | ||||
| module.exports = { | ||||
|     presets: [ | ||||
|         [ | ||||
|             "@babel/preset-env", | ||||
|             { | ||||
|                 corejs: 3, | ||||
|                 useBuiltIns: "usage", | ||||
|             }, | ||||
|         ], | ||||
|         "@babel/typescript", | ||||
|     ], | ||||
|     plugins: [ | ||||
|         "@babel/proposal-class-properties", | ||||
|     ], | ||||
|     sourceType: "unambiguous", | ||||
| }; | ||||
| @@ -16,11 +16,13 @@ from django.http import HttpRequest, HttpResponse | ||||
| from django.shortcuts import render | ||||
| from django.utils.timezone import now as timezone_now | ||||
|  | ||||
| from zerver.lib.send_email import send_email | ||||
| from zerver.lib.utils import generate_random_token | ||||
| from zerver.models import PreregistrationUser, EmailChangeStatus, MultiuseInvite, \ | ||||
|     UserProfile, Realm | ||||
| from random import SystemRandom | ||||
| import string | ||||
| from typing import Dict, Optional, Union | ||||
| from typing import Any, Dict, Optional, Text, Union | ||||
|  | ||||
| class ConfirmationKeyException(Exception): | ||||
|     WRONG_LENGTH = 1 | ||||
| @@ -69,11 +71,8 @@ def create_confirmation_link(obj: ContentType, host: str, | ||||
|                              confirmation_type: int, | ||||
|                              url_args: Optional[Dict[str, str]]=None) -> str: | ||||
|     key = generate_key() | ||||
|     realm = None | ||||
|     if hasattr(obj, 'realm'): | ||||
|         realm = obj.realm | ||||
|     Confirmation.objects.create(content_object=obj, date_sent=timezone_now(), confirmation_key=key, | ||||
|                                 realm=realm, type=confirmation_type) | ||||
|                                 realm=obj.realm, type=confirmation_type) | ||||
|     return confirmation_url(key, host, confirmation_type, url_args) | ||||
|  | ||||
| def confirmation_url(confirmation_key: str, host: str, | ||||
| @@ -101,10 +100,9 @@ class Confirmation(models.Model): | ||||
|     SERVER_REGISTRATION = 5 | ||||
|     MULTIUSE_INVITE = 6 | ||||
|     REALM_CREATION = 7 | ||||
|     REALM_REACTIVATION = 8 | ||||
|     type = models.PositiveSmallIntegerField()  # type: int | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|     def __str__(self) -> Text: | ||||
|         return '<Confirmation: %s>' % (self.content_object,) | ||||
|  | ||||
| class ConfirmationType: | ||||
| @@ -124,18 +122,8 @@ _properties = { | ||||
|         'zerver.views.registration.accounts_home_from_multiuse_invite', | ||||
|         validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS), | ||||
|     Confirmation.REALM_CREATION: ConfirmationType('check_prereg_key_and_redirect'), | ||||
|     Confirmation.REALM_REACTIVATION: ConfirmationType('zerver.views.realm.realm_reactivation'), | ||||
| } | ||||
|  | ||||
| def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> str: | ||||
|     """ | ||||
|     Generate a unique link that a logged-out user can visit to unsubscribe from | ||||
|     Zulip e-mails without having to first log in. | ||||
|     """ | ||||
|     return create_confirmation_link(user_profile, user_profile.realm.host, | ||||
|                                     Confirmation.UNSUBSCRIBE, | ||||
|                                     url_args = {'email_type': email_type}) | ||||
|  | ||||
| # Functions related to links generated by the generate_realm_creation_link.py | ||||
| # management command. | ||||
| # Note that being validated here will just allow the user to access the create_realm | ||||
| @@ -157,7 +145,7 @@ def validate_key(creation_key: Optional[str]) -> Optional['RealmCreationKey']: | ||||
|         raise RealmCreationKey.Invalid() | ||||
|     return key_record | ||||
|  | ||||
| def generate_realm_creation_url(by_admin: bool=False) -> str: | ||||
| def generate_realm_creation_url(by_admin: bool=False) -> Text: | ||||
|     key = generate_key() | ||||
|     RealmCreationKey.objects.create(creation_key=key, | ||||
|                                     date_created=timezone_now(), | ||||
|   | ||||
| @@ -2,6 +2,8 @@ | ||||
|  | ||||
| # Copyright: (c) 2008, Jarek Zgoda <jarek.zgoda@gmail.com> | ||||
|  | ||||
| from typing import Any, Dict | ||||
|  | ||||
| __revision__ = '$Id: settings.py 12 2008-11-23 19:38:52Z jarek.zgoda $' | ||||
|  | ||||
| STATUS_ACTIVE = 1 | ||||
|   | ||||
| @@ -1,498 +0,0 @@ | ||||
| from datetime import datetime | ||||
| from decimal import Decimal | ||||
| from functools import wraps | ||||
| import logging | ||||
| import math | ||||
| import os | ||||
| from typing import Any, Callable, Dict, Optional, TypeVar, Tuple, cast | ||||
| import ujson | ||||
|  | ||||
| from django.conf import settings | ||||
| from django.db import transaction | ||||
| from django.utils.translation import ugettext as _ | ||||
| from django.utils.timezone import now as timezone_now | ||||
| from django.core.signing import Signer | ||||
| import stripe | ||||
|  | ||||
| from zerver.lib.logging_util import log_to_file | ||||
| from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime | ||||
| from zerver.lib.utils import generate_random_token | ||||
| from zerver.models import Realm, UserProfile, RealmAuditLog | ||||
| from corporate.models import Customer, CustomerPlan, LicenseLedger, \ | ||||
|     get_current_plan | ||||
| from zproject.settings 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('/var/log/zulip' | ||||
|                                 if not settings.DEVELOPMENT | ||||
|                                 else settings.DEVELOPMENT_LOG_DIRECTORY, | ||||
|                                 'billing.log') | ||||
| billing_logger = logging.getLogger('corporate.stripe') | ||||
| log_to_file(billing_logger, BILLING_LOG_PATH) | ||||
| log_to_file(logging.getLogger('stripe'), BILLING_LOG_PATH) | ||||
|  | ||||
| CallableT = TypeVar('CallableT', bound=Callable[..., Any]) | ||||
|  | ||||
| MIN_INVOICED_LICENSES = 30 | ||||
| DEFAULT_INVOICE_DAYS_UNTIL_DUE = 30 | ||||
|  | ||||
| def get_seat_count(realm: Realm) -> int: | ||||
|     non_guests = UserProfile.objects.filter( | ||||
|         realm=realm, is_active=True, is_bot=False).exclude(role=UserProfile.ROLE_GUEST).count() | ||||
|     guests = UserProfile.objects.filter( | ||||
|         realm=realm, is_active=True, is_bot=False, role=UserProfile.ROLE_GUEST).count() | ||||
|     return max(non_guests, math.ceil(guests / 5)) | ||||
|  | ||||
| def sign_string(string: str) -> Tuple[str, str]: | ||||
|     salt = generate_random_token(64) | ||||
|     signer = Signer(salt=salt) | ||||
|     return signer.sign(string), salt | ||||
|  | ||||
| def unsign_string(signed_string: str, salt: str) -> str: | ||||
|     signer = Signer(salt=salt) | ||||
|     return signer.unsign(signed_string) | ||||
|  | ||||
| # 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 | ||||
| # within a few seconds) for basically any value from when the billing system | ||||
| # went online to within a year from now. | ||||
| def add_months(dt: datetime, months: int) -> datetime: | ||||
|     assert(months >= 0) | ||||
|     # It's fine that the max day in Feb is 28 for leap years. | ||||
|     MAX_DAY_FOR_MONTH = {1: 31, 2: 28, 3: 31, 4: 30, 5: 31, 6: 30, | ||||
|                          7: 31, 8: 31, 9: 30, 10: 31, 11: 30, 12: 31} | ||||
|     year = dt.year | ||||
|     month = dt.month + months | ||||
|     while month > 12: | ||||
|         year += 1 | ||||
|         month -= 12 | ||||
|     day = min(dt.day, MAX_DAY_FOR_MONTH[month]) | ||||
|     # datetimes don't support leap seconds, so don't need to worry about those | ||||
|     return dt.replace(year=year, month=month, day=day) | ||||
|  | ||||
| def next_month(billing_cycle_anchor: datetime, dt: datetime) -> datetime: | ||||
|     estimated_months = round((dt - billing_cycle_anchor).days * 12. / 365) | ||||
|     for months in range(max(estimated_months - 1, 0), estimated_months + 2): | ||||
|         proposed_next_month = add_months(billing_cycle_anchor, months) | ||||
|         if 20 < (proposed_next_month - dt).days < 40: | ||||
|             return proposed_next_month | ||||
|     raise AssertionError('Something wrong in next_month calculation with ' | ||||
|                          'billing_cycle_anchor: %s, dt: %s' % (billing_cycle_anchor, dt)) | ||||
|  | ||||
| def start_of_next_billing_cycle(plan: CustomerPlan, event_time: datetime) -> datetime: | ||||
|     months_per_period = { | ||||
|         CustomerPlan.ANNUAL: 12, | ||||
|         CustomerPlan.MONTHLY: 1, | ||||
|     }[plan.billing_schedule] | ||||
|     periods = 1 | ||||
|     dt = plan.billing_cycle_anchor | ||||
|     while dt <= event_time: | ||||
|         dt = add_months(plan.billing_cycle_anchor, months_per_period * periods) | ||||
|         periods += 1 | ||||
|     return dt | ||||
|  | ||||
| def next_invoice_date(plan: CustomerPlan) -> Optional[datetime]: | ||||
|     if plan.status == CustomerPlan.ENDED: | ||||
|         return None | ||||
|     assert(plan.next_invoice_date is not None)  # for mypy | ||||
|     months_per_period = { | ||||
|         CustomerPlan.ANNUAL: 12, | ||||
|         CustomerPlan.MONTHLY: 1, | ||||
|     }[plan.billing_schedule] | ||||
|     if plan.automanage_licenses: | ||||
|         months_per_period = 1 | ||||
|     periods = 1 | ||||
|     dt = plan.billing_cycle_anchor | ||||
|     while dt <= plan.next_invoice_date: | ||||
|         dt = add_months(plan.billing_cycle_anchor, months_per_period * periods) | ||||
|         periods += 1 | ||||
|     return dt | ||||
|  | ||||
| def renewal_amount(plan: CustomerPlan, event_time: datetime) -> int:  # nocoverage: TODO | ||||
|     if plan.fixed_price is not None: | ||||
|         return plan.fixed_price | ||||
|     last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time) | ||||
|     if last_ledger_entry is None: | ||||
|         return 0 | ||||
|     if last_ledger_entry.licenses_at_next_renewal is None: | ||||
|         return 0 | ||||
|     assert(plan.price_per_license is not None)  # for mypy | ||||
|     return plan.price_per_license * last_ledger_entry.licenses_at_next_renewal | ||||
|  | ||||
| class BillingError(Exception): | ||||
|     # error messages | ||||
|     CONTACT_SUPPORT = _("Something went wrong. Please contact %s.") % (settings.ZULIP_ADMINISTRATOR,) | ||||
|     TRY_RELOADING = _("Something went wrong. Please reload the page.") | ||||
|  | ||||
|     # description is used only for tests | ||||
|     def __init__(self, description: str, message: str=CONTACT_SUPPORT) -> None: | ||||
|         self.description = description | ||||
|         self.message = message | ||||
|  | ||||
| class StripeCardError(BillingError): | ||||
|     pass | ||||
|  | ||||
| class StripeConnectionError(BillingError): | ||||
|     pass | ||||
|  | ||||
| def catch_stripe_errors(func: CallableT) -> CallableT: | ||||
|     @wraps(func) | ||||
|     def wrapped(*args: Any, **kwargs: Any) -> Any: | ||||
|         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 | ||||
|         # https://stripe.com/docs/api/ruby#error_handling suggests there are additional fields, and | ||||
|         # https://stripe.com/docs/error-codes gives a more detailed set of error codes | ||||
|         except stripe.error.StripeError as e: | ||||
|             err = e.json_body.get('error', {}) | ||||
|             billing_logger.error("Stripe error: %s %s %s %s" % ( | ||||
|                 e.http_status, err.get('type'), err.get('code'), err.get('param'))) | ||||
|             if isinstance(e, stripe.error.CardError): | ||||
|                 # TODO: Look into i18n for this | ||||
|                 raise StripeCardError('card error', err.get('message')) | ||||
|             if isinstance(e, stripe.error.RateLimitError) or \ | ||||
|                isinstance(e, stripe.error.APIConnectionError):  # nocoverage TODO | ||||
|                 raise StripeConnectionError( | ||||
|                     'stripe connection error', | ||||
|                     _("Something went wrong. Please wait a few seconds and try again.")) | ||||
|             raise BillingError('other stripe error', BillingError.CONTACT_SUPPORT) | ||||
|     return wrapped  # type: ignore # https://github.com/python/mypy/issues/1927 | ||||
|  | ||||
| @catch_stripe_errors | ||||
| def stripe_get_customer(stripe_customer_id: str) -> stripe.Customer: | ||||
|     return stripe.Customer.retrieve(stripe_customer_id, expand=["default_source"]) | ||||
|  | ||||
| @catch_stripe_errors | ||||
| 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 | ||||
|     # bad thing that will happen is that we will create an extra stripe | ||||
|     # customer that we can delete or ignore. | ||||
|     stripe_customer = stripe.Customer.create( | ||||
|         description="%s (%s)" % (realm.string_id, realm.name), | ||||
|         email=user.email, | ||||
|         metadata={'realm_id': realm.id, 'realm_str': realm.string_id}, | ||||
|         source=stripe_token) | ||||
|     event_time = timestamp_to_datetime(stripe_customer.created) | ||||
|     with transaction.atomic(): | ||||
|         RealmAuditLog.objects.create( | ||||
|             realm=user.realm, acting_user=user, event_type=RealmAuditLog.STRIPE_CUSTOMER_CREATED, | ||||
|             event_time=event_time) | ||||
|         if stripe_token is not None: | ||||
|             RealmAuditLog.objects.create( | ||||
|                 realm=user.realm, acting_user=user, event_type=RealmAuditLog.STRIPE_CARD_CHANGED, | ||||
|                 event_time=event_time) | ||||
|         customer, created = Customer.objects.update_or_create(realm=realm, defaults={ | ||||
|             'stripe_customer_id': stripe_customer.id}) | ||||
|         user.is_billing_admin = True | ||||
|         user.save(update_fields=["is_billing_admin"]) | ||||
|     return customer | ||||
|  | ||||
| @catch_stripe_errors | ||||
| def do_replace_payment_source(user: UserProfile, stripe_token: str, | ||||
|                               pay_invoices: bool=False) -> stripe.Customer: | ||||
|     stripe_customer = stripe_get_customer(Customer.objects.get(realm=user.realm).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, event_type=RealmAuditLog.STRIPE_CARD_CHANGED, | ||||
|         event_time=timezone_now()) | ||||
|     if pay_invoices: | ||||
|         for stripe_invoice in stripe.Invoice.list( | ||||
|                 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 explictly 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) | ||||
|     return updated_stripe_customer | ||||
|  | ||||
| # event_time should roughly be timezone_now(). Not designed to handle | ||||
| # event_times in the past or future | ||||
| def make_end_of_cycle_updates_if_needed(plan: CustomerPlan, | ||||
|                                         event_time: datetime) -> Optional[LicenseLedger]: | ||||
|     last_ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by('-id').first() | ||||
|     last_renewal = LicenseLedger.objects.filter(plan=plan, is_renewal=True) \ | ||||
|                                         .order_by('-id').first().event_time | ||||
|     next_billing_cycle = start_of_next_billing_cycle(plan, last_renewal) | ||||
|     if next_billing_cycle <= event_time: | ||||
|         if plan.status == CustomerPlan.ACTIVE: | ||||
|             return LicenseLedger.objects.create( | ||||
|                 plan=plan, is_renewal=True, event_time=next_billing_cycle, | ||||
|                 licenses=last_ledger_entry.licenses_at_next_renewal, | ||||
|                 licenses_at_next_renewal=last_ledger_entry.licenses_at_next_renewal) | ||||
|         if plan.status == CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE: | ||||
|             process_downgrade(plan) | ||||
|         return None | ||||
|     return last_ledger_entry | ||||
|  | ||||
| # Returns Customer instead of stripe_customer so that we don't make a Stripe | ||||
| # API call if there's nothing to update | ||||
| def update_or_create_stripe_customer(user: UserProfile, stripe_token: Optional[str]=None) -> Customer: | ||||
|     realm = user.realm | ||||
|     customer = Customer.objects.filter(realm=realm).first() | ||||
|     if customer is None or customer.stripe_customer_id is None: | ||||
|         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 | ||||
|  | ||||
| def compute_plan_parameters( | ||||
|         automanage_licenses: bool, billing_schedule: int, | ||||
|         discount: Optional[Decimal]) -> 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 leapseconds? | ||||
|     billing_cycle_anchor = timezone_now().replace(microsecond=0) | ||||
|     if billing_schedule == CustomerPlan.ANNUAL: | ||||
|         # TODO use variables to account for Zulip Plus | ||||
|         price_per_license = 8000 | ||||
|         period_end = add_months(billing_cycle_anchor, 12) | ||||
|     elif billing_schedule == CustomerPlan.MONTHLY: | ||||
|         price_per_license = 800 | ||||
|         period_end = add_months(billing_cycle_anchor, 1) | ||||
|     else: | ||||
|         raise AssertionError('Unknown billing_schedule: {}'.format(billing_schedule)) | ||||
|     if discount is not None: | ||||
|         # There are no fractional cents in Stripe, so round down to nearest integer. | ||||
|         price_per_license = int(float(price_per_license * (1 - discount / 100)) + .00001) | ||||
|     next_invoice_date = period_end | ||||
|     if automanage_licenses: | ||||
|         next_invoice_date = add_months(billing_cycle_anchor, 1) | ||||
|     return billing_cycle_anchor, next_invoice_date, period_end, price_per_license | ||||
|  | ||||
| # Only used for cloud signups | ||||
| @catch_stripe_errors | ||||
| def process_initial_upgrade(user: UserProfile, licenses: int, automanage_licenses: bool, | ||||
|                             billing_schedule: int, stripe_token: Optional[str]) -> None: | ||||
|     realm = user.realm | ||||
|     customer = update_or_create_stripe_customer(user, stripe_token=stripe_token) | ||||
|     if get_current_plan(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 {} trying to upgrade, but has an active subscription".format(customer)) | ||||
|         raise BillingError('subscribing with existing subscription', BillingError.TRY_RELOADING) | ||||
|  | ||||
|     billing_cycle_anchor, next_invoice_date, period_end, price_per_license = compute_plan_parameters( | ||||
|         automanage_licenses, billing_schedule, customer.default_discount) | ||||
|     # 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. | ||||
|     charge_automatically = stripe_token is not None | ||||
|     if charge_automatically: | ||||
|         stripe_charge = stripe.Charge.create( | ||||
|             amount=price_per_license * licenses, | ||||
|             currency='usd', | ||||
|             customer=customer.stripe_customer_id, | ||||
|             description="Upgrade to Zulip Standard, ${} x {}".format(price_per_license/100, licenses), | ||||
|             receipt_email=user.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. | ||||
|         stripe.InvoiceItem.create( | ||||
|             amount=price_per_license * licenses * -1, | ||||
|             currency='usd', | ||||
|             customer=customer.stripe_customer_id, | ||||
|             description="Payment (Card ending in {})".format(cast(stripe.Card, stripe_charge.source).last4), | ||||
|             discountable=False) | ||||
|  | ||||
|     # TODO: The correctness of this relies on user creation, deactivation, etc being | ||||
|     # in a transaction.atomic() with the relevant RealmAuditLog entries | ||||
|     with transaction.atomic(): | ||||
|         # billed_licenses can greater than licenses if users are added between the start of | ||||
|         # this function (process_initial_upgrade) and now | ||||
|         billed_licenses = max(get_seat_count(realm), licenses) | ||||
|         plan_params = { | ||||
|             'automanage_licenses': automanage_licenses, | ||||
|             'charge_automatically': charge_automatically, | ||||
|             'price_per_license': price_per_license, | ||||
|             'discount': customer.default_discount, | ||||
|             'billing_cycle_anchor': billing_cycle_anchor, | ||||
|             'billing_schedule': billing_schedule, | ||||
|             'tier': CustomerPlan.STANDARD} | ||||
|         plan = CustomerPlan.objects.create( | ||||
|             customer=customer, | ||||
|             next_invoice_date=next_invoice_date, | ||||
|             **plan_params) | ||||
|         ledger_entry = LicenseLedger.objects.create( | ||||
|             plan=plan, | ||||
|             is_renewal=True, | ||||
|             event_time=billing_cycle_anchor, | ||||
|             licenses=billed_licenses, | ||||
|             licenses_at_next_renewal=billed_licenses) | ||||
|         plan.invoiced_through = ledger_entry | ||||
|         plan.save(update_fields=['invoiced_through']) | ||||
|         RealmAuditLog.objects.create( | ||||
|             realm=realm, acting_user=user, event_time=billing_cycle_anchor, | ||||
|             event_type=RealmAuditLog.CUSTOMER_PLAN_CREATED, | ||||
|             extra_data=ujson.dumps(plan_params)) | ||||
|     stripe.InvoiceItem.create( | ||||
|         currency='usd', | ||||
|         customer=customer.stripe_customer_id, | ||||
|         description='Zulip Standard', | ||||
|         discountable=False, | ||||
|         period = {'start': datetime_to_timestamp(billing_cycle_anchor), | ||||
|                   'end': datetime_to_timestamp(period_end)}, | ||||
|         quantity=billed_licenses, | ||||
|         unit_amount=price_per_license) | ||||
|  | ||||
|     if charge_automatically: | ||||
|         billing_method = 'charge_automatically' | ||||
|         days_until_due = None | ||||
|     else: | ||||
|         billing_method = 'send_invoice' | ||||
|         days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE | ||||
|     stripe_invoice = stripe.Invoice.create( | ||||
|         auto_advance=True, | ||||
|         billing=billing_method, | ||||
|         customer=customer.stripe_customer_id, | ||||
|         days_until_due=days_until_due, | ||||
|         statement_descriptor='Zulip Standard') | ||||
|     stripe.Invoice.finalize_invoice(stripe_invoice) | ||||
|  | ||||
|     from zerver.lib.actions import do_change_plan_type | ||||
|     do_change_plan_type(realm, Realm.STANDARD) | ||||
|  | ||||
| def update_license_ledger_for_automanaged_plan(realm: Realm, plan: CustomerPlan, | ||||
|                                                event_time: datetime) -> None: | ||||
|     last_ledger_entry = make_end_of_cycle_updates_if_needed(plan, event_time) | ||||
|     if last_ledger_entry is None: | ||||
|         return | ||||
|     licenses_at_next_renewal = get_seat_count(realm) | ||||
|     licenses = max(licenses_at_next_renewal, last_ledger_entry.licenses) | ||||
|     LicenseLedger.objects.create( | ||||
|         plan=plan, event_time=event_time, licenses=licenses, | ||||
|         licenses_at_next_renewal=licenses_at_next_renewal) | ||||
|  | ||||
| def update_license_ledger_if_needed(realm: Realm, event_time: datetime) -> None: | ||||
|     customer = Customer.objects.filter(realm=realm).first() | ||||
|     if customer is None: | ||||
|         return | ||||
|     plan = get_current_plan(customer) | ||||
|     if plan is None: | ||||
|         return | ||||
|     if not plan.automanage_licenses: | ||||
|         return | ||||
|     update_license_ledger_for_automanaged_plan(realm, plan, event_time) | ||||
|  | ||||
| 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.') | ||||
|     make_end_of_cycle_updates_if_needed(plan, event_time) | ||||
|     assert(plan.invoiced_through is not None) | ||||
|     licenses_base = plan.invoiced_through.licenses | ||||
|     invoice_item_created = False | ||||
|     for ledger_entry in LicenseLedger.objects.filter(plan=plan, id__gt=plan.invoiced_through.id, | ||||
|                                                      event_time__lte=event_time).order_by('id'): | ||||
|         price_args = {}  # type: Dict[str, int] | ||||
|         if ledger_entry.is_renewal: | ||||
|             if plan.fixed_price is not None: | ||||
|                 price_args = {'amount': plan.fixed_price} | ||||
|             else: | ||||
|                 assert(plan.price_per_license is not None)  # needed for mypy | ||||
|                 price_args = {'unit_amount': plan.price_per_license, | ||||
|                               'quantity': ledger_entry.licenses} | ||||
|             description = "Zulip Standard - renewal" | ||||
|         elif ledger_entry.licenses != licenses_base: | ||||
|             assert(plan.price_per_license) | ||||
|             last_renewal = LicenseLedger.objects.filter( | ||||
|                 plan=plan, is_renewal=True, event_time__lte=ledger_entry.event_time) \ | ||||
|                 .order_by('-id').first().event_time | ||||
|             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 + .5), | ||||
|                           'quantity': ledger_entry.licenses - licenses_base} | ||||
|             description = "Additional license ({} - {})".format( | ||||
|                 ledger_entry.event_time.strftime('%b %-d, %Y'), period_end.strftime('%b %-d, %Y')) | ||||
|  | ||||
|         if price_args: | ||||
|             plan.invoiced_through = ledger_entry | ||||
|             plan.invoicing_status = CustomerPlan.STARTED | ||||
|             plan.save(update_fields=['invoicing_status', 'invoiced_through']) | ||||
|             idempotency_key = 'ledger_entry:{}'.format(ledger_entry.id)  # type: Optional[str] | ||||
|             if settings.TEST_SUITE: | ||||
|                 idempotency_key = None | ||||
|             stripe.InvoiceItem.create( | ||||
|                 currency='usd', | ||||
|                 customer=plan.customer.stripe_customer_id, | ||||
|                 description=description, | ||||
|                 discountable=False, | ||||
|                 period = {'start': datetime_to_timestamp(ledger_entry.event_time), | ||||
|                           'end': datetime_to_timestamp( | ||||
|                               start_of_next_billing_cycle(plan, ledger_entry.event_time))}, | ||||
|                 idempotency_key=idempotency_key, | ||||
|                 **price_args) | ||||
|             invoice_item_created = True | ||||
|         plan.invoiced_through = ledger_entry | ||||
|         plan.invoicing_status = CustomerPlan.DONE | ||||
|         plan.save(update_fields=['invoicing_status', 'invoiced_through']) | ||||
|         licenses_base = ledger_entry.licenses | ||||
|  | ||||
|     if invoice_item_created: | ||||
|         if plan.charge_automatically: | ||||
|             billing_method = 'charge_automatically' | ||||
|             days_until_due = None | ||||
|         else: | ||||
|             billing_method = 'send_invoice' | ||||
|             days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE | ||||
|         stripe_invoice = stripe.Invoice.create( | ||||
|             auto_advance=True, | ||||
|             billing=billing_method, | ||||
|             customer=plan.customer.stripe_customer_id, | ||||
|             days_until_due=days_until_due, | ||||
|             statement_descriptor='Zulip Standard') | ||||
|         stripe.Invoice.finalize_invoice(stripe_invoice) | ||||
|  | ||||
|     plan.next_invoice_date = next_invoice_date(plan) | ||||
|     plan.save(update_fields=['next_invoice_date']) | ||||
|  | ||||
| def invoice_plans_as_needed(event_time: datetime=timezone_now()) -> None: | ||||
|     for plan in CustomerPlan.objects.filter(next_invoice_date__lte=event_time): | ||||
|         invoice_plan(plan, event_time) | ||||
|  | ||||
| def attach_discount_to_realm(realm: Realm, discount: Decimal) -> None: | ||||
|     Customer.objects.update_or_create(realm=realm, defaults={'default_discount': discount}) | ||||
|  | ||||
| def get_discount_for_realm(realm: Realm) -> Optional[Decimal]: | ||||
|     customer = Customer.objects.filter(realm=realm).first() | ||||
|     if customer is not None: | ||||
|         return customer.default_discount | ||||
|     return None | ||||
|  | ||||
| def do_change_plan_status(plan: CustomerPlan, status: int) -> None: | ||||
|     plan.status = status | ||||
|     plan.save(update_fields=['status']) | ||||
|     billing_logger.info('Change plan status: Customer.id: %s, CustomerPlan.id: %s, status: %s' % ( | ||||
|         plan.customer.id, plan.id, status)) | ||||
|  | ||||
| def process_downgrade(plan: CustomerPlan) -> None: | ||||
|     from zerver.lib.actions import do_change_plan_type | ||||
|     do_change_plan_type(plan.customer.realm, Realm.LIMITED) | ||||
|     plan.status = CustomerPlan.ENDED | ||||
|     plan.save(update_fields=['status']) | ||||
|  | ||||
| def estimate_annual_recurring_revenue_by_realm() -> Dict[str, int]:  # nocoverage | ||||
|     annual_revenue = {} | ||||
|     for plan in CustomerPlan.objects.filter( | ||||
|             status=CustomerPlan.ACTIVE).select_related('customer__realm'): | ||||
|         # TODO: figure out what to do for plans that don't automatically | ||||
|         # renew, but which probably will renew | ||||
|         renewal_cents = renewal_amount(plan, timezone_now()) | ||||
|         if plan.billing_schedule == CustomerPlan.MONTHLY: | ||||
|             renewal_cents *= 12 | ||||
|         # TODO: Decimal stuff | ||||
|         annual_revenue[plan.customer.realm.string_id] = int(renewal_cents / 100) | ||||
|     return annual_revenue | ||||
| @@ -1,53 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.14 on 2018-09-25 12:02 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     initial = True | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('zerver', '0189_userprofile_add_some_emojisets'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='BillingProcessor', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('state', models.CharField(max_length=20)), | ||||
|                 ('last_modified', models.DateTimeField(auto_now=True)), | ||||
|                 ('log_row', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='zerver.RealmAuditLog')), | ||||
|                 ('realm', models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm')), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='Coupon', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('percent_off', models.SmallIntegerField(unique=True)), | ||||
|                 ('stripe_coupon_id', models.CharField(max_length=255, unique=True)), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='Customer', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('stripe_customer_id', models.CharField(max_length=255, unique=True)), | ||||
|                 ('has_billing_relationship', models.BooleanField(default=False)), | ||||
|                 ('realm', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to='zerver.Realm')), | ||||
|             ], | ||||
|         ), | ||||
|         migrations.CreateModel( | ||||
|             name='Plan', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('nickname', models.CharField(max_length=40, unique=True)), | ||||
|                 ('stripe_plan_id', models.CharField(max_length=255, unique=True)), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,20 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.16 on 2018-12-12 20:19 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('corporate', '0001_initial'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AddField( | ||||
|             model_name='customer', | ||||
|             name='default_discount', | ||||
|             field=models.DecimalField(decimal_places=4, max_digits=7, null=True), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,35 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.16 on 2018-12-22 21:05 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('corporate', '0002_customer_default_discount'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='CustomerPlan', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('licenses', models.IntegerField()), | ||||
|                 ('automanage_licenses', models.BooleanField(default=False)), | ||||
|                 ('charge_automatically', models.BooleanField(default=False)), | ||||
|                 ('price_per_license', models.IntegerField(null=True)), | ||||
|                 ('fixed_price', models.IntegerField(null=True)), | ||||
|                 ('discount', models.DecimalField(decimal_places=4, max_digits=6, null=True)), | ||||
|                 ('billing_cycle_anchor', models.DateTimeField()), | ||||
|                 ('billing_schedule', models.SmallIntegerField()), | ||||
|                 ('billed_through', models.DateTimeField()), | ||||
|                 ('next_billing_date', models.DateTimeField(db_index=True)), | ||||
|                 ('tier', models.SmallIntegerField()), | ||||
|                 ('status', models.SmallIntegerField(default=1)), | ||||
|                 ('customer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='corporate.Customer')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,27 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.18 on 2019-01-19 05:01 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('corporate', '0003_customerplan'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.CreateModel( | ||||
|             name='LicenseLedger', | ||||
|             fields=[ | ||||
|                 ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), | ||||
|                 ('is_renewal', models.BooleanField(default=False)), | ||||
|                 ('event_time', models.DateTimeField()), | ||||
|                 ('licenses', models.IntegerField()), | ||||
|                 ('licenses_at_next_renewal', models.IntegerField(null=True)), | ||||
|                 ('plan', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='corporate.CustomerPlan')), | ||||
|             ], | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,35 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.18 on 2019-01-28 13:04 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
| import django.db.models.deletion | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('corporate', '0004_licenseledger'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RenameField( | ||||
|             model_name='customerplan', | ||||
|             old_name='next_billing_date', | ||||
|             new_name='next_invoice_date', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='customerplan', | ||||
|             name='billed_through', | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='customerplan', | ||||
|             name='invoiced_through', | ||||
|             field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='corporate.LicenseLedger'), | ||||
|         ), | ||||
|         migrations.AddField( | ||||
|             model_name='customerplan', | ||||
|             name='invoicing_status', | ||||
|             field=models.SmallIntegerField(default=1), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,20 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.18 on 2019-01-29 01:46 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('corporate', '0005_customerplan_invoicing'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='customer', | ||||
|             name='stripe_customer_id', | ||||
|             field=models.CharField(max_length=255, null=True, unique=True), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,40 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.18 on 2019-01-31 22:16 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('corporate', '0006_nullable_stripe_customer_id'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.RemoveField( | ||||
|             model_name='billingprocessor', | ||||
|             name='log_row', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='billingprocessor', | ||||
|             name='realm', | ||||
|         ), | ||||
|         migrations.DeleteModel( | ||||
|             name='Coupon', | ||||
|         ), | ||||
|         migrations.DeleteModel( | ||||
|             name='Plan', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='customer', | ||||
|             name='has_billing_relationship', | ||||
|         ), | ||||
|         migrations.RemoveField( | ||||
|             model_name='customerplan', | ||||
|             name='licenses', | ||||
|         ), | ||||
|         migrations.DeleteModel( | ||||
|             name='BillingProcessor', | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,20 +0,0 @@ | ||||
| # -*- coding: utf-8 -*- | ||||
| # Generated by Django 1.11.20 on 2019-04-11 00:45 | ||||
| from __future__ import unicode_literals | ||||
|  | ||||
| from django.db import migrations, models | ||||
|  | ||||
|  | ||||
| class Migration(migrations.Migration): | ||||
|  | ||||
|     dependencies = [ | ||||
|         ('corporate', '0007_remove_deprecated_fields'), | ||||
|     ] | ||||
|  | ||||
|     operations = [ | ||||
|         migrations.AlterField( | ||||
|             model_name='customerplan', | ||||
|             name='next_invoice_date', | ||||
|             field=models.DateTimeField(db_index=True, null=True), | ||||
|         ), | ||||
|     ] | ||||
| @@ -1,73 +0,0 @@ | ||||
| import datetime | ||||
| from decimal import Decimal | ||||
| from typing import Optional | ||||
|  | ||||
| from django.db import models | ||||
| from django.db.models import CASCADE | ||||
|  | ||||
| from zerver.models import Realm | ||||
|  | ||||
| class Customer(models.Model): | ||||
|     realm = models.OneToOneField(Realm, on_delete=CASCADE)  # type: Realm | ||||
|     stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)  # type: str | ||||
|     # A percentage, like 85. | ||||
|     default_discount = models.DecimalField(decimal_places=4, max_digits=7, null=True)  # type: Optional[Decimal] | ||||
|  | ||||
|     def __str__(self) -> str: | ||||
|         return "<Customer %s %s>" % (self.realm, self.stripe_customer_id) | ||||
|  | ||||
| class CustomerPlan(models.Model): | ||||
|     customer = models.ForeignKey(Customer, on_delete=CASCADE)  # type: Customer | ||||
|     automanage_licenses = models.BooleanField(default=False)  # type: bool | ||||
|     charge_automatically = models.BooleanField(default=False)  # type: bool | ||||
|  | ||||
|     # Both of these are in cents. Exactly one of price_per_license or | ||||
|     # fixed_price should be set. fixed_price is only for manual deals, and | ||||
|     # can't be set via the self-serve billing system. | ||||
|     price_per_license = models.IntegerField(null=True)  # type: Optional[int] | ||||
|     fixed_price = models.IntegerField(null=True)  # type: Optional[int] | ||||
|  | ||||
|     # Discount that was applied. For display purposes only. | ||||
|     discount = models.DecimalField(decimal_places=4, max_digits=6, null=True)  # type: Optional[Decimal] | ||||
|  | ||||
|     billing_cycle_anchor = models.DateTimeField()  # type: datetime.datetime | ||||
|     ANNUAL = 1 | ||||
|     MONTHLY = 2 | ||||
|     billing_schedule = models.SmallIntegerField()  # type: int | ||||
|  | ||||
|     next_invoice_date = models.DateTimeField(db_index=True, null=True)  # type: Optional[datetime.datetime] | ||||
|     invoiced_through = models.ForeignKey( | ||||
|         'LicenseLedger', null=True, on_delete=CASCADE, related_name='+')  # type: Optional[LicenseLedger] | ||||
|     DONE = 1 | ||||
|     STARTED = 2 | ||||
|     invoicing_status = models.SmallIntegerField(default=DONE)  # type: int | ||||
|  | ||||
|     STANDARD = 1 | ||||
|     PLUS = 2  # not available through self-serve signup | ||||
|     ENTERPRISE = 10 | ||||
|     tier = models.SmallIntegerField()  # type: int | ||||
|  | ||||
|     ACTIVE = 1 | ||||
|     DOWNGRADE_AT_END_OF_CYCLE = 2 | ||||
|     # "Live" plans should have a value < LIVE_STATUS_THRESHOLD. | ||||
|     # There should be at most one live plan per customer. | ||||
|     LIVE_STATUS_THRESHOLD = 10 | ||||
|     ENDED = 11 | ||||
|     NEVER_STARTED = 12 | ||||
|     status = models.SmallIntegerField(default=ACTIVE)  # type: int | ||||
|  | ||||
|     # TODO maybe override setattr to ensure billing_cycle_anchor, etc are immutable | ||||
|  | ||||
| def get_current_plan(customer: Customer) -> Optional[CustomerPlan]: | ||||
|     return CustomerPlan.objects.filter( | ||||
|         customer=customer, status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD).first() | ||||
|  | ||||
| class LicenseLedger(models.Model): | ||||
|     plan = models.ForeignKey(CustomerPlan, on_delete=CASCADE)  # type: CustomerPlan | ||||
|     # Also True for the initial upgrade. | ||||
|     is_renewal = models.BooleanField(default=False)  # type: bool | ||||
|     event_time = models.DateTimeField()  # type: datetime.datetime | ||||
|     licenses = models.IntegerField()  # type: int | ||||
|     # None means the plan does not automatically renew. | ||||
|     # This cannot be None if plan.automanage_licenses. | ||||
|     licenses_at_next_renewal = models.IntegerField(null=True)  # type: Optional[int] | ||||
| @@ -1,112 +0,0 @@ | ||||
| { | ||||
|   "amount": 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 | ||||
|   }, | ||||
|   "captured": true, | ||||
|   "created": 1000000000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "description": "Upgrade to Zulip Standard, $12.0 x 6", | ||||
|   "destination": null, | ||||
|   "dispute": null, | ||||
|   "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": 00, | ||||
|     "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", | ||||
|       "last4": "4242", | ||||
|       "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": "Zulip Standard", | ||||
|   "status": "succeeded", | ||||
|   "transfer_data": null, | ||||
|   "transfer_group": null | ||||
| } | ||||
| @@ -1,112 +0,0 @@ | ||||
| { | ||||
|   "amount": 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 | ||||
|   }, | ||||
|   "captured": true, | ||||
|   "created": 1000000000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "description": "Upgrade to Zulip Standard, $60.0 x 6", | ||||
|   "destination": null, | ||||
|   "dispute": null, | ||||
|   "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": 00, | ||||
|     "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", | ||||
|       "last4": "4242", | ||||
|       "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": "Zulip Standard", | ||||
|   "status": "succeeded", | ||||
|   "transfer_data": null, | ||||
|   "transfer_group": null | ||||
| } | ||||
| @@ -1,113 +0,0 @@ | ||||
| { | ||||
|   "data": [ | ||||
|     { | ||||
|       "amount": 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 | ||||
|       }, | ||||
|       "captured": true, | ||||
|       "created": 1000000000, | ||||
|       "currency": "usd", | ||||
|       "customer": "cus_NORMALIZED0001", | ||||
|       "description": "Upgrade to Zulip Standard, $12.0 x 6", | ||||
|       "destination": null, | ||||
|       "dispute": null, | ||||
|       "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": 00, | ||||
|         "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", | ||||
|           "last4": "4242", | ||||
|           "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": {}, | ||||
|       "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": "Zulip Standard", | ||||
|       "status": "succeeded", | ||||
|       "transfer_data": null, | ||||
|       "transfer_group": null | ||||
|     } | ||||
|   ], | ||||
|   "has_more": false, | ||||
|   "object": "list", | ||||
|   "url": "/v1/charges" | ||||
| } | ||||
| @@ -1,219 +0,0 @@ | ||||
| { | ||||
|   "data": [ | ||||
|     { | ||||
|       "amount": 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 | ||||
|       }, | ||||
|       "captured": true, | ||||
|       "created": 1000000000, | ||||
|       "currency": "usd", | ||||
|       "customer": "cus_NORMALIZED0001", | ||||
|       "description": "Upgrade to Zulip Standard, $60.0 x 6", | ||||
|       "destination": null, | ||||
|       "dispute": null, | ||||
|       "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": 00, | ||||
|         "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", | ||||
|           "last4": "4242", | ||||
|           "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": {}, | ||||
|       "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": "Zulip Standard", | ||||
|       "status": "succeeded", | ||||
|       "transfer_data": null, | ||||
|       "transfer_group": null | ||||
|     }, | ||||
|     { | ||||
|       "amount": 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 | ||||
|       }, | ||||
|       "captured": true, | ||||
|       "created": 1000000000, | ||||
|       "currency": "usd", | ||||
|       "customer": "cus_NORMALIZED0001", | ||||
|       "description": "Upgrade to Zulip Standard, $12.0 x 6", | ||||
|       "destination": null, | ||||
|       "dispute": null, | ||||
|       "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": 00, | ||||
|         "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", | ||||
|           "last4": "4242", | ||||
|           "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": {}, | ||||
|       "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": "Zulip Standard", | ||||
|       "status": "succeeded", | ||||
|       "transfer_data": null, | ||||
|       "transfer_group": null | ||||
|     } | ||||
|   ], | ||||
|   "has_more": false, | ||||
|   "object": "list", | ||||
|   "url": "/v1/charges" | ||||
| } | ||||
| @@ -1,79 +0,0 @@ | ||||
| { | ||||
|   "account_balance": 0, | ||||
|   "address": null, | ||||
|   "balance": 0, | ||||
|   "created": 1000000000, | ||||
|   "currency": null, | ||||
|   "default_source": "card_NORMALIZED00000000000001", | ||||
|   "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, | ||||
|   "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_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,103 +0,0 @@ | ||||
| { | ||||
|   "account_balance": 0, | ||||
|   "address": null, | ||||
|   "balance": 0, | ||||
|   "created": 1000000000, | ||||
|   "currency": "usd", | ||||
|   "default_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 | ||||
|   }, | ||||
|   "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, | ||||
|   "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_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,79 +0,0 @@ | ||||
| { | ||||
|   "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, | ||||
|   "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,117 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 0, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 0, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": null, | ||||
|   "ending_balance": null, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": null, | ||||
|   "id": "in_NORMALIZED00000000000001", | ||||
|   "invoice_pdf": null, | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 7200, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000001", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 6, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": -7200, | ||||
|         "currency": "usd", | ||||
|         "description": "Payment (Card ending in 4242)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000002", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "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": 1000000000, | ||||
|   "number": "NORMALI-0001", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "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": "draft", | ||||
|   "status_transitions": { | ||||
|     "finalized_at": null, | ||||
|     "marked_uncollectible_at": null, | ||||
|     "paid_at": null, | ||||
|     "voided_at": null | ||||
|   }, | ||||
|   "subscription": null, | ||||
|   "subtotal": 0, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 0, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": null | ||||
| } | ||||
| @@ -1,117 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 0, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 0, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": null, | ||||
|   "ending_balance": null, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": null, | ||||
|   "id": "in_NORMALIZED00000000000002", | ||||
|   "invoice_pdf": null, | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 36000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000003", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000003", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 6, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": -36000, | ||||
|         "currency": "usd", | ||||
|         "description": "Payment (Card ending in 4242)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000004", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000004", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "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": "NORMALI-0002", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "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": "draft", | ||||
|   "status_transitions": { | ||||
|     "finalized_at": null, | ||||
|     "marked_uncollectible_at": null, | ||||
|     "paid_at": null, | ||||
|     "voided_at": null | ||||
|   }, | ||||
|   "subscription": null, | ||||
|   "subtotal": 0, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 0, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": null | ||||
| } | ||||
| @@ -1,117 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 0, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 0, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": 0, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001", | ||||
|   "id": "in_NORMALIZED00000000000001", | ||||
|   "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf", | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 7200, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000001", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 6, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": -7200, | ||||
|         "currency": "usd", | ||||
|         "description": "Payment (Card ending in 4242)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000002", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "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", | ||||
|   "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_tax_amounts": [], | ||||
|   "webhooks_delivered_at": 1000000000 | ||||
| } | ||||
| @@ -1,117 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 0, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 0, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": 0, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002", | ||||
|   "id": "in_NORMALIZED00000000000002", | ||||
|   "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf", | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 36000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000003", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000003", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 6, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": -36000, | ||||
|         "currency": "usd", | ||||
|         "description": "Payment (Card ending in 4242)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000004", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000004", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "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", | ||||
|   "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_tax_amounts": [], | ||||
|   "webhooks_delivered_at": 1000000000 | ||||
| } | ||||
| @@ -1,124 +0,0 @@ | ||||
| { | ||||
|   "data": [ | ||||
|     { | ||||
|       "account_country": "US", | ||||
|       "account_name": "Dev account", | ||||
|       "amount_due": 0, | ||||
|       "amount_paid": 0, | ||||
|       "amount_remaining": 0, | ||||
|       "application_fee_amount": 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": [], | ||||
|       "default_payment_method": null, | ||||
|       "default_source": null, | ||||
|       "default_tax_rates": [], | ||||
|       "description": "", | ||||
|       "discount": null, | ||||
|       "due_date": 1000000000, | ||||
|       "ending_balance": 0, | ||||
|       "footer": null, | ||||
|       "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001", | ||||
|       "id": "in_NORMALIZED00000000000001", | ||||
|       "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf", | ||||
|       "lines": { | ||||
|         "data": [ | ||||
|           { | ||||
|             "amount": 7200, | ||||
|             "currency": "usd", | ||||
|             "description": "Zulip Standard", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000001", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1000000000, | ||||
|               "start": 1000000000 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 6, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           }, | ||||
|           { | ||||
|             "amount": -7200, | ||||
|             "currency": "usd", | ||||
|             "description": "Payment (Card ending in 4242)", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000002", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1000000000, | ||||
|               "start": 1000000000 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "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", | ||||
|       "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_tax_amounts": [], | ||||
|       "webhooks_delivered_at": 1000000000 | ||||
|     } | ||||
|   ], | ||||
|   "has_more": false, | ||||
|   "object": "list", | ||||
|   "url": "/v1/invoices" | ||||
| } | ||||
| @@ -1,241 +0,0 @@ | ||||
| { | ||||
|   "data": [ | ||||
|     { | ||||
|       "account_country": "US", | ||||
|       "account_name": "Dev account", | ||||
|       "amount_due": 0, | ||||
|       "amount_paid": 0, | ||||
|       "amount_remaining": 0, | ||||
|       "application_fee_amount": 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": [], | ||||
|       "default_payment_method": null, | ||||
|       "default_source": null, | ||||
|       "default_tax_rates": [], | ||||
|       "description": "", | ||||
|       "discount": null, | ||||
|       "due_date": 1000000000, | ||||
|       "ending_balance": 0, | ||||
|       "footer": null, | ||||
|       "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002", | ||||
|       "id": "in_NORMALIZED00000000000002", | ||||
|       "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf", | ||||
|       "lines": { | ||||
|         "data": [ | ||||
|           { | ||||
|             "amount": 36000, | ||||
|             "currency": "usd", | ||||
|             "description": "Zulip Standard", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000003", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000003", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1000000000, | ||||
|               "start": 1000000000 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 6, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           }, | ||||
|           { | ||||
|             "amount": -36000, | ||||
|             "currency": "usd", | ||||
|             "description": "Payment (Card ending in 4242)", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000004", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000004", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1000000000, | ||||
|               "start": 1000000000 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "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", | ||||
|       "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_tax_amounts": [], | ||||
|       "webhooks_delivered_at": 1000000000 | ||||
|     }, | ||||
|     { | ||||
|       "account_country": "US", | ||||
|       "account_name": "Dev account", | ||||
|       "amount_due": 0, | ||||
|       "amount_paid": 0, | ||||
|       "amount_remaining": 0, | ||||
|       "application_fee_amount": 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": [], | ||||
|       "default_payment_method": null, | ||||
|       "default_source": null, | ||||
|       "default_tax_rates": [], | ||||
|       "description": "", | ||||
|       "discount": null, | ||||
|       "due_date": 1000000000, | ||||
|       "ending_balance": 0, | ||||
|       "footer": null, | ||||
|       "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001", | ||||
|       "id": "in_NORMALIZED00000000000001", | ||||
|       "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf", | ||||
|       "lines": { | ||||
|         "data": [ | ||||
|           { | ||||
|             "amount": 7200, | ||||
|             "currency": "usd", | ||||
|             "description": "Zulip Standard", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000001", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1000000000, | ||||
|               "start": 1000000000 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 6, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           }, | ||||
|           { | ||||
|             "amount": -7200, | ||||
|             "currency": "usd", | ||||
|             "description": "Payment (Card ending in 4242)", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000002", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1000000000, | ||||
|               "start": 1000000000 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "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", | ||||
|       "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_tax_amounts": [], | ||||
|       "webhooks_delivered_at": 1000000000 | ||||
|     } | ||||
|   ], | ||||
|   "has_more": false, | ||||
|   "object": "list", | ||||
|   "url": "/v1/invoices" | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "amount": -7200, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "date": 1000000000, | ||||
|   "description": "Payment (Card ending in 4242)", | ||||
|   "discountable": false, | ||||
|   "id": "ii_NORMALIZED00000000000002", | ||||
|   "invoice": null, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "object": "invoiceitem", | ||||
|   "period": { | ||||
|     "end": 1000000000, | ||||
|     "start": 1000000000 | ||||
|   }, | ||||
|   "plan": null, | ||||
|   "proration": false, | ||||
|   "quantity": 1, | ||||
|   "subscription": null, | ||||
|   "tax_rates": [], | ||||
|   "unit_amount": -7200 | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "amount": 7200, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "date": 1000000000, | ||||
|   "description": "Zulip Standard", | ||||
|   "discountable": false, | ||||
|   "id": "ii_NORMALIZED00000000000001", | ||||
|   "invoice": null, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "object": "invoiceitem", | ||||
|   "period": { | ||||
|     "end": 1000000000, | ||||
|     "start": 1000000000 | ||||
|   }, | ||||
|   "plan": null, | ||||
|   "proration": false, | ||||
|   "quantity": 6, | ||||
|   "subscription": null, | ||||
|   "tax_rates": [], | ||||
|   "unit_amount": 1200 | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "amount": -36000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "date": 1000000000, | ||||
|   "description": "Payment (Card ending in 4242)", | ||||
|   "discountable": false, | ||||
|   "id": "ii_NORMALIZED00000000000004", | ||||
|   "invoice": null, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "object": "invoiceitem", | ||||
|   "period": { | ||||
|     "end": 1000000000, | ||||
|     "start": 1000000000 | ||||
|   }, | ||||
|   "plan": null, | ||||
|   "proration": false, | ||||
|   "quantity": 1, | ||||
|   "subscription": null, | ||||
|   "tax_rates": [], | ||||
|   "unit_amount": -36000 | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "amount": 36000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "date": 1000000000, | ||||
|   "description": "Zulip Standard", | ||||
|   "discountable": false, | ||||
|   "id": "ii_NORMALIZED00000000000003", | ||||
|   "invoice": null, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "object": "invoiceitem", | ||||
|   "period": { | ||||
|     "end": 1000000000, | ||||
|     "start": 1000000000 | ||||
|   }, | ||||
|   "plan": null, | ||||
|   "proration": false, | ||||
|   "quantity": 6, | ||||
|   "subscription": null, | ||||
|   "tax_rates": [], | ||||
|   "unit_amount": 6000 | ||||
| } | ||||
| @@ -1,33 +0,0 @@ | ||||
| { | ||||
|   "card": { | ||||
|     "address_city": "Pacific", | ||||
|     "address_country": "United States", | ||||
|     "address_line1": "Under the sea,", | ||||
|     "address_line1_check": "unchecked", | ||||
|     "address_line2": null, | ||||
|     "address_state": null, | ||||
|     "address_zip": "33333", | ||||
|     "address_zip_check": "unchecked", | ||||
|     "brand": "Visa", | ||||
|     "country": "US", | ||||
|     "cvc_check": "unchecked", | ||||
|     "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 | ||||
|   }, | ||||
|   "client_ip": "0.0.0.0", | ||||
|   "created": 1000000000, | ||||
|   "id": "tok_NORMALIZED00000000000001", | ||||
|   "livemode": false, | ||||
|   "object": "token", | ||||
|   "type": "card", | ||||
|   "used": false | ||||
| } | ||||
| @@ -1,33 +0,0 @@ | ||||
| { | ||||
|   "card": { | ||||
|     "address_city": "Pacific", | ||||
|     "address_country": "United States", | ||||
|     "address_line1": "Under the sea,", | ||||
|     "address_line1_check": "unchecked", | ||||
|     "address_line2": null, | ||||
|     "address_state": null, | ||||
|     "address_zip": "33333", | ||||
|     "address_zip_check": "unchecked", | ||||
|     "brand": "Visa", | ||||
|     "country": "US", | ||||
|     "cvc_check": "unchecked", | ||||
|     "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 | ||||
|   }, | ||||
|   "client_ip": "0.0.0.0", | ||||
|   "created": 1000000000, | ||||
|   "id": "tok_NORMALIZED00000000000002", | ||||
|   "livemode": false, | ||||
|   "object": "token", | ||||
|   "type": "card", | ||||
|   "used": false | ||||
| } | ||||
| @@ -1,112 +0,0 @@ | ||||
| { | ||||
|   "amount": 48000, | ||||
|   "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 | ||||
|   }, | ||||
|   "captured": true, | ||||
|   "created": 1000000000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "description": "Upgrade to Zulip Standard, $80.0 x 6", | ||||
|   "destination": null, | ||||
|   "dispute": null, | ||||
|   "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": 00, | ||||
|     "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", | ||||
|       "last4": "4242", | ||||
|       "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": "Zulip Standard", | ||||
|   "status": "succeeded", | ||||
|   "transfer_data": null, | ||||
|   "transfer_group": null | ||||
| } | ||||
| @@ -1,79 +0,0 @@ | ||||
| { | ||||
|   "account_balance": 0, | ||||
|   "address": null, | ||||
|   "balance": 0, | ||||
|   "created": 1000000000, | ||||
|   "currency": null, | ||||
|   "default_source": "card_NORMALIZED00000000000001", | ||||
|   "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, | ||||
|   "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_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,103 +0,0 @@ | ||||
| { | ||||
|   "account_balance": 0, | ||||
|   "address": null, | ||||
|   "balance": 0, | ||||
|   "created": 1000000000, | ||||
|   "currency": "usd", | ||||
|   "default_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 | ||||
|   }, | ||||
|   "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, | ||||
|   "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_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,103 +0,0 @@ | ||||
| { | ||||
|   "account_balance": 0, | ||||
|   "address": null, | ||||
|   "balance": 0, | ||||
|   "created": 1000000000, | ||||
|   "currency": "usd", | ||||
|   "default_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 | ||||
|   }, | ||||
|   "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, | ||||
|   "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_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,117 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 0, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 0, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": null, | ||||
|   "ending_balance": null, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": null, | ||||
|   "id": "in_NORMALIZED00000000000001", | ||||
|   "invoice_pdf": null, | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 48000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000001", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 6, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": -48000, | ||||
|         "currency": "usd", | ||||
|         "description": "Payment (Card ending in 4242)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000002", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "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": 1000000000, | ||||
|   "number": "NORMALI-0001", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "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": "draft", | ||||
|   "status_transitions": { | ||||
|     "finalized_at": null, | ||||
|     "marked_uncollectible_at": null, | ||||
|     "paid_at": null, | ||||
|     "voided_at": null | ||||
|   }, | ||||
|   "subscription": null, | ||||
|   "subtotal": 0, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 0, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": null | ||||
| } | ||||
| @@ -1,117 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 0, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 0, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": 0, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001", | ||||
|   "id": "in_NORMALIZED00000000000001", | ||||
|   "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf", | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 48000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000001", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 6, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": -48000, | ||||
|         "currency": "usd", | ||||
|         "description": "Payment (Card ending in 4242)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000002", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "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", | ||||
|   "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_tax_amounts": [], | ||||
|   "webhooks_delivered_at": 1000000000 | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "amount": -48000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "date": 1000000000, | ||||
|   "description": "Payment (Card ending in 4242)", | ||||
|   "discountable": false, | ||||
|   "id": "ii_NORMALIZED00000000000002", | ||||
|   "invoice": null, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "object": "invoiceitem", | ||||
|   "period": { | ||||
|     "end": 1000000000, | ||||
|     "start": 1000000000 | ||||
|   }, | ||||
|   "plan": null, | ||||
|   "proration": false, | ||||
|   "quantity": 1, | ||||
|   "subscription": null, | ||||
|   "tax_rates": [], | ||||
|   "unit_amount": -48000 | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "amount": 48000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "date": 1000000000, | ||||
|   "description": "Zulip Standard", | ||||
|   "discountable": false, | ||||
|   "id": "ii_NORMALIZED00000000000001", | ||||
|   "invoice": null, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "object": "invoiceitem", | ||||
|   "period": { | ||||
|     "end": 1000000000, | ||||
|     "start": 1000000000 | ||||
|   }, | ||||
|   "plan": null, | ||||
|   "proration": false, | ||||
|   "quantity": 6, | ||||
|   "subscription": null, | ||||
|   "tax_rates": [], | ||||
|   "unit_amount": 8000 | ||||
| } | ||||
| @@ -1,33 +0,0 @@ | ||||
| { | ||||
|   "card": { | ||||
|     "address_city": "Pacific", | ||||
|     "address_country": "United States", | ||||
|     "address_line1": "Under the sea,", | ||||
|     "address_line1_check": "unchecked", | ||||
|     "address_line2": null, | ||||
|     "address_state": null, | ||||
|     "address_zip": "33333", | ||||
|     "address_zip_check": "unchecked", | ||||
|     "brand": "Visa", | ||||
|     "country": "US", | ||||
|     "cvc_check": "unchecked", | ||||
|     "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 | ||||
|   }, | ||||
|   "client_ip": "0.0.0.0", | ||||
|   "created": 1000000000, | ||||
|   "id": "tok_NORMALIZED00000000000001", | ||||
|   "livemode": false, | ||||
|   "object": "token", | ||||
|   "type": "card", | ||||
|   "used": false | ||||
| } | ||||
| @@ -1,53 +0,0 @@ | ||||
| { | ||||
|   "account_balance": 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, | ||||
|   "object": "customer", | ||||
|   "phone": null, | ||||
|   "preferred_locales": [], | ||||
|   "shipping": null, | ||||
|   "sources": { | ||||
|     "data": [], | ||||
|     "has_more": false, | ||||
|     "object": "list", | ||||
|     "total_count": 0, | ||||
|     "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,95 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 984000, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 984000, | ||||
|   "application_fee_amount": null, | ||||
|   "attempt_count": 0, | ||||
|   "attempted": false, | ||||
|   "auto_advance": true, | ||||
|   "billing": "send_invoice", | ||||
|   "billing_reason": "manual", | ||||
|   "charge": null, | ||||
|   "collection_method": "send_invoice", | ||||
|   "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": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": null, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": null, | ||||
|   "id": "in_NORMALIZED00000000000001", | ||||
|   "invoice_pdf": null, | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 984000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000001", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1357095845, | ||||
|           "start": 1325473445 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 123, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       } | ||||
|     ], | ||||
|     "has_more": false, | ||||
|     "object": "list", | ||||
|     "total_count": 1, | ||||
|     "url": "/v1/invoices/in_NORMALIZED00000000000001/lines" | ||||
|   }, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "next_payment_attempt": null, | ||||
|   "number": "NORMALI-0001", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "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": "draft", | ||||
|   "status_transitions": { | ||||
|     "finalized_at": null, | ||||
|     "marked_uncollectible_at": null, | ||||
|     "paid_at": null, | ||||
|     "voided_at": null | ||||
|   }, | ||||
|   "subscription": null, | ||||
|   "subtotal": 984000, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 984000, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": null | ||||
| } | ||||
| @@ -1,95 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 100, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 100, | ||||
|   "application_fee_amount": null, | ||||
|   "attempt_count": 0, | ||||
|   "attempted": false, | ||||
|   "auto_advance": true, | ||||
|   "billing": "send_invoice", | ||||
|   "billing_reason": "manual", | ||||
|   "charge": null, | ||||
|   "collection_method": "send_invoice", | ||||
|   "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": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": null, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": null, | ||||
|   "id": "in_NORMALIZED00000000000002", | ||||
|   "invoice_pdf": null, | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 100, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard - renewal", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000002", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1388631845, | ||||
|           "start": 1357095845 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 1, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       } | ||||
|     ], | ||||
|     "has_more": false, | ||||
|     "object": "list", | ||||
|     "total_count": 1, | ||||
|     "url": "/v1/invoices/in_NORMALIZED00000000000002/lines" | ||||
|   }, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "next_payment_attempt": null, | ||||
|   "number": "NORMALI-0002", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "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": "draft", | ||||
|   "status_transitions": { | ||||
|     "finalized_at": null, | ||||
|     "marked_uncollectible_at": null, | ||||
|     "paid_at": null, | ||||
|     "voided_at": null | ||||
|   }, | ||||
|   "subscription": null, | ||||
|   "subtotal": 100, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 100, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": null | ||||
| } | ||||
| @@ -1,95 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 984000, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 984000, | ||||
|   "application_fee_amount": null, | ||||
|   "attempt_count": 0, | ||||
|   "attempted": false, | ||||
|   "auto_advance": true, | ||||
|   "billing": "send_invoice", | ||||
|   "billing_reason": "manual", | ||||
|   "charge": null, | ||||
|   "collection_method": "send_invoice", | ||||
|   "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": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": 0, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001", | ||||
|   "id": "in_NORMALIZED00000000000001", | ||||
|   "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf", | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 984000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000001", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1357095845, | ||||
|           "start": 1325473445 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 123, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       } | ||||
|     ], | ||||
|     "has_more": false, | ||||
|     "object": "list", | ||||
|     "total_count": 1, | ||||
|     "url": "/v1/invoices/in_NORMALIZED00000000000001/lines" | ||||
|   }, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "next_payment_attempt": null, | ||||
|   "number": "NORMALI-0001", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "payment_intent": "pi_1F96B0Gh0CmXqmnwFFVAGxWQ", | ||||
|   "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": 984000, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 984000, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": 1000000000 | ||||
| } | ||||
| @@ -1,95 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 100, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 100, | ||||
|   "application_fee_amount": null, | ||||
|   "attempt_count": 0, | ||||
|   "attempted": false, | ||||
|   "auto_advance": true, | ||||
|   "billing": "send_invoice", | ||||
|   "billing_reason": "manual", | ||||
|   "charge": null, | ||||
|   "collection_method": "send_invoice", | ||||
|   "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": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": 0, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002", | ||||
|   "id": "in_NORMALIZED00000000000002", | ||||
|   "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf", | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 100, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard - renewal", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000002", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1388631845, | ||||
|           "start": 1357095845 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 1, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       } | ||||
|     ], | ||||
|     "has_more": false, | ||||
|     "object": "list", | ||||
|     "total_count": 1, | ||||
|     "url": "/v1/invoices/in_NORMALIZED00000000000002/lines" | ||||
|   }, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "next_payment_attempt": null, | ||||
|   "number": "NORMALI-0002", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "payment_intent": "pi_1F96B2Gh0CmXqmnwZLKqLOFy", | ||||
|   "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": 100, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 100, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": 1000000000 | ||||
| } | ||||
| @@ -1,197 +0,0 @@ | ||||
| { | ||||
|   "data": [ | ||||
|     { | ||||
|       "account_country": "US", | ||||
|       "account_name": "Dev account", | ||||
|       "amount_due": 100, | ||||
|       "amount_paid": 0, | ||||
|       "amount_remaining": 100, | ||||
|       "application_fee_amount": null, | ||||
|       "attempt_count": 0, | ||||
|       "attempted": false, | ||||
|       "auto_advance": true, | ||||
|       "billing": "send_invoice", | ||||
|       "billing_reason": "manual", | ||||
|       "charge": null, | ||||
|       "collection_method": "send_invoice", | ||||
|       "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": "", | ||||
|       "discount": null, | ||||
|       "due_date": 1000000000, | ||||
|       "ending_balance": 0, | ||||
|       "footer": null, | ||||
|       "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002", | ||||
|       "id": "in_NORMALIZED00000000000002", | ||||
|       "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf", | ||||
|       "lines": { | ||||
|         "data": [ | ||||
|           { | ||||
|             "amount": 100, | ||||
|             "currency": "usd", | ||||
|             "description": "Zulip Standard - renewal", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000002", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1388631845, | ||||
|               "start": 1357095845 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 1, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           } | ||||
|         ], | ||||
|         "has_more": false, | ||||
|         "object": "list", | ||||
|         "total_count": 1, | ||||
|         "url": "/v1/invoices/in_NORMALIZED00000000000002/lines" | ||||
|       }, | ||||
|       "livemode": false, | ||||
|       "metadata": {}, | ||||
|       "next_payment_attempt": null, | ||||
|       "number": "NORMALI-0002", | ||||
|       "object": "invoice", | ||||
|       "paid": false, | ||||
|       "payment_intent": "pi_1F96B2Gh0CmXqmnwZLKqLOFy", | ||||
|       "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": 100, | ||||
|       "tax": null, | ||||
|       "tax_percent": null, | ||||
|       "total": 100, | ||||
|       "total_tax_amounts": [], | ||||
|       "webhooks_delivered_at": 1000000000 | ||||
|     }, | ||||
|     { | ||||
|       "account_country": "US", | ||||
|       "account_name": "Dev account", | ||||
|       "amount_due": 984000, | ||||
|       "amount_paid": 0, | ||||
|       "amount_remaining": 984000, | ||||
|       "application_fee_amount": null, | ||||
|       "attempt_count": 0, | ||||
|       "attempted": false, | ||||
|       "auto_advance": true, | ||||
|       "billing": "send_invoice", | ||||
|       "billing_reason": "manual", | ||||
|       "charge": null, | ||||
|       "collection_method": "send_invoice", | ||||
|       "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": "", | ||||
|       "discount": null, | ||||
|       "due_date": 1000000000, | ||||
|       "ending_balance": 0, | ||||
|       "footer": null, | ||||
|       "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001", | ||||
|       "id": "in_NORMALIZED00000000000001", | ||||
|       "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf", | ||||
|       "lines": { | ||||
|         "data": [ | ||||
|           { | ||||
|             "amount": 984000, | ||||
|             "currency": "usd", | ||||
|             "description": "Zulip Standard", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000001", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1357095845, | ||||
|               "start": 1325473445 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 123, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           } | ||||
|         ], | ||||
|         "has_more": false, | ||||
|         "object": "list", | ||||
|         "total_count": 1, | ||||
|         "url": "/v1/invoices/in_NORMALIZED00000000000001/lines" | ||||
|       }, | ||||
|       "livemode": false, | ||||
|       "metadata": {}, | ||||
|       "next_payment_attempt": null, | ||||
|       "number": "NORMALI-0001", | ||||
|       "object": "invoice", | ||||
|       "paid": false, | ||||
|       "payment_intent": "pi_1F96B0Gh0CmXqmnwFFVAGxWQ", | ||||
|       "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": 984000, | ||||
|       "tax": null, | ||||
|       "tax_percent": null, | ||||
|       "total": 984000, | ||||
|       "total_tax_amounts": [], | ||||
|       "webhooks_delivered_at": 1000000000 | ||||
|     } | ||||
|   ], | ||||
|   "has_more": false, | ||||
|   "object": "list", | ||||
|   "url": "/v1/invoices" | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "amount": 984000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "date": 1000000000, | ||||
|   "description": "Zulip Standard", | ||||
|   "discountable": false, | ||||
|   "id": "ii_NORMALIZED00000000000001", | ||||
|   "invoice": null, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "object": "invoiceitem", | ||||
|   "period": { | ||||
|     "end": 1357095845, | ||||
|     "start": 1325473445 | ||||
|   }, | ||||
|   "plan": null, | ||||
|   "proration": false, | ||||
|   "quantity": 123, | ||||
|   "subscription": null, | ||||
|   "tax_rates": [], | ||||
|   "unit_amount": 8000 | ||||
| } | ||||
| @@ -1,23 +0,0 @@ | ||||
| { | ||||
|   "amount": 100, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "date": 1000000000, | ||||
|   "description": "Zulip Standard - renewal", | ||||
|   "discountable": false, | ||||
|   "id": "ii_NORMALIZED00000000000002", | ||||
|   "invoice": null, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "object": "invoiceitem", | ||||
|   "period": { | ||||
|     "end": 1388631845, | ||||
|     "start": 1357095845 | ||||
|   }, | ||||
|   "plan": null, | ||||
|   "proration": false, | ||||
|   "quantity": 1, | ||||
|   "subscription": null, | ||||
|   "tax_rates": [], | ||||
|   "unit_amount": 100 | ||||
| } | ||||
| @@ -1,112 +0,0 @@ | ||||
| { | ||||
|   "amount": 48000, | ||||
|   "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 | ||||
|   }, | ||||
|   "captured": true, | ||||
|   "created": 1000000000, | ||||
|   "currency": "usd", | ||||
|   "customer": "cus_NORMALIZED0001", | ||||
|   "description": "Upgrade to Zulip Standard, $80.0 x 6", | ||||
|   "destination": null, | ||||
|   "dispute": null, | ||||
|   "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": 00, | ||||
|     "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", | ||||
|       "last4": "4242", | ||||
|       "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": "Zulip Standard", | ||||
|   "status": "succeeded", | ||||
|   "transfer_data": null, | ||||
|   "transfer_group": null | ||||
| } | ||||
| @@ -1,79 +0,0 @@ | ||||
| { | ||||
|   "account_balance": 0, | ||||
|   "address": null, | ||||
|   "balance": 0, | ||||
|   "created": 1000000000, | ||||
|   "currency": null, | ||||
|   "default_source": "card_NORMALIZED00000000000001", | ||||
|   "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, | ||||
|   "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_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,117 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 0, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 0, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": null, | ||||
|   "ending_balance": null, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": null, | ||||
|   "id": "in_NORMALIZED00000000000001", | ||||
|   "invoice_pdf": null, | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 48000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000001", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1357095845, | ||||
|           "start": 1325473445 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 6, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": -48000, | ||||
|         "currency": "usd", | ||||
|         "description": "Payment (Card ending in 4242)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000002", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "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": 1000000000, | ||||
|   "number": "NORMALI-0001", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "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": "draft", | ||||
|   "status_transitions": { | ||||
|     "finalized_at": null, | ||||
|     "marked_uncollectible_at": null, | ||||
|     "paid_at": null, | ||||
|     "voided_at": null | ||||
|   }, | ||||
|   "subscription": null, | ||||
|   "subtotal": 0, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 0, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": null | ||||
| } | ||||
| @@ -1,139 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 80697, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 80697, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": null, | ||||
|   "ending_balance": null, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": null, | ||||
|   "id": "in_NORMALIZED00000000000002", | ||||
|   "invoice_pdf": null, | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 7255, | ||||
|         "currency": "usd", | ||||
|         "description": "Additional license (Feb 5, 2013 - Jan 2, 2014)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000003", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000003", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1388631845, | ||||
|           "start": 1360033445 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 1, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": 56000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard - renewal", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000004", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000004", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1388631845, | ||||
|           "start": 1357095845 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 7, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": 17442, | ||||
|         "currency": "usd", | ||||
|         "description": "Additional license (Apr 11, 2012 - Jan 2, 2013)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000005", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000005", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1357095845, | ||||
|           "start": 1334113445 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 3, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       } | ||||
|     ], | ||||
|     "has_more": false, | ||||
|     "object": "list", | ||||
|     "total_count": 3, | ||||
|     "url": "/v1/invoices/in_NORMALIZED00000000000002/lines" | ||||
|   }, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "next_payment_attempt": 1000000000, | ||||
|   "number": "NORMALI-0002", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "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": "draft", | ||||
|   "status_transitions": { | ||||
|     "finalized_at": null, | ||||
|     "marked_uncollectible_at": null, | ||||
|     "paid_at": null, | ||||
|     "voided_at": null | ||||
|   }, | ||||
|   "subscription": null, | ||||
|   "subtotal": 80697, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 80697, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": null | ||||
| } | ||||
| @@ -1,117 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 0, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 0, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": 0, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001", | ||||
|   "id": "in_NORMALIZED00000000000001", | ||||
|   "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf", | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 48000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000001", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1357095845, | ||||
|           "start": 1325473445 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 6, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": -48000, | ||||
|         "currency": "usd", | ||||
|         "description": "Payment (Card ending in 4242)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000002", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1000000000, | ||||
|           "start": 1000000000 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "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", | ||||
|   "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_tax_amounts": [], | ||||
|   "webhooks_delivered_at": 1000000000 | ||||
| } | ||||
| @@ -1,139 +0,0 @@ | ||||
| { | ||||
|   "account_country": "US", | ||||
|   "account_name": "Dev account", | ||||
|   "amount_due": 80697, | ||||
|   "amount_paid": 0, | ||||
|   "amount_remaining": 80697, | ||||
|   "application_fee_amount": 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": [], | ||||
|   "default_payment_method": null, | ||||
|   "default_source": null, | ||||
|   "default_tax_rates": [], | ||||
|   "description": "", | ||||
|   "discount": null, | ||||
|   "due_date": 1000000000, | ||||
|   "ending_balance": 0, | ||||
|   "footer": null, | ||||
|   "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002", | ||||
|   "id": "in_NORMALIZED00000000000002", | ||||
|   "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf", | ||||
|   "lines": { | ||||
|     "data": [ | ||||
|       { | ||||
|         "amount": 7255, | ||||
|         "currency": "usd", | ||||
|         "description": "Additional license (Feb 5, 2013 - Jan 2, 2014)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000003", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000003", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1388631845, | ||||
|           "start": 1360033445 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 1, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": 56000, | ||||
|         "currency": "usd", | ||||
|         "description": "Zulip Standard - renewal", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000004", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000004", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1388631845, | ||||
|           "start": 1357095845 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 7, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       }, | ||||
|       { | ||||
|         "amount": 17442, | ||||
|         "currency": "usd", | ||||
|         "description": "Additional license (Apr 11, 2012 - Jan 2, 2013)", | ||||
|         "discountable": false, | ||||
|         "id": "ii_NORMALIZED00000000000005", | ||||
|         "invoice_item": "ii_NORMALIZED00000000000005", | ||||
|         "livemode": false, | ||||
|         "metadata": {}, | ||||
|         "object": "line_item", | ||||
|         "period": { | ||||
|           "end": 1357095845, | ||||
|           "start": 1334113445 | ||||
|         }, | ||||
|         "plan": null, | ||||
|         "proration": false, | ||||
|         "quantity": 3, | ||||
|         "subscription": null, | ||||
|         "tax_amounts": [], | ||||
|         "tax_rates": [], | ||||
|         "type": "invoiceitem" | ||||
|       } | ||||
|     ], | ||||
|     "has_more": false, | ||||
|     "object": "list", | ||||
|     "total_count": 3, | ||||
|     "url": "/v1/invoices/in_NORMALIZED00000000000002/lines" | ||||
|   }, | ||||
|   "livemode": false, | ||||
|   "metadata": {}, | ||||
|   "next_payment_attempt": 1000000000, | ||||
|   "number": "NORMALI-0002", | ||||
|   "object": "invoice", | ||||
|   "paid": false, | ||||
|   "payment_intent": "pi_1F96BCGh0CmXqmnwfafGafrD", | ||||
|   "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": 80697, | ||||
|   "tax": null, | ||||
|   "tax_percent": null, | ||||
|   "total": 80697, | ||||
|   "total_tax_amounts": [], | ||||
|   "webhooks_delivered_at": 1000000000 | ||||
| } | ||||
| @@ -1,263 +0,0 @@ | ||||
| { | ||||
|   "data": [ | ||||
|     { | ||||
|       "account_country": "US", | ||||
|       "account_name": "Dev account", | ||||
|       "amount_due": 80697, | ||||
|       "amount_paid": 0, | ||||
|       "amount_remaining": 80697, | ||||
|       "application_fee_amount": 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": [], | ||||
|       "default_payment_method": null, | ||||
|       "default_source": null, | ||||
|       "default_tax_rates": [], | ||||
|       "description": "", | ||||
|       "discount": null, | ||||
|       "due_date": 1000000000, | ||||
|       "ending_balance": 0, | ||||
|       "footer": null, | ||||
|       "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002", | ||||
|       "id": "in_NORMALIZED00000000000002", | ||||
|       "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000002/pdf", | ||||
|       "lines": { | ||||
|         "data": [ | ||||
|           { | ||||
|             "amount": 7255, | ||||
|             "currency": "usd", | ||||
|             "description": "Additional license (Feb 5, 2013 - Jan 2, 2014)", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000003", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000003", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1388631845, | ||||
|               "start": 1360033445 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 1, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           }, | ||||
|           { | ||||
|             "amount": 56000, | ||||
|             "currency": "usd", | ||||
|             "description": "Zulip Standard - renewal", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000004", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000004", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1388631845, | ||||
|               "start": 1357095845 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 7, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           }, | ||||
|           { | ||||
|             "amount": 17442, | ||||
|             "currency": "usd", | ||||
|             "description": "Additional license (Apr 11, 2012 - Jan 2, 2013)", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000005", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000005", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1357095845, | ||||
|               "start": 1334113445 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 3, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           } | ||||
|         ], | ||||
|         "has_more": false, | ||||
|         "object": "list", | ||||
|         "total_count": 3, | ||||
|         "url": "/v1/invoices/in_NORMALIZED00000000000002/lines" | ||||
|       }, | ||||
|       "livemode": false, | ||||
|       "metadata": {}, | ||||
|       "next_payment_attempt": 1000000000, | ||||
|       "number": "NORMALI-0002", | ||||
|       "object": "invoice", | ||||
|       "paid": false, | ||||
|       "payment_intent": "pi_1F96BCGh0CmXqmnwfafGafrD", | ||||
|       "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": 80697, | ||||
|       "tax": null, | ||||
|       "tax_percent": null, | ||||
|       "total": 80697, | ||||
|       "total_tax_amounts": [], | ||||
|       "webhooks_delivered_at": 1000000000 | ||||
|     }, | ||||
|     { | ||||
|       "account_country": "US", | ||||
|       "account_name": "Dev account", | ||||
|       "amount_due": 0, | ||||
|       "amount_paid": 0, | ||||
|       "amount_remaining": 0, | ||||
|       "application_fee_amount": 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": [], | ||||
|       "default_payment_method": null, | ||||
|       "default_source": null, | ||||
|       "default_tax_rates": [], | ||||
|       "description": "", | ||||
|       "discount": null, | ||||
|       "due_date": 1000000000, | ||||
|       "ending_balance": 0, | ||||
|       "footer": null, | ||||
|       "hosted_invoice_url": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001", | ||||
|       "id": "in_NORMALIZED00000000000001", | ||||
|       "invoice_pdf": "https://pay.stripe.com/invoice/invst_NORMALIZED0000000000000001/pdf", | ||||
|       "lines": { | ||||
|         "data": [ | ||||
|           { | ||||
|             "amount": 48000, | ||||
|             "currency": "usd", | ||||
|             "description": "Zulip Standard", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000001", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000001", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1357095845, | ||||
|               "start": 1325473445 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "proration": false, | ||||
|             "quantity": 6, | ||||
|             "subscription": null, | ||||
|             "tax_amounts": [], | ||||
|             "tax_rates": [], | ||||
|             "type": "invoiceitem" | ||||
|           }, | ||||
|           { | ||||
|             "amount": -48000, | ||||
|             "currency": "usd", | ||||
|             "description": "Payment (Card ending in 4242)", | ||||
|             "discountable": false, | ||||
|             "id": "ii_NORMALIZED00000000000002", | ||||
|             "invoice_item": "ii_NORMALIZED00000000000002", | ||||
|             "livemode": false, | ||||
|             "metadata": {}, | ||||
|             "object": "line_item", | ||||
|             "period": { | ||||
|               "end": 1000000000, | ||||
|               "start": 1000000000 | ||||
|             }, | ||||
|             "plan": null, | ||||
|             "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", | ||||
|       "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_tax_amounts": [], | ||||
|       "webhooks_delivered_at": 1000000000 | ||||
|     } | ||||
|   ], | ||||
|   "has_more": false, | ||||
|   "object": "list", | ||||
|   "url": "/v1/invoices" | ||||
| } | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user