Compare commits

..

229 Commits

Author SHA1 Message Date
Alex Vandiver
c805268f2d locale: Fix bad zh_TW string. 2025-07-26 02:30:33 +00:00
Alex Vandiver
cb8dc451a8 compilemessages: Weblate's Language-Team uses <>.
(cherry picked from commit 4c2cf4dca8)
2025-07-25 23:49:32 +00:00
Alex Vandiver
e630281275 i18n: Remove translations which had bogus empty plurals. 2025-07-25 23:43:29 +00:00
Alex Vandiver
007708a626 i18n: Update .po files with ./manage.py makemessages. 2025-07-25 23:42:52 +00:00
Hosted Weblate
d16b40a156 i18n: Sync translations from Weblate. 2025-07-25 23:38:46 +00:00
Tim Abbott
e5a3d20a5c version: Update version after 10.4 release. 2025-07-02 13:33:10 -07:00
Tim Abbott
ad6867b6cc Release Zulip Server 10.4. 2025-07-02 11:50:43 -07:00
Anders Kaseorg
175ec1f365 CVE-2025-52559: Generate HTML for digest new channels safely.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-07-02 11:40:30 -07:00
Anders Kaseorg
1a8429e338 CVE-2025-52559: Generate HTML for digest message sender safely.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-07-02 11:40:26 -07:00
Anders Kaseorg
6608c87772 CVE-2025-52559: Generate HTML for digest recipient header safely.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-07-02 11:40:20 -07:00
Alex Vandiver
5606489d96 puppet: Use Service for PostgreSQL restarts.
Using pg_ctlcluster leaves systemctl thinking the process aborted; and
not all instances (e.g. Docker) have systemctl.

(cherry picked from commit 7a8a8f5f23)
2025-07-01 16:37:03 -07:00
Alex Vandiver
bc7ea80452 puppet: Do not bother manually symlinking hunspell dictionaries.
This code dates back to 57b52310639a; however, this has been handled
by `postgresql-common` adding a post-install trigger to call
`pg_updatedicts` for each new PostgreSQL version, since
`postgresql-common` version 153 (February 2014).

(cherry picked from commit 9def655564)
2025-07-01 16:37:03 -07:00
Alex Vandiver
2b6058d5f7 upgrade-postgresql: Slightly better error-proof post-upgrade scripts.
(cherry picked from commit 0442bb6f0e)
2025-07-01 16:37:03 -07:00
Alex Vandiver
176a8bd3df upgrade-postgresql: Explicitly ask to not start the new cluster.
Recent versions of postgresql-common's `pg_upgradecluster`, starting
with version 254, (i.e. on Ubuntu 24.04, but not 22.04) will not just
_suggest_ running the analyze, but will do so automatically.  While
somewhat helpful, it always does so with `--analyze-in-stages`, which
as noted in f77bbd3323, is actually the incorrect choice for us.
Passing `--no-start` ensures that `pg_upgradecluster` consistently
does not do any analyzing, allowing us to start the cluster manually
and then perform the analyze correctly ourselves.

(cherry picked from commit 3ab6be650b)
2025-07-01 16:37:03 -07:00
Alex Vandiver
38421b77ea upgrade-postgresql: Use tags to partially-apply configuration.
This uses the same technique used in 840884ec89, to only apply select
parts of the Puppet configuration.  This is more correct, and simpler,
than attempting to chop out some base puppet roles, and hack around
the `purge => true` supervisor.d configuration.

(cherry picked from commit e13f82f048)
2025-07-01 16:37:03 -07:00
Alex Vandiver
2fc5040b0b upgrade-postgresql: Only touch pgroonga_setup.sql.applied if required.
Since c8ec3dfcf6, the file must contain the version that was
configured, or we run `ALTER EXTENSION pgroonga UPDATE`; if the file
is missing, and pgroonga was previously installed, it run `CREATE
EXTENSION pgroonga` which will be an error.  If the file is present
but pgroonga was not configured, a later attempt to enable pgroonga
will incorrectly run `ALTER EXTENSION pgroonga UPDATE` instead of
`CREATE EXTENSION pgroonga`.

If the file existed on the previous version, touch it in the new
PostgreSQL version.  This will ensure that puppet will *always* run
the pgroonga update, which may be necessary in case the pgroonga
version also changed.  At worst, if the pgroonga version has not
changed, this will be a safe no-op.

(cherry picked from commit 2dc5c6c50e)
2025-07-01 16:37:03 -07:00
Lauryn Menard
a0c1bf1e02 docs: Fix renamed reset_authentication_attempt_count command.
This management command was renamed in commit 7ef1a024db, but the
documentation was not updated at that time.

(cherry picked from commit 50c45a00c8)
2025-07-01 16:37:03 -07:00
Alex Vandiver
3095a7b439 docs: Fix field name in thumbnailing doc.
(cherry picked from commit 476ba3ec61)
2025-07-01 16:37:03 -07:00
Alex Vandiver
9002cf750f thumbnail: Add flag for when thumbnail files are missing.
(cherry picked from commit de67d37884)
2025-07-01 16:37:03 -07:00
Alex Vandiver
171f902fe1 setup-apt-repo: Add libheif PPA, and debian bookworm backport.
libheif 1.18 is required to be able to parse images generated by iOS
18; none of Zulip's supported distributions package libheif 1.18, so
we pull new version of the package from PPA (Ubuntu) or backports
(Debian).

(cherry picked from commit b924169d17)
2025-07-01 16:37:03 -07:00
Anders Kaseorg
1205d7603c requirements: Upgrade Python requirements.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 5a090c47ad)
2025-06-26 17:40:12 -07:00
Anders Kaseorg
9c1fcfb69f requirements: Remove python-twitter.
Commit dce4a3c98e (#25816) removed
Twitter support.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 013be33b20)
2025-06-26 17:40:12 -07:00
Anders Kaseorg
ea27b848fc mypy: Add types-requests-oauthlib, types-uwsgi.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 0b776e2c15)
2025-06-26 17:40:12 -07:00
Anders Kaseorg
836ab28a75 install-uv: Upgrade uv from 0.7.2 to 0.7.11.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 56470bba8d)
2025-06-26 17:40:12 -07:00
Tim Abbott
8cff227ecf api docs: Clarify how the various presence APIs relate.
Co-authored-by: Greg Price <greg@zulip.com>
(cherry picked from commit ea68b7320a)
2025-06-26 10:02:57 -07:00
Tim Abbott
fdea941046 docs: Delete legacy presence subsystem page.
Everything on this page is now better explained in the API
documentation for presence.

(cherry picked from commit 8179a31dc7)
2025-06-26 10:02:57 -07:00
apoorvapendse
496f793dae typeahead_helper: Cache diacritic-less names for performance.
This code path now uses the full name with removed diacritics cache,
just like the right sidebar buddy list search.

This fixes a major performance issue with trying to mention users in
organizations with 10,000s of total users.

Fixes:
https://chat.zulip.org/#narrow/channel/9-issues/topic/Mention.20typeahead.20performance/with/2157415.
(cherry picked from commit 378dc7a97d)
2025-06-26 10:02:57 -07:00
apoorvapendse
ff4860ddbc people: Extract name diacritics removal logic.
This is done as a prep commit to use this new function in
`query_matches_person` to introduce caching on the diacritic-less full
name.

(cherry picked from commit cb0a598481)
2025-06-26 10:02:57 -07:00
apoorvapendse
456a644fad typeahead: Remove diacritics only for valid queries.
Where a valid query is a string whose lowercase version
contains only ASCII lowercase letters.

@timabbott said:
>Note that the query being ascii is important
to how the comparisons are done, so that queries
with diacritics are handled properly to match
the exact diacritics used, for example.

(cherry picked from commit 1bcf05b13d)
2025-06-26 10:02:57 -07:00
apoorvapendse
428081bf14 typeahead: Extract matching logic into a new function.
This is a non-functional change done as a part of
a series of commits to eventually cache and use
diacritic-less full names instead of computing them
every time.

The eventual aim is to pass cached diacritic-less
full names directly to
`query_matches_string_in_order_assume_canonicalized`
when the query is plain ascii.

(cherry picked from commit e0c786c7a0)
2025-06-26 10:02:57 -07:00
Alex Vandiver
77da214e3c send_email: Only attempt suppression list removal with credentials.
Servers without any configured credentials raise a NoCredentialsError,
which is not a subclass of botocore.exceptions.ClientError, and hence
abort the password reset attempt.

Check for if we have any credentials at all before we attempt the API
call.

(cherry picked from commit 66c123dd43)
2025-06-26 10:02:57 -07:00
Sérgio Glórias
358a9a5fbe email_mirror: Also strip "SV:" from subject.
Observed in emails from Nordic countries, the prefix SV: is used instead of RE:.

(cherry picked from commit d817bd5faf)
2025-06-26 10:02:57 -07:00
Niklas Fiekas
e29bcff2fb email_mirror: Also strip "Re[123]:" from subject.
Observed consecutively numbered replies from Outlook.

(cherry picked from commit e4d366d159)
2025-06-26 10:02:57 -07:00
Alex Vandiver
ba5f68c155 upload: Fix uploading the same file twice in the same session.
This commit fixes a bug where uploading the same file a second time
in the same browser session would appear to the user to stall with
`Uploading [filename]...` in the composebox.  This is because
`tus-js-client` makes a HEAD request to check for already-uploaded
files -- and, if found, that request is used in the `upload-success`
callback.  That left the callback with no response body to parse, to
know what URL to insert.

Store the `/user_uploads/...` URL in the file metadata after a
successful upload, and if the fingerprint matches a previous upload,
pull that URL (and filename, as it may have changed server-side) out
of the previous upload's metadata.

Co-authored-by: Shubham Padia <shubham@zulip.com>
(cherry picked from commit c2e0a27d2c)
2025-06-26 10:02:57 -07:00
Alex Vandiver
cfa6e41691 upload: Handle non-200 responses more gracefully.
(cherry picked from commit 098228a210)
2025-06-26 10:02:57 -07:00
Shubham Padia
b0f6e3058e upload: Pass filename to get_translated_status instead of file.
We only need the name to get the translated status, there's not much
point passing the complete file object as an argument. This will help us
in making future changes where the file name may not be coming either
from the file object or file.name.

(cherry picked from commit 009eeb22ad)
2025-06-26 10:02:57 -07:00
Niloth P
1462fe7bb2 integrations: Add OpenSearch incoming webhook integration.
Co-authored-by: merlinz01 <158784988+merlinz01@users.noreply.github.com>
(cherry picked from commit 22c80117f5)
2025-06-26 10:02:57 -07:00
Niloth P
cd877aba5a generate-integration-docs-screenshot: Support plain/text payloads.
All *.txt fixtures were assumed to only contain URL parameters,
now fixtures with plain/text payloads are also supported.

(cherry picked from commit 7e3218ee45)
2025-06-26 10:02:57 -07:00
Anders Kaseorg
612aaae283 upload: Avoid unnecessary .one() usage to simplify test.
Both of these handlers immediately hide the banner, so the difference
between .on() and .one() doesn’t matter.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 1a41a13ea3)
2025-06-26 10:02:57 -07:00
PieterCK
6b9365f616 mattermost_import: Except error when converting messages HTML.
This adds a try-except block when running html2text when processing raw
messages from HTML to markdown.

convert_html_to_text is added mainly for testing convinience. We don't
have any sample of Mattermosts' problematic content that could trigger
this sort of error yet, so the test mocks convert_html_to_text to raise
error instead.

(cherry picked from commit 201a71b575)
2025-05-21 17:03:59 -07:00
PieterCK
a5ee0e913e mattermost_import: Log when processing messages.
This logs a line for every batch of messages processed by
process_list_in_batches.

(cherry picked from commit 45b396393f)
2025-05-21 17:03:59 -07:00
Anders Kaseorg
f388030e85 requirements: Upgrade Python requirements.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit acd7353538)
2025-05-16 13:06:58 -07:00
Anders Kaseorg
c657b6cc72 test_auth_backends: Fix AuthException construction.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 7566e6549e)
2025-05-16 13:06:58 -07:00
Anders Kaseorg
e82a693576 install-uv: Upgrade uv from 0.6.13 to 0.7.2.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit fe96666782)
2025-05-16 13:06:58 -07:00
Tim Abbott
84a1024b09 version: Update version after 10.3 release. 2025-05-15 16:01:02 -07:00
Tim Abbott
c1be5e6766 Release Zulip Server 10.3. 2025-05-15 15:23:42 -07:00
Sayam Samal
3c4d473708 channel_settings: Fix advanced configurations subsection toggle area.
This commit fixes the toggleable area of the advanced configurations
subsection in the channel settings, preventing the toggle action from
interfering with the save discard widget.

(cherry picked from commit 46c568cace)
2025-05-15 14:58:41 -07:00
Sayam Samal
e37cd63001 channel_settings: Show tooltip for invalid message retention period.
(cherry picked from commit 7d8e32da6a)
2025-05-15 14:58:41 -07:00
Sayam Samal
2db8371074 settings: Fix absent save/discard widget for empty message retention.
This commit fixes the state where the save/discard widget was not being
shown when the custom message retention input was empty, by not
defaulting to settings_config.retain_message_forever (-1) as the value,
when the input field is empty.

(cherry picked from commit 154bb67f9f)
2025-05-15 14:58:41 -07:00
Sayam Samal
9d9ab65dcc settings: Show tooltip for invalid message retention period.
(cherry picked from commit 98ab25c623)
2025-05-15 14:58:41 -07:00
Sayam Samal
d4f95bc1f9 settings: Improve enable_or_disable_save_button method code.
This commit reorganizes the code in enable_or_disable_save_button
method, and improves its readability. It also replaces the previous
tooltip logic for the disabled save button in the save/discard widget
with the more standardized methods to handle the same from ui_util.ts.

(cherry picked from commit 0914732387)
2025-05-15 14:58:41 -07:00
Sayam Samal
e533b7c017 ui_util: Improve enable_element_and_remove_tooltip method logic.
This commit improves the enable_element_and_remove_tooltip method to
only unwrap the disabled element from the tooltip wrapper, once we
confirm the wrappers existence.

(cherry picked from commit 30bfabb2eb)
2025-05-15 14:58:41 -07:00
Sayam Samal
fccd5f5e82 ui_util: Improve disable_element_and_add_tooltip method logic.
This commit reinforces the disable_element_and_add_tooltip method
logic to factor in the edge case where the element in question is
already disabled and wrapped in a tooltip attached container and the
method is called again.

(cherry picked from commit 10716c8f4d)
2025-05-15 14:58:41 -07:00
Sahil Batra
c0cd582f0c streams: Check creation permission when updating channel privacy.
User who did not have permission to create public channels
could create them by first creating a private or web-public
channel, if they had the permission to create them, and then
changing privacy of that stream to be a public stream.

Similarly user without permission to create private channels
could also create them.

This commit fixes both these bugs.
2025-05-15 14:11:47 -07:00
Tim Abbott
b0589bf286 i18n: Update translations from Transifex. 2025-05-07 15:38:22 -07:00
Sahil Batra
a653a73af2 message_view: Live update on losing access to a stream.
This commit adds code to live update the message view when
user loses access to a stream and also remove the data of
messages from that stream.

(cherry picked from commit 52b20354e6)
2025-05-07 15:38:22 -07:00
Sahil Batra
7074abd86e streams: Live update UI correctly when archiving streams.
Previously, "delete" event was sent for both archiving streams
and when user lost access to a stream. So, when user lost
access to a stream, the UI was live updated like the stream
was archived, which was not correct. But now "update" event
is sent when a stream is archived, so the webapp code is
changed accordingly to live-update the UI for both cases
correctly.

As a result, some of the changes in 43932cd6aa and a29b6485d6
are reverted as "update" event is sent when archiving and "delete"
event is sent only when a user loses access to a stream as before.

(cherry picked from commit 9d15a11331)
2025-05-07 15:38:22 -07:00
Sahil Batra
53f885fa15 streams: Fix events send when archiving and unarchiving streams.
(cherry picked from commit 7c470f0161)
2025-05-07 15:38:22 -07:00
Sahil Batra
43c6e3d47f register: Include archived channels in "streams" field.
(cherry picked from commit ae579aa25a)
2025-05-07 15:38:22 -07:00
Sahil Batra
7911ca0cee events: Do not compute first_message_id unnecessarily.
"first_message_id" field for subscription objects needs
to be updated when archiving a stream as we send a
notification message, but first_message_id will only
change if the stream did not have any messages previously.

This commit updates the code to update first_message_id
only when required.

(cherry picked from commit a6cc33f478)
2025-05-07 15:38:22 -07:00
Sayam Samal
bf1c5e08ec tests: Fix admin.test.ts failing puppeteer test using waitForFunction.
(cherry picked from commit f7129ae550)
2025-05-07 15:38:22 -07:00
Sayam Samal
68c9866a6e tests: Fix admin.test.ts puppeteer test case failing.
The puppeteer test case for checking the save discard widget button
states was failing sometimes as the button was being updated too soon.

Due to this, sometimes the assertion check would execute after the
button label has already been updated — resulting in mismatched results.

(cherry picked from commit e1acec52eb)
2025-05-07 15:38:22 -07:00
Sayam Samal
5c1451dd21 tests: Improve change signup announcements stream test in admin.test.ts.
This commit makes the test setup in
test_change_signup_announcements_stream consistent with those in
test_change_new_stream_announcements_stream and
test_change_zulip_update_announcements_stream.

(cherry picked from commit 91efa998ee)
2025-05-07 15:38:22 -07:00
Sayam Samal
2e4fca6daa settings: Prevent save discard widget override.
This commit prevents the save button in the save discard widget from
showing the "Saved" label when the user has made some other changes
in the settings while the saving process was in action — which resulted
in the "Save changes" label in the save button, and thus shouldn't be
replaced with "Saved".

This commit also fixes the failing puppeteer tests in
`web/e2e-tests/admin.test.ts` which was introduced in #34081.

(cherry picked from commit 7b45ff50ba)
2025-05-07 15:38:22 -07:00
Sayam Samal
c1be1e0116 settings: Remove unused "saved" state in save discard widget.
(cherry picked from commit f496bd6350)
2025-05-07 15:38:22 -07:00
Sayam Samal
de42b4d1ad settings: Remove unused success callback in save_organization_settings.
(cherry picked from commit fbd28f1349)
2025-05-07 15:38:22 -07:00
Sayam Samal
819b16b11e settings: Fix save discard widget closing before saved state.
Sometimes, in slower connections, Tornado long-polling callback can
reach the client at almost exactly the same time as the success
response, making the prediction of which arrives first
non-deterministic. Due to this, the server event call responsible for
syncing the realm settings across multiple users would sometimes take
over and hide and the save discard widget before the success callback
from `/json/realm` could show the "saved" state in the button.

This commit fixes this issue by blocking the "discarded" state from
hiding the save discard widget when the save button is already in the
"saving" or "succeeded" state, since in those conditions the visibility
of the save discard widget would anyways be handled by a "failed" or
"succeeded" state.

(cherry picked from commit 83c5733144)
2025-05-07 15:38:22 -07:00
Sayam Samal
8276dcdb30 settings: Update save button style when "Saved" is shown.
This commit updates the save button style in the settings component to
ensure that the button appears as a borderless attention + success
intent action button alongside the "Saved" label, when an updated
setting is saved.

(cherry picked from commit b115368a81)
2025-05-07 15:38:22 -07:00
Sayam Samal
087a89b2f4 components: Restructure component type declarations.
This commit moves the ComponentIntent type to types.ts since it is
common across all the components, and also moves the ActionButton type
from banners.ts to buttons.ts since it is specific to the button
component.

On top of that, the commit also updates the type declarations to be
based off of array declarations to make it easier to modify them
programmatically.

(cherry picked from commit 203ca08446)
2025-05-07 15:38:22 -07:00
Sayam Samal
d41dc8eeae settings: Fix alert notification indicator styling in settings.
(cherry picked from commit b43d3dc1d4)
2025-05-07 15:38:22 -07:00
Sayam Samal
91da77d4e8 settings: Improve subsection header styling.
This commit makes all the subsection header as flex boxes, and improves
it's CSS styling.

(cherry picked from commit 616a957842)
2025-05-07 15:38:22 -07:00
Sayam Samal
dbfe4ddee4 settings: Update save and discard buttons to redesigned button styles.
This commit updates the save and discard buttons in the setting modals
to use redesigned button styles along with the new loading indicator.

(cherry picked from commit 6bf2887991)
2025-05-07 15:38:22 -07:00
Pratik Chanda
fb083bafec stream_edit: Org permission not reflecting in channel permission.
Earlier, permission in edit panel of channel settings was not the
same as that of org permission, specifically for public channel
option.

This commit fixes that by updating the privacy option state.

Fixes:zulip#34526.
(cherry picked from commit fdf7bc0888)
2025-05-07 15:38:22 -07:00
Karl Stolley
15fbcafed8 page_loader: Correct clipped logo circle.
(cherry picked from commit a9e3331fcc)
2025-05-07 15:38:22 -07:00
Jitendra Kumar
a04dd1bb92 message_feed UI: Update logo dimensions for better scalability.
Change 'z' logo dimensions so that it scales according to chosen font
size.

Fix: #34266
(cherry picked from commit 8682db5573)
2025-05-07 15:38:22 -07:00
Prakhar Pratyush
859cc29657 unread_data: Ensure deterministic ordering of unread message rows.
Earlier, in `get_raw_unread_data` the ordering was applied inside
the CTE.

Once we leave the CTE scope and do a join, SQL makes no promise
about preserving the row order unless we re-specify ORDER BY in
the outer query.

Since, there was no ORDER BY clause in the outer query it was
resulting in a random ordering of the entries. This bug was caught
by `test_unreads_case_insensitive_topics` failing in a flaky way.

This commit fixes the bug.

(cherry picked from commit ccc82976dc)
2025-05-07 15:38:22 -07:00
Alex Vandiver
7ee999917f thumbnail: Add a tool to re-thumbnail spinners, or process old images.
(cherry picked from commit 49d2c1010a)
2025-05-07 15:38:22 -07:00
Shubham Padia
d6fadeec77 attachments: Allow seeing attachments to users with content access.
Fixes https://chat.zulip.org/#narrow/channel/9-issues/topic/Can't.20view.20images.20in.20private.20channel.2E

(cherry picked from commit 700da670cf)
2025-05-07 15:38:22 -07:00
Shubham Padia
9bb9c20c88 test_subs: Add check_subscription_exists helper.
Fetching a subscription and then checking if it exists was taking too
much space in a test and making it feel convoluted. We're planning to
check it more in future commits.

(cherry picked from commit 6baa106460)
2025-05-07 15:38:22 -07:00
Shubham Padia
69ac1c0724 attachments: Do not fetch complete owner object.
We just need to compare the user profile id and the owner id, we will
save 1 query call this way.

(cherry picked from commit ca50b5dac7)
2025-05-07 15:38:22 -07:00
Tim Abbott
e97d532811 version: Update version after 10.2 release.
This should have been pushed before backporting the first commits for
10.3, but better late than never.
2025-05-07 12:59:58 -07:00
Mateusz Mandera
fea421b54d ldap: Add migration to fix incorrect system group memberships.
In #34510 we fixed the underlying bug in the ldap integration, which
would cause users to end up with their system group memberships not
matching their .role value. However, users who may already be in that
state still need to be fixed through a migration. We implement that
here.

There are two things we fix here:
1. Group memberships. The user should have a direct group membership
   for the specific system group implied by their .role.
2. We want to also add the missing RealmAuditLog entry.
2025-05-07 12:56:53 -07:00
Mateusz Mandera
4cb838168f populate_analytics_db: Create missing system group memberships. 2025-05-07 12:56:53 -07:00
Mateusz Mandera
6ea67a7df2 ldap: Fix the syncing of user role via AUTH_LDAP_USER_FLAGS_BY_GROUP.
This was broken, due the mechanism simply using our
is_guest/is_realm_admin/etc. role setters, but failing to adjust system
group memberships - resulting in corrupted database state.
We need to ensure that change_user_role is called for setting user role.

There are two relevant codepaths that run the sync based on
AUTH_LDAP_USER_FLAGS_BY_GROUP and thus need to get this right:
1. manage.py sync_ldap_user_data
2. Just-in-time user creation when a user without a Zulip account logs
   in for the first using their ldap credentials. After
   get_or_build_user returns, django-auth-ldap sees that the user
   account has just been created, and proceeds to run ._populate_user().

Now that both user.save() and do_change_user_realm will be getting
called together, we need to ensure this always happens atomically.

This imposes the need to override _get_or_create_user to put it in a
transaction. The troublesome consequence is that this new
`atomic(savepoint=False)` causes the usual type of issue, where tests
testing error get their transaction rolled back and cannot continue
executing.

To get around that, we add a test helper
`artificial_transaction_savepoint` which allows these tests to wrap
their problematic blocks in an artificial transaction which provides a
savepoint, thus preventing the full test transaction rollback derailing
the rest of the test.
2025-05-07 12:56:53 -07:00
Mateusz Mandera
03ebeb10ab ldap: Fix dev/test-specific bugs with AUTH_LDAP_USER_FLAGS_BY_GROUP.
Without these overrides, we cannot test the functionality in DEVELOPMENT
and TESTING.

There are two codepaths that we're covering here:
1. The sync which happens via `sync_ldap_user_data`.
2. The sync which happens during just-in-time user creation upon first
   login via ldap.

Both codepaths end up triggering ldap_user._get_or_create_user().
2025-05-07 12:56:53 -07:00
Tim Abbott
c65cc48215 Release Zulip Server 10.2. 2025-04-15 17:23:30 -07:00
Alex Vandiver
25d1491999 tusd: Update development version.
This update was missed in 21eff33875.

(cherry picked from commit 826c643401)
2025-04-15 16:48:14 -07:00
Tim Abbott
bc3753d859 docs: Add upgrade note for S3_SKIP_CHECKSUM. 2025-04-15 11:19:23 -07:00
Mateusz Mandera
33f4cd1ad4 realm_creation: Disable open realm creation if no password backend. 2025-04-15 11:19:23 -07:00
Mateusz Mandera
4bc70f7c04 signup: Don't run password_strength form validator in ldap signup mode.
When an ldap user is signing up via the registration form, they are
required to enter their ldap password. This is in contract to "regular"
password signup, where the user sets the password for their new account.

Checking password strength makes sense in the latter case, but not in the
ldap case - the password is already set at the ldap level after all.

In any case, the password_strength validator is not even added to the
form field with `id="ldap-password"`, so this was bugged throwing errors
such as

```
TypeError: $.validator.methods[method] is undefined. Exception occurred when checking element ldap-password, check the 'password_strength' method. at http://localhost:9991/webpack/vendors-node_modules_pnpm_jquery-validation_1_21_0_jquery_3_7_1_node_modules_jquery-validatio-b912f7.js:810
at check .pnpm/jquery-validation@1.21.0_jquery@3.7.1/node_modules/jquery-validation/dist/jquery.validate.js:803
at element .pnpm/jquery-validation@1.21.0_jquery@3.7.1/node_modules/jquery-validation/dist/jquery.validate.js:510
at onfocusout .pnpm/jquery-validation@1.21.0_jquery@3.7.1/node_modules/jquery-validation/dist/jquery.validate.js:310
at delegate .pnpm/jquery-validation@1.21.0_jquery@3.7.1/node_modules/jquery-validation/dist/jquery.validate.js:441
at dispatch .pnpm/jquery@3.7.1/node_modules/jquery/dist/jquery.js:5145
at ../node_modules/.pnpm/jquery jquery/dist/jquery.js?1d73/</add/elemData.handle@http://localhost:9991/webpack/vendors-node_modules_pnpm_error-stack-parser_2_1_4_node_modules_error-stack-parser_error-stac-967546.js:16502
at trigger .pnpm/jquery@3.7.1/node_modules/jquery/dist/jquery.js:8629
at simulate .pnpm/jquery@3.7.1/node_modules/jquery/dist/jquery.js:8698
at focusMappedHandler .pnpm/jquery@3.7.1/node_modules/jquery/dist/jquery.js:5574
```

when interacting with the form.
2025-04-15 11:19:23 -07:00
Mateusz Mandera
b5ab90aaa4 signup: Prevent unauthorized signup for realms without EmailAuthBackend.
Zulip supports a configuration where account creation is limited solely
by being able to authenticate with a single-sign on authentication
backend, such as Google Authentication, SAML, or LDAP (i.e., the
organization places no restrictions on email address domains or
invitations being required to join, but has disabled the
EmailAuthBackend that is used for email/password authentication).

A bug in the Zulip server meant that Zulip allowed users to create an
account in such organizations by confirming their email address, without
having an account with the SSO authentication backend.

Co-authored-by: Tim Abbott <tabbott@zulip.com>
2025-04-15 11:19:23 -07:00
Sahil Batra
9423f213a7 settings: Fix opening settings for guests who cannot access all users.
Opening settings and stream settings UI was not working for guests
if they could not access all users. This was because is_person_active
did not handle inaccessible users correctly, if they were not added in
the users data, when being called in get_group_members to render
group pills.

(cherry picked from commit 4f80823191)
2025-04-14 16:02:26 -07:00
Sahil Batra
6abbbc190c typeahead: Fix typeahead showing for disabled inputs.
When user cannot type in the input, because of contenteditable
being set to "false", typeahead should not be shown when clicking
on the input element.

(cherry picked from commit 3739081792)
2025-04-14 16:02:26 -07:00
Sahil Batra
b61da7d944 streams: Don't show confirmation modal if user can subscribe.
Previously, we showed confirmation modal when user was unsubscribing
themselves from the private stream from "Unsubscribe" button in
subscribers list, even when user had the permission to subscribe
to the stream again.

This commit fixes it to not show the confirmation modal if user
has permission to subscribe again. We already have same behavior
when user tries to unsubscribe from the button present at the
right of tabs.

(cherry picked from commit 722d501107)
2025-04-14 16:02:26 -07:00
Sahil Batra
73669ff7f0 streams: Fix warning shown when unsubscribing from private stream.
We no longer archive the stream when private stream becomes
vacant, so removed that part from the warning.

When a private stream becomes vacant, everyone in the organization
can lose content access to it if no one has permission to subscribe
themselves or others to the stream. So, the warning is updated to
mention this.

(cherry picked from commit d3c06234e2)
2025-04-14 16:02:26 -07:00
Alex Vandiver
ed5fc4cc19 tusd: Use GCS upload backend when the endpoint matches.
This works around tus/tusd#322, which in turn is caused by
aws/aws-sdk-go-v2#1816.  This requires separate authentication via
service account key.

Fixes: #34186.
(cherry picked from commit e1aa8b1cb0)
2025-04-14 16:02:26 -07:00
Anders Kaseorg
85b2e6a1e9 install: Support PostgreSQL 17.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 818742c62b)
2025-04-14 16:02:26 -07:00
Tim Abbott
f4279a2a7f help: Document copy-pasting LaTeX.
Fixes: https://chat.zulip.org/#narrow/channel/9-issues/topic/.E2.9C.94.20.F0.9F.93.82.20pasting.20LaTeX/near/2129200.

Co-authored-by: Apoorva Pendse <apoorvavpendse@gmail.com>
(cherry picked from commit 70e36ef16a)
2025-04-14 16:02:26 -07:00
Shubham Padia
7fd018d82a settings: Exclude nobody from channel and group settings typeahead.
We have filtered the group in the get_user_groups argument of
set_up_pill_typeahead. We could have done it in `set_up_combined` but
that would have made that function non-generic and specific to these two
settings. We could also have filtered it in get_all_realm_user_groups by
adding an argument on whether to exclude it or not, but that would have
been very hard to read and track. This seemed like the better of the
options we had.

Fixes https://chat.zulip.org/#narrow/channel/9-issues/topic/.22Nobody.22.20group.20in.20UI.20to.20add.20subscribers.2Fgroup.20members

(cherry picked from commit 349e88adc6)
2025-04-14 16:02:26 -07:00
Alex Vandiver
4a35e00d1c tusd: Reject tusd terminations after we insert them into our database.
The tusd protocol allows DELETE requests ("terminations") at any
point, including after a file has successfully been uploaded.  This
can allow tusd to remove a file from the bucket, out from under Zulip.

We use the new-in-2.7.0 pre-terminate hook to look up the file which
the client is requesting to terminate, and reject the termination if
it is a file that the Zulip database is already aware of.

(cherry picked from commit cf51013bb7)
2025-04-14 16:02:26 -07:00
Alex Vandiver
e44108edb2 puppet: Upgrade tusd to 2.8.0.
(cherry picked from commit 21eff33875)
2025-04-14 16:02:26 -07:00
Alex Vandiver
d7293735e1 smokescreen: Move metrics port from the default 9810, to 4760.
This prevents errors if Smokescreen is running on a host with more
than 10 Tornado shards.

(cherry picked from commit b11cbbab01)
2025-04-14 16:02:26 -07:00
Alex Vandiver
da72e9447e kandra: Add a grok exporter to parse nginx logfiles.
This provides access logging metrics to Prometheus.  For cardinality
reasons, we cannot (nor would we want to) put every request path into
its own label value -- but we do separate out the most-frequent access
paths (as well as some low-frequency but high-interest ones) into
their own label values.

In order to differentiate accesses to https://zulip.com/ from
https://example.zulipchat.com/ (both of which appear at path `/`), we
use a `grok_exporter.realm_names_regex` value in `zulip.conf`, which
is expected to be set to match the hostname of all possible realms.

(cherry picked from commit 840fa74854)
2025-04-14 16:02:26 -07:00
Alex Vandiver
c357eb8225 kandra: Update prometheus configuration.
This pulls in the more complete production Prometheus configuration.

(cherry picked from commit bd54f0363e)
2025-04-14 16:02:26 -07:00
Aman Agrawal
03feb5a546 message_overlay: Fix restore tooltips detached with message content.
To avoid restore tooltip of message from being displayed outside
the overlay, we define a boundary, outside which the tooltip
cannot exist. Popper library is smart enough to render the tooltip
correctly by respecting the provided boundary and flipping the
tooltip placement if required.

(cherry picked from commit cd439c0232)
2025-04-14 16:02:26 -07:00
Sanchit Sharma
bcd88fdb68 streams: Return archived web-public channels.
(cherry picked from commit d5c83e02c3)
2025-04-14 16:02:26 -07:00
Anders Kaseorg
e6291a540c narrow: Fix get_base_query_for_search access restrictions.
The type_id is the id of a UserProfile, Stream, or DirectMessageGroup,
not the id of a type.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit ad31ef22f2)
2025-04-14 16:02:26 -07:00
Tim Abbott
f539147446 i18n: Update translations from Transifex. 2025-04-10 17:42:48 -07:00
Niloth P
45f30a19e1 invite: Update email pill widget's usage.
- Rename the variable "pills" to "email_pill_widget". To conform better
with other pill widgets.
- Re-use the email pill creation function.
- Directly access `item.email`, skipping function call.

(cherry picked from commit cabea0ea9a)
2025-04-10 17:42:48 -07:00
Niloth P
f131269395 app_variables: Rename background color of pill containers.
Pill containers that do not use placeholders.

(cherry picked from commit 2b7961f30f)
2025-04-10 17:42:48 -07:00
Niloth P
d8501197ee integration-url-modal: Use input pills for branch names.
(cherry picked from commit dafef91c8c)
2025-04-10 17:42:48 -07:00
Karl Stolley
3a89ca6b46 message_row: Better flexibly align hover controls.
(cherry picked from commit a1bba7a453)
2025-04-10 17:42:48 -07:00
Karl Stolley
a906bd4b33 message_row: Restore padding to senderless content box.
(cherry picked from commit 11fdd5f005)
2025-04-10 17:42:48 -07:00
Karl Stolley
2ef119b62b message_row: Better target first children.
(cherry picked from commit 46b33f0d26)
2025-04-10 17:42:48 -07:00
Karl Stolley
7f8bc37cf5 rendered_markdown: Adjust content blocks for link focus ring.
(cherry picked from commit 465971171d)
2025-04-10 17:42:48 -07:00
Alex Vandiver
9ef4649406 tusd: Use default already set in computed_settings.
Having an additional fallback here is not necessary.

(cherry picked from commit 33339f89c3)
2025-04-10 17:42:48 -07:00
Alex Vandiver
9fbf4527a8 settings: S3 is enabled if LOCAL_UPLOADS_DIR is unset.
We should not key off of `S3_KEY`/`S3_SECRET_KEY`, since those are
optional if the host is in EC2 and using instance profiles.  Instead,
check if `LOCAL_UPLOADS_DIR` is None1, which is the authoritative
source for if the S3 backend is in use.

(cherry picked from commit ba5d1108c0)
2025-04-10 17:42:48 -07:00
Alex Vandiver
ab81867721 nginx: Relay the same Host: header that nginx saw.
Unilaterally adding the port can cause CSRF failures when the port is
a default port, and thus optional.  Switch to providing the exact
`Host` header that the original request contained.

(cherry picked from commit 5f783ed5ad)
2025-04-10 17:42:48 -07:00
Alex Vandiver
b7e38f4dd6 s3: Support non-AWS S3 providers which do not support request checksums.
(cherry picked from commit aeed907c50)
2025-04-10 17:42:48 -07:00
Mateusz Mandera
4f86630faa do_change_user_email: Store old and new email in the audit log.
We forgot to store the actual values in the audit log, making these logs
not very helpful in actually auditing a user's email change history.

(cherry picked from commit 5814ac559f)
2025-04-10 17:42:48 -07:00
Aman Agrawal
eeecb995ca upload: Fix send button disabled when compose is closed during upload.
While uploading a file, if you close the compose box, and reopen
it, compose send button remains disabled due to upload in progress
being true.

To fix it, we update upload status for compose when upload is
cancelled.

(cherry picked from commit b8651e78e7)
2025-04-10 17:42:48 -07:00
Alex Vandiver
1e6a413895 nginx: Use cache slicing to prevent thundering herds for video thumbs.
This prevents a thundering herd for videos -- if a very large video is
posted to a channel with many active clients, all of them
simultaneously request it, to provide the in-feed preview image.
While these requests come with a `Range` header which is intended to
limit the request to just the first couple MB, nginx ignores this
header when making its request to the upstream -- so it can obtain and
cache the whole file locally.  This results in multiple competing
requests for the whole content from S3, all racing to store the
content in the cache.

Use cache slicing to split the content cache into chunks of 5MB; the
cache is filled one slice at a time, as needed based on the byte
ranges that clients request.  Clients making requests without a
`Range` header are provided with the content transparently stitched
together from the individual slices.

The slice size of 5MB is chosen to encompass more 95% of file
uploads (saving an extra trip to the origin) while also being large
enough to be able to provide video thumbnails in a single slice, as
well as not take too much time to obtain from the upstream.

(cherry picked from commit 23e8eb5c7c)
2025-04-10 17:42:48 -07:00
Aman Agrawal
21691024d2 navigate: Fix up keypress behaviour when a long prev message.
Fixes #32970

When navigating from a short message to a tall message via up
keypress, we used to jump to the top of the message. This
doesn't align with user's expectation that up / down keypress
will let them see the entire message feed.

To fix it, we can `page_up` which scrolls up the correct amount
and then our message selection logic kicks in to select the
correct message on screen.

(cherry picked from commit 203cc69969)
2025-04-10 17:42:48 -07:00
Saubhagya Patel
d3ff0cb95f message_move: Fix new_topic_name in topic already exists warning.
In the move topic modal, the `new_topic_name` input is disabled if
the user doesn't have permission to move messages between topics.
This commit fixes a bug where `new_topic_name` is undefined since its
input is disabled. This causes `show_topic_already_exists_warning()`
to throw an AssertionError. Hence, the warning is not shown.

Specifically, this bug occurs when a user moves a topic to an
already existing topic in a different channel when he has permission
to move messages between channels but not between topics.

(cherry picked from commit 62745ddccb)
2025-04-10 17:42:48 -07:00
Saubhagya Patel
8dcc2bf592 message_move: Pass stream_widget_value to update submit button state.
In the move topic modal, the stream ID from the dropdown widget
should be passed to `update_submit_button_disabled_state()`
function instead of `current_stream_id`. This fixes a bug where
the submit button was incorrectly disabled after editing the
move topic input.

Specifically, when selecting a different channel and an existing
topic, the submit button remains enabled initially. However, if a
character is removed and then retyped in the move topic input,
the submit button becomes disabled incorrectly.

(cherry picked from commit 57c1a12853)
2025-04-10 17:42:48 -07:00
Saubhagya Patel
1c6fba6c8f message_move: Initialize ResizeObserver for Rename topic modal.
This commit fixes a bug where the Rename topic modal did not resize
when the "topic already exists" warning was shown or hidden. This
caused the topic edit typeahead for topics in a channel
with similar prefixes to be misaligned.

(cherry picked from commit 725fd707fe)
2025-04-10 17:42:48 -07:00
Anders Kaseorg
5918266544 requirements: Upgrade Python requirements.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 8450f04efc)
2025-04-10 17:07:44 -07:00
Anders Kaseorg
e916abf31e worker: Check if Sentry is initialized before calling add_breadcrumb.
Otherwise we get spammed with “Dropped breadcrumb because no client
bound” log messages.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit e8faa4a029)
2025-04-10 17:07:44 -07:00
Anders Kaseorg
25c8d2abd0 install-uv: Upgrade uv from 0.6.6 to 0.6.13.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit e4a2695f54)
2025-04-10 17:07:44 -07:00
Sahil Batra
1f442f0bd1 group-settings: Live update when can_manage_all_groups is changed.
Groups UI is now live updated when can_manage_all_groups is
changed.

Backported a04ee8a8b8 from #34205.
2025-04-09 15:43:33 -07:00
Tim Abbott
7183621e87 settings: Remove useless commented mypy type.
Legacy settings contained type "Dict" which were removed in zulip 9.0,
so this type was wrong, but it also serves no purpose.

(The non-commented types are checked in the development environment).

(cherry picked from commit c95dd65d75)
2025-04-04 12:01:08 -07:00
Kartikay5849
748ce899d2 ui: Update unread banner text and button label.
Changes banner text to "This conversation also has older unread
messages. Jump to first unread message?"
Updates button label from "Jump to first unread" to "Jump".

(cherry picked from commit 1e4eec9803)
2025-04-04 12:01:08 -07:00
Karl Stolley
d7ee758ca2 left_sidebar: Display bot icon, status emoji as inline block.
(cherry picked from commit 67da4d5a2b)
2025-04-04 12:01:08 -07:00
Karl Stolley
693e06bf63 left_sidebar: Present two-line DM rows.
(cherry picked from commit 131e031f1c)
2025-04-04 12:01:08 -07:00
whilstsomebody
9077cd6467 left_sidebar: Use opaque hover color to avoid topic bleedthrough.
(cherry picked from commit 846d771084)
2025-04-04 12:01:08 -07:00
whilstsomebody
c9182efcea popover: Hide unexpired invitation count when it is 0.
In deactivate user confirm dialog, we hide the count of
unexpired invitations of a user when the count is zero.

Fixes: #34265
(cherry picked from commit 22341c18db)
2025-04-04 12:01:08 -07:00
Karl Stolley
a59b9b3f66 compose: Prevent picker from collapsing.
(cherry picked from commit ca1e56d91b)
2025-04-04 12:01:08 -07:00
Karl Stolley
9c7a00faaf snippets: Better distinguish snippet content in dropdown.
(cherry picked from commit 815dccdb7f)
2025-04-04 12:01:08 -07:00
Alex Vandiver
0232a4c92e actions: Add test upgrade from 10.0.
(cherry picked from commit bd8764f0f6)
2025-04-04 12:01:08 -07:00
Alex Vandiver
0e3eb0081b nginx: Serve full app from localhost.
Some deployments choose to wrap Zulip's nginx in an outer proxy -- for
example, to do custom TLS termination.  In such deployments, the outer
proxy is routing to `127.0.0.1:80`; b4fb22ba1b breaks these
configurations, as it switches the `127.0.0.1:80` listener to only
serving `/api/internal/` paths.

Switch to serving the whole application over `127.0.0.1:80`.

(cherry picked from commit e2e0c72a80)
2025-04-04 12:01:08 -07:00
Alex Vandiver
71348739be nginx: Tell the backend service what port we listen on.
The `$host` nginx variable is _not_ the unadulterated `Host`
header (which would be `$http_host`) -- it is that header, *without
the port*, with a fallback to the `server_name` which processed the
request.

This means that backend services are not aware of the port that the
request came in on, unless they derive that from reading
`nginx_listen_port` in `/etc/zulip/zulip.conf`, or similar.
Specifically, this caused `tusd`, on deploys with non-standard
`nginx_listen_port`, to generate a `Location` header which left off
the port, and as such attempted a CORS check when retrieving metadata
about the just-uploaded file, which failed.

Add the port to the `Host` header we pass to `tusd` and other backend
services.

(cherry picked from commit 4e26705fbc)
2025-04-01 09:29:32 -07:00
Alex Vandiver
c3401557b7 runtusd: Respect application_server.nginx_listen_port.
In deploys `nginx_listen_port` set, tusd would fail to send its hook
requests, as it assumed that nginx would always be listening on
127.0.0.1:80.

Set the `nginx_listen_port` on the hook URL, if necessary.

(cherry picked from commit bee3c6eb59)
2025-04-01 09:29:32 -07:00
Alex Vandiver
851953a729 nginx: Move localhost to its own block, bound to the loopback address.
This makes the `localhost.d` directory less of a lie, and decreases
the chances that local reconfigurations will break the 127.0.0.1:80
server which is used for IPC.

In cases where `nginx_http_only` is enabled, we respect
`nginx_listen_port` soas to not attempt to bind on port 80 if the
administrator was explicitly attempting to avoid that.

(cherry picked from commit b4fb22ba1b)
2025-04-01 09:29:32 -07:00
Mateusz Mandera
d4532683bb test-api: Make desdemona consenting to private data export.
Fixes CI - it was failing due to the API test for organization exports,
which was returning an error due to there being no Organization Owners
with consent to private data export.

(cherry picked from commit e31dfebc07)
2025-03-28 23:05:49 -07:00
Tim Abbott
3bbd490d7b version: Update version after 10.1 release. 2025-03-28 18:14:49 -07:00
Tim Abbott
b22f514bb7 test_import_export: Fix typos. 2025-03-28 17:29:45 -07:00
Tim Abbott
13e59d590e Release Zulip Server 10.1. 2025-03-28 17:12:54 -07:00
Anders Kaseorg
744b7c7382 custom_profile_fields: Restrict access to users in the same realm.
This fixes CVE-2025-30369.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-28 16:56:11 -07:00
Anders Kaseorg
cce3c7ebb1 realm_export: Restrict deletion to users in the same realm.
This fixes CVE-2025-30368.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
2025-03-28 16:56:00 -07:00
Mateusz Mandera
9b33e3bb14 export: Also add guardrail to the management command. 2025-03-28 16:52:44 -07:00
Mateusz Mandera
d0cdbab1c0 export: Add guardrails against generating a dysfunctional export via UI.
As explained in the comments, if in an export with consent there are no
consenting owners or in a public export there are no owners with email
visibility set to at least ADMINS, the exported data will, upon import,
create an organization without usable owner accounts.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
28fee7aab8 export: Add detailed tests for export of public vs private data.
Adds detailed tests for the work in the prior commits fixing the
treatment of private data in various tables in exports with consent and
public exports.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
38de0ce7af export: Don't export DirectMessageGroup info of non-consented users.
This is private information, as by inspecting the DirectMessageGroup
objects and their associated Subscription objects, you could determine
which users conversed with each other in a DM group.

This did *not* leak any actual message - only the fact that at least one
of the users in the group sent a group DM.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
ffd7e4a426 export: Fix public exports.
The prior significantly restricted what data gets exported from
non-consented users. The last thing we're missing is to fix the logic
to work correctly for public exports.

Prior commits focused on addressing exports with consent. This commit
adapts it to work with public exports.:
- Do not turn user accounts into mirror dummies in the public export - or
  after export->import you'll end up with a realm with no functional
  accounts; as every user is non-consented and the original logic added in
  the prior commits will turn them into mirror dummies.
- Some of the custom fetch/process functions were changed without
  considering public exports - now they work correctly, by setting
  consenting_user_ids to an empty set.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
29a05bb16f export: Scrub Subscriptions to defaults for non-consented users.
The Subscription Config is constructed in a bit of a strange way, that's
not compatible with defining a custom_fetch function.
Instead we have to extend the system to support passing a custom
function for processing just the final list of rows right before it's
returned for writing to export files.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
39f1e1951b export: Don't turn non-consented deactivated users into mirror dummies.
As explained in the comment, if we turn a non-consented deactivated user
into a mirror dummy, this will violate the rule that a deactivated user
cannot restore their account by themselves after an export->import
cycle.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
ff876d2df4 export: Treat is_mirror_dummy=True users as consenting.
As explained in the comment added to the function, in terms of privacy
concerns, it is fine to export all data for these accounts. And it is
important to do - so that exporting an organization which was originally
imported e.g. from Slack doesn't result in excessively limited data for
accounts that were mirror dummies and never "activated" themselves.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
3c43603607 export: Treat deactivated user with consent enabled as consenting.
Prior to this, deactivated user were presumed to be non-consenting to
private data export, regardless of their setting.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
3c1fae1707 export: Fix get_consented_user_ids to also account for bots.
Now that we severely limited the way that non-consenting users get
exported, we need to start to consider bots as consenting when
appropriate - otherwise the exported bot accounts will be unusable after
importing.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
e57b6719fa export: Scrub RealmAuditLog rows where modified_user is non-consenting. 2025-03-28 16:52:44 -07:00
Mateusz Mandera
9da4eeaa94 export: Don't export real email of users unless accessible to admins.
An administrator shouldn't be able to bypass a user's setting to hide
their email address from everyone, including admins.
Therefore, we should overwrite the delivery_email for such users during
export - unless the user consented to have their private data exported.

The notable consequence of this is that such user accounts will become
completely inaccessible after importing this data to a new server, due
to not having a functional email address on record.

These accounts will only be possible to reclaim via a manual
intervention to change the email address on the `UserProfile` by server
administrators.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
13303fd916 export: Plumb consented_user_ids to export_usermessage_batch in a file.
This allows us to get rid of the call to `get_consented_user_ids` in
`fetch_usermessages`. Now it's only called at the beginning of the
export, eliminating the redundant db query and also resolving the
potential for data consistency issues, if some users change their
consent setting after the export starts.

Now the full export process operates with a single snapshot of these
consenting user ids.

These ids need to be plumbed through via a file rather than normal arg
passing, because this is a separate management command, run in
subprocesses during the export.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
747e73470e export: Reset settings to default for users not in exportable_user_ids.
These users didn't consent to having their private data exported.
Therefore, correct handling of these users should involve scrubbing
their settings to just match the realm defaults.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
ceb32a7285 export: Use exportable_user_ids arg to plumb through consenting users.
Instead of making repeated calls to get_consented_user_ids, we can just
fetch it (mostly) once and put it in
`context["exportable_user_ids"]`. This is essentially what the
(unused until now) exportable_user_ids logic was added for after all.

The added, intended, effect of this is that non-consenting users will
now get exported as mirror dummy accounts, due to the handling of
non-exportable users in `custom_fetch_user_profile`.

The remaining additional call to `get_consented_user_ids` is in
`fetch_usermessages`. This one is tricky as this function gets called
in subprocesses via
`zerver/management/commands/export_usermessage_batch.py` management
command invoked by the export process.
It requires passing the `exportable_user_ids` in some other way. This
can be dealt with in upcoming commits.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
8b9516fb0b export: Only export Client objects needed by the data being exported.
We shouldn't export the entire Client table - it includes Clients for
all the realms on the server, completely unrelated to the realm we're
exporting. Since these contain parts of the UserAgents used by the
users, we should treat these as private data and only export the Clients
that the specific data we're exporting "knows" about.
2025-03-28 16:52:44 -07:00
Mateusz Mandera
3a0de29f5d export: Don't export miscellaneous private data of non-consenting users. 2025-03-28 16:52:43 -07:00
Alex Vandiver
69e165f0fd docs: Clarify which IAM role is being referenced.
(cherry picked from commit 88b74c46bc)
2025-03-28 16:51:44 -07:00
Alex Vandiver
e23673141a tusd: Attempt to derive S3 region.
We already do this in computed_settings.py, but only if the
S3 (secret) key is set.  Those aren't required to be set, and tusd
_requires_ a region, so we try again to suss it out here.

(cherry picked from commit 9c043c6c14)
2025-03-28 16:51:44 -07:00
Alex Vandiver
f1df8f3efa tusd: Support running without explicit AWS keys.
Fixes: #34102.
(cherry picked from commit 794588629d)
2025-03-28 16:51:44 -07:00
Jitendra Kumar
cd97468587 message_list: Update trailing bookend on empty channel or topic.
Show `You are not subscribed to  #xyz. Subscribe` bookend
on channel or topic which are not subscribed and have no messages.

Fixes: #33209
(cherry picked from commit 4696c8eb67)
2025-03-27 21:53:31 -07:00
Aman Agrawal
5458a2ca2f message_list: Fix just_unsubscribed for empty views.
This fixes a bug where wrong bookend is shown in empty views in
the next commit.

(cherry picked from commit afbc6f2510)
2025-03-27 21:53:31 -07:00
userAdityaa
19dd4f67ce search_pill: Aligned the user emoji with the search text.
This commit ensures that the user emoji in the search pill
is correctly aligned with the search text. The issue was
caused by improper line height, leading to the emoji being
slightly cut off at the top. Adjusting the `line-height`
of `.pill-value` resolves this, providing a consistent
and visually balanced appearance.

(cherry picked from commit 1b260c9fd7)
2025-03-27 21:53:31 -07:00
Elsa Kihlberg Gawell
7905491fa2 migration: Fix topic name for imported DMs from third-party exports.
Currently, imported direct messages from third-party exports might have
a non-empty string as their topic name.

This migration updates the topic names for all imported third-party DMs
and GDMs to an empty string if they aren’t already.

Fixes #29466.

Co-authored-by: Pieter CK <pieterceka123@gmail.com>
(cherry picked from commit f6b3d59c35)
2025-03-27 21:53:31 -07:00
PieterCK
36339b6998 slack_import: Fix thread conversion condition.
Currently, threads in Slack direct messages will increment the
`thread_counter` variable inside the thread conversion logic. Since we
don't treat thread messages in Slack DMs differently than any other DM,
threads in DM will only falsely increment the thread topic names in
channels.

This adds a condition that checks if the Slack message is a DM or not
before executing the thread conversion logic.

(cherry picked from commit d5e28bcd28)
2025-03-27 21:53:31 -07:00
Elsa Kihlberg Gawell
af3eefb951 import_data: Make sure converted DMs don't have topic name.
Previously, `build_message` sets a message's topic name to the given
topic name, regardless of whether the message was a direct message (DM)
or a group direct message (GDM).

This change adds the `is_private` parameter to `build_message`. If
`is_private` is `True`, the `topic_name` will be overridden to an empty
string (""). Consequently, this also updates the third-party importers
to pass this parameter when calling `build_message`.

Co-authored-by: Pieter CK <pieterceka123@gmail.com>
(cherry picked from commit 845f0d40e1)
2025-03-27 21:53:31 -07:00
Evy Kassirer
39e43838c3 buddy_list: Make sure we always open Others section during search.
(cherry picked from commit 70e542c9cc)
2025-03-27 21:53:31 -07:00
Lauryn Menard
5f0844d7fb compose-actions: Set topic earlier if specified in start opts.
When on_compose_select_recipient_update is called when we start
the compose box actions, then it subsequently calls
compose_recipient.update_on_recipient_change.

If there is a specified topic in the opts for the compose box,
then that should be set for various functions that are called
in update_on_recipient_change.

compose_recipient.update_topic_displayed_text is called later for
all cases, direct messages and empty topics, which will update the
compose_state.topic again.

(cherry picked from commit d7873fbc11)
2025-03-27 21:53:31 -07:00
Lauryn Menard
a7bc77aaa0 narrow-state: Filter out "with" operator in narrowed_by_topic_reply.
As the web app is now using the "with" operator for links to channel
topic conversations, we need to filter out that operator when
checking the current narrow state.

(cherry picked from commit 24a65c1783)
2025-03-27 21:53:31 -07:00
Lauryn Menard
2e6eeabac6 typing-events: Use valid channel ID to get conversation typists.
(cherry picked from commit ae66bf287b)
2025-03-27 21:53:31 -07:00
Prakhar Pratyush
3f37ee7bc7 typing: Rename "(no topic)" to empty string topic.
This commit renames "(no topic)" to "" when used as
topic name in `POST /typing`.

Message sent in "(no topic)" is translated as being
sent in "" by the server, so it makes sense to show
the typing notification in "" when message is being composed.

(cherry picked from commit d011fb0621)
2025-03-27 21:53:31 -07:00
Tim Abbott
b340286e53 i18n: Update translations from Transifex. 2025-03-27 21:53:31 -07:00
Kartikay5849
558ed44d4b compose: Prevent duplicate group mention warning banners.
We now use `data-user-group-id` to check if a banner for the same
group already exists, preventing duplicate warnings when the same
group is mentioned multiple times.

(cherry picked from commit 35289dfe51)
2025-03-27 16:53:35 -07:00
apoorvapendse
7efac715a8 user_groups: Persist settings view while switching tabs.
Fixes: #33437.
(cherry picked from commit 753b4e31b9)
2025-03-27 16:53:35 -07:00
whilstsomebody
735a604d8b widgets: Remove white background from "Add task"/"Add option" button.
In dark theme, when clicking the "Add task" button of
todo and "Add option" button of poll, the background
color incorrectly turns white.

This commit removes the white background color of the
buttons and makes it consistent woth the other green
buttons.

(cherry picked from commit eef44429e2)
2025-03-27 16:53:35 -07:00
Saubhagya Patel
10e0405220 message_move: Show "general chat" in link of confirmation toast.
When a message is moved using the "Move only this message" option
a confirmation toast is shown. This commit adds support to show
"general chat" in link of the toast when a message is moved to it.

(cherry picked from commit 939691dfed)
2025-03-27 16:53:35 -07:00
Prakhar Pratyush
6396dc5cad recipient_row: Fix empty string topic display in keyword search view.
Searching for a word that appears in a message in a empty string
topic via the search box resulted in a message view where the
topic names in the recipient_row were empty string instead of
`realm_empty_topic_display_name`.

This commit fixes that bug.

(cherry picked from commit 8383b11526)
2025-03-27 16:53:35 -07:00
evykassirer
b5c5853027 drafts: Fix bug opening drafts in 'general chat'.
This was likely a longstanding issue that wasn't
caught because we required topics on CZO. The new
logic ensures topic match even for empty string
(general chat) topics.

(cherry picked from commit 8a51fa4b83)
2025-03-27 16:53:35 -07:00
Prakhar Pratyush
dfa6f67ea8 inline_topic_edit: Fix inline topic edit input field width for topic="".
Earlier, for topic="" and mandatory_topics=False, the inline topic
edit input field width was not set correctly when the inline topic
edit was started for the first time.

This resulted in overflowing placeholder.

This commit fixes that bug.

(cherry picked from commit b53327dabe)
2025-03-27 16:53:35 -07:00
Karl Stolley
c9ffd17d2d home_views: Let Recents/Inbox view filter fit content.
(cherry picked from commit 55ea5be022)
2025-03-27 16:53:35 -07:00
Aman Agrawal
f1461c5334 message_view: Only show just to unread banner in conversation views.
Showing this banner in every view can be annoying. As a first step,
we only show it in conversation view to reduce the banner spam.

(cherry picked from commit 6c81ff61ee)
2025-03-27 16:53:35 -07:00
Aman Agrawal
03ecbd6654 channel_settings: Fix channel name incorrect hidden.
`max-width` was not working correctly here. Removing it gets
us in good state.

(cherry picked from commit 5ad100afef)
2025-03-27 16:53:35 -07:00
Tim Abbott
338fd40ab0 backends: Fix exception with password lengths above 72.
Apparently, while we set our own maximum password length of 100
characters, zxcvbn had a hardcoded maximum length of 72 characters,
and threw an exception if that was exceeded.

The fact that we're discovering this now would suggest that nobody has
previously attempted a password between 72 and 100 characters in
length.

(cherry picked from commit 37b7a32eb4)
2025-03-27 16:53:35 -07:00
Lauryn Menard
44fdbe5f04 compose-closed-ui: Refactor get_recipient_label.
Refactors get_recipient_label so that it's a bit clearer what the
recipient_information parameter is for and what we do when that
parameter is undefined.

In doing so, we no longer treat the constructed objects, that are
passed as the recipient_information parameter, and actual Message
objects, that we get from the current message list view, as the
same thing.

(cherry picked from commit 7d3b77e490)
2025-03-27 16:53:35 -07:00
Lauryn Menard
9080684585 compose-closed-ui: Fix inbox and recent views not updating button.
Both the inbox and recent conversation views pass information about
the reply recipient to this function's caller so that the button
text can be updated for the focused row.

Therefore, the check for an undefined current message list should
be inside the case where the recipient information parameter is
undefined.

This was changed in f630272b4c when non-message list views set
undefined for the current message list.

(cherry picked from commit 4f163e5ad2)
2025-03-27 16:53:35 -07:00
Lauryn Menard
2efef3a0e6 compose-closed-ui: Clarify object and type for reply to button.
Renames ComposeClosedMessage to ReplyRecipientInformation, and
exports the type from compose_closed_ui.ts so that the functions
that construct these objects from the recent conversations and
inbox views have the type available.

Also, renames the variables for these objects to not be "message",
so that it's clear that these are not Message objects.

(cherry picked from commit b48134a03e)
2025-03-27 16:53:35 -07:00
Lauryn Menard
1345944688 compose-closed-ui: Rename update_reply_recipient_label.
Renames update_reply_recipient_label to
update_recipient_text_for_reply_button.

This better matches the function that sets the default text for
the closed compose box button: set_standard_text_for_reply_button.

(cherry picked from commit 94fe5fc173)
2025-03-27 16:53:35 -07:00
Aman Agrawal
2dd46fe522 compose_validate: Make validate single source for error messages.
Instead of relying on different functions to get error messages,
we use `validate` to get the error message for the current compose
state.

This fixes a bug where compose tooltip was not defined when
compose state was not valid.

(cherry picked from commit 01c5197dd9)
2025-03-27 16:53:35 -07:00
Aman Agrawal
698c827693 compose_validate: Extract banner text to be re-used.
(cherry picked from commit 0463f3ab1e)
2025-03-27 16:53:35 -07:00
Aman Agrawal
319e6de495 compose_validate: Don't mix validation states for compose and edit msg.
`message_too_long` is only used to track state for compose box
and not message edit.

(cherry picked from commit 8a9363c6b7)
2025-03-27 16:53:35 -07:00
Aman Agrawal
fa0da5e9ab compose: Reduce repeated no posting policy message to couple places.
(cherry picked from commit 9ce4cead44)
2025-03-27 16:53:35 -07:00
Aman Agrawal
b047bf4d44 compose: Remove old style compose send disable.
We now use `disabled-message-send-controls` class to control
the disabled status of send button. So, this is not required.

(cherry picked from commit ad0b616bbd)
2025-03-27 16:53:35 -07:00
Sayam Samal
260c56ab0c banners: Fix banner action buttons vertical alignment.
(cherry picked from commit 5f0a55544b)
2025-03-27 16:53:35 -07:00
Sayam Samal
b7919e1957 banners: Update banner layout.
This is follow-up commit for d00b4cb0bd,
which updates the padding of the banner label and banner close button
to accommodate the previous font size change.

(cherry picked from commit 836e04fac8)
2025-03-27 16:53:35 -07:00
Sayam Samal
26c983d717 banners: Improve banner scaling with font size.
This commit converts the pixel values to em instead to make the banner
scale better with the different font sizes.

(cherry picked from commit bf88426cd1)
2025-03-27 16:53:35 -07:00
Aman Agrawal
c1d321012a registration: Fix language code missing for find_team emails.
If there were no users found for `find_team`, we need to provide
a default langauge for the email as one cannot be extracted from
`UserProfile` in this case.

(cherry picked from commit e6dd79f241)
2025-03-27 16:53:35 -07:00
Shubham Padia
ac9a97e1a7 streams: Use bulk function in can_access_stream_metadata_user_ids.
Performance remains the same whether we're using the bulk function
underneath the function in question or not, this helps us avoid
duplication.

(cherry picked from commit 2e48293e4b)
2025-03-27 16:53:35 -07:00
Shubham Padia
e6f3c15a92 test_subs: Check query count for can_access_stream_metadata_user_ids.
We want this function to just use the bulk function instead underneath,
we add a query count check here so that when we do that replace in the
next commit, we can make sure that the query count has not increased.

(cherry picked from commit 0570bfa90c)
2025-03-27 16:53:35 -07:00
Shubham Padia
06172ea126 user_groups: Send metadata access related events on remove subgroups.
Fixes #33420.

(cherry picked from commit 6833ad8a21)
2025-03-27 16:53:35 -07:00
Shubham Padia
003ea23eb3 user_groups: Send metadata access related events on add subgroups.
(cherry picked from commit c06dae81fb)
2025-03-27 16:53:35 -07:00
Shubham Padia
e9f1d5a4ca user_groups: Send metadata access related events on remove members.
(cherry picked from commit e089eb0fa1)
2025-03-27 16:53:35 -07:00
Shubham Padia
5aeda0417d user_groups: Send metadata access related events on add members.
(cherry picked from commit 8c069674b0)
2025-03-27 16:53:35 -07:00
Shubham Padia
7f85d045db streams: Add get_metadata_access_streams_via_group_ids.
(cherry picked from commit b62d51f0ae)
2025-03-27 16:53:35 -07:00
Shubham Padia
1e896d9878 user_groups: Add get_recursive_supergroups_union_for_groups.
This function will be useful in finding out affected groups when
sending events for users gaining or losing metadata access when the
members of a user group change in any way.

(cherry picked from commit 139679cdb1)
2025-03-27 16:53:35 -07:00
Shubham Padia
5d947cb501 streams: Add bulk_access_stream_metadata_user_ids.
This function will be useful in sending events for users gaining or
losing metadata access when the members of a user group change in any
way.

(cherry picked from commit 208ee1b8d9)
2025-03-27 16:53:35 -07:00
Alex Vandiver
b99656d3f2 message: Enforce no parallel bitmap heap scans.
(cherry picked from commit 74d6f033b0)
2025-03-27 16:53:35 -07:00
Alex Vandiver
611085abc2 message: Move join to recipients outside of LIMIT, via CTE.
(cherry picked from commit 70ed1ee46c)
2025-03-27 16:53:35 -07:00
Evy Kassirer
c1a00e7308 stream_settings: Move muted channels help text to below header.
(cherry picked from commit 8adb46867b)
2025-03-27 16:53:35 -07:00
Karl Stolley
c2fc886a8a left_sidebar: Avoid misaligned unreads on Safari.
(cherry picked from commit 852b957da8)
2025-03-27 16:53:35 -07:00
Aman Agrawal
c2484b01de stream_settings: Fix action items overflowing to next container.
`+` icon was overflowing the right pane on 18px font size.

(cherry picked from commit fde6278e34)
2025-03-26 10:55:07 -07:00
Aman Agrawal
a42d31bcb2 subscriptions: Fix wrapping of channel name above 18px font size.
We limit the width of the channel title and show ellipsis for
overflowing channel name.

(cherry picked from commit c7364fafe5)
2025-03-26 10:55:07 -07:00
Aman Agrawal
e3d4677ac5 stream_settings: Fix incorrect use of translated strings in conditions.
Since the strings like "Subscribed" will be translated to other
languages, they will not work correctly when compared with
these values in other languages.

Fixed by using data values that are not translated.

(cherry picked from commit 3429898dbf)
2025-03-26 10:55:07 -07:00
Aman Agrawal
f12d72d711 stream_list_sort: Fix user setting for demote inactive stream ignored.
We were not using `filter_out_inactives` and `pin_to_top` when
sorting stream in the left sidebar.

These were incorrectly removed in
1aee0ef98b.

Restored the original function and the places where it was used
to bring back original functionality.

(cherry picked from commit 67ff430e45)
2025-03-26 10:55:07 -07:00
Aman Agrawal
30e52c90e5 stream_list_sort: Remove unused function.
This function was introduced in c1b5021d84
but was never used.

(cherry picked from commit 87864992a8)
2025-03-26 10:55:07 -07:00
Aman Agrawal
b78307d559 stream_list: Set filter_out_inactives before rendering.
When initializing the app or re-rendering left sidebar to update
the `demote_inactive_streams` property, we need to update
`filter_out_inactives` property first.

(cherry picked from commit 737acb1cb1)
2025-03-26 10:55:07 -07:00
Alex Vandiver
f902a39ac9 nginx: Allow adding extra monitoring paths in a localhost.d.
(cherry picked from commit 023e634e98)
2025-03-26 10:55:07 -07:00
Mateusz Mandera
6ef28773a1 migrations: Fix migration 0574 to handle edge-case DirectMessageGroups.
The Django ORM query would not work correctly for pathological
DirectMessageGroups with no Subscription rows. When the Subquery gave
empty results, the UPDATE would set group_size to null - when the point
of the migration was exactly to make sure this column is always set and
allow making the column non-nullable in 0575.

Such DirectMessageGroups can occur as a result doing .delete() on all
UserProfiles that were in the group - or by doing realm deletion via
either .delete() or `manage.py delete_realm`.

The natural choice for group_size of these objects is 0. The simple SQL
query naturally achieves this result, without needing any special
handling for this case.

(cherry picked from commit 60e166bcd0)
2025-03-26 10:55:07 -07:00
Anders Kaseorg
1a82ce38af install: Move ourself to deployments path before creating venv.
This prevents the venv from ending up with references to /root.

Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit c517e95e6b)
2025-03-26 10:55:07 -07:00
Anders Kaseorg
f972e4f832 docs: Work around uv bugs in Ubuntu 24.04 upgrade instructions.
Signed-off-by: Anders Kaseorg <anders@zulip.com>
(cherry picked from commit 343e9a99ae)
2025-03-26 10:55:07 -07:00
Tim Abbott
367f1e634a version: Update version and docs after 10.0 release. 2025-03-20 13:58:34 -07:00
3472 changed files with 179481 additions and 274508 deletions

View File

@@ -29,4 +29,3 @@ ges
assertIn
thirdparty
asend
COO

4
.gitattributes vendored
View File

@@ -20,6 +20,7 @@ corporate/tests/stripe_fixtures/*.json -diff
*.eot binary
*.woff binary
*.woff2 binary
*.svg binary
*.ttf binary
*.png binary
*.otf binary
@@ -29,6 +30,3 @@ corporate/tests/stripe_fixtures/*.json -diff
*.bmp binary
*.mp3 binary
*.pdf binary
# Treat SVG files as code for diffing purposes.
*.svg diff

View File

@@ -11,8 +11,8 @@ labels: ["bug"]
**Zulip Server and web app version:**
- [ ] Zulip Cloud (`*.zulipchat.com`)
- [ ] Zulip Server 11.x
- [ ] Zulip Server 10.x
- [ ] Zulip Server 9.x
- [ ] Zulip Server 8.x or older
- [ ] Zulip Server 8.x
- [ ] Zulip Server 7.x
- [ ] Zulip Server 6.x or older
- [ ] Other or not sure

View File

@@ -1,46 +0,0 @@
name: Check feature level updated
on:
push:
branches: [main]
paths:
- "api_docs/**"
workflow_dispatch:
jobs:
check-feature-level-updated:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Add required permissions
run: chmod +x ./tools/check-feature-level-updated
- name: Run tools/check-feature-level-updated
id: run_check
run: ./tools/check-feature-level-updated >> $GITHUB_OUTPUT
- name: Report status to CZO
if: ${{ steps.run_check.outputs.fail == 'true' && github.repository == 'zulip/zulip'}}
uses: zulip/github-actions-zulip/send-message@v1
with:
api-key: ${{ secrets.ZULIP_BOT_KEY }}
email: "github-actions-bot@chat.zulip.org"
organization-url: "https://chat.zulip.org"
to: "automated testing"
topic: ${{ steps.run_check.outputs.topic }}
type: "stream"
content: ${{ steps.run_check.outputs.content }}
- name: Fail job if feature level not updated in API docs
if: ${{ steps.run_check.outputs.fail == 'true' }}
run: exit 1

View File

@@ -154,11 +154,6 @@ jobs:
os: bookworm
extra-args: --test-custom-db
- docker_image: zulip/ci:trixie
name: Debian 13 production install
os: trixie
extra-args: ""
name: ${{ matrix.name }}
container:
image: ${{ matrix.docker_image }}

View File

@@ -28,7 +28,7 @@ jobs:
fail-fast: false
matrix:
include:
# Base images are built using `tools/ci/Dockerfile`.
# Base images are built using `tools/ci/Dockerfile.prod.template`.
# The comments at the top explain how to build and upload these images.
# Ubuntu 22.04 ships with Python 3.10.12.
- docker_image: zulip/ci:jammy
@@ -48,12 +48,6 @@ jobs:
os: noble
include_documentation_tests: false
include_frontend_tests: false
# Debian 13 ships with Python 3.13.5.
- docker_image: zulip/ci:trixie
name: Debian 13 (Python 3.13, backend)
os: trixie
include_documentation_tests: false
include_frontend_tests: false
runs-on: ubuntu-latest
name: ${{ matrix.name }}

View File

@@ -31,7 +31,6 @@ Aman Agrawal <amanagr@zulip.com>
Aman Agrawal <amanagr@zulip.com> <f2016561@pilani.bits-pilani.ac.in>
Aman Vishwakarma <vishwakarmarambhawan572@gmail.com>
Aman Vishwakarma <vishwakarmarambhawan572@gmail.com> <185982038+whilstsomebody@users.noreply.github.com>
Aman Vishwakarma <vishwakarmarambhawan572@gmail.com> <whilstsomebody@gmail.com>
Anders Kaseorg <anders@zulip.com> <anders@zulipchat.com>
Anders Kaseorg <anders@zulip.com> <andersk@mit.edu>
aparna-bhatt <aparnabhatt2001@gmail.com> <86338542+aparna-bhatt@users.noreply.github.com>

40
.tx/config Normal file
View File

@@ -0,0 +1,40 @@
# Migrated from transifex-client format with `tx migrate`
#
# See https://developers.transifex.com/docs/using-the-client which hints at
# this format, but in general, the headings are in the format of:
#
# [o:<org>:p:<project>:r:<resource>]
[main]
host = https://www.transifex.com
lang_map = zh-Hans: zh_Hans
[o:zulip:p:zulip:r:djangopo]
file_filter = locale/<lang>/LC_MESSAGES/django.po
source_file = locale/en/LC_MESSAGES/django.po
source_lang = en
type = PO
[o:zulip:p:zulip:r:mobile]
file_filter = locale/<lang>/mobile.json
source_file = locale/en/mobile.json
source_lang = en
type = KEYVALUEJSON
[o:zulip:p:zulip:r:translationsjson]
file_filter = locale/<lang>/translations.json
source_file = locale/en/translations.json
source_lang = en
type = KEYVALUEJSON
[o:zulip:p:zulip-test:r:djangopo]
file_filter = locale/<lang>/LC_MESSAGES/django.po
source_file = locale/en/LC_MESSAGES/django.po
source_lang = en
type = PO
[o:zulip:p:zulip-test:r:translationsjson]
file_filter = locale/<lang>/translations.json
source_file = locale/en/translations.json
source_lang = en
type = KEYVALUEJSON

View File

@@ -8,8 +8,8 @@ Zulip every day. Zulip is the only [modern team chat app][features] that is
designed for both live and asynchronous conversations.
Zulip is built by a distributed community of developers from all around the
world, with 97+ people who have each contributed 100+ commits. With
over 1,500 contributors merging over 500 commits a month, Zulip is the
world, with 74+ people who have each contributed 100+ commits. With
over 1000 contributors merging over 500 commits a month, Zulip is the
largest and fastest growing open source team chat project.
Come find us on the [development community chat](https://zulip.com/development-community/)!

View File

@@ -631,9 +631,9 @@ def count_message_type_by_user_query(realm: Realm | None) -> QueryFn:
(
SELECT zerver_userprofile.realm_id, zerver_userprofile.id, count(*),
CASE WHEN
zerver_recipient.type = 1 OR (zerver_recipient.type = 3 AND zerver_huddle.group_size <= 2) THEN 'private_message'
zerver_recipient.type = 1 THEN 'private_message'
WHEN
zerver_recipient.type = 3 AND zerver_huddle.group_size > 2 THEN 'huddle_message'
zerver_recipient.type = 3 THEN 'huddle_message'
WHEN
zerver_stream.invite_only = TRUE THEN 'private_stream'
ELSE 'public_stream'
@@ -650,15 +650,12 @@ def count_message_type_by_user_query(realm: Realm | None) -> QueryFn:
JOIN zerver_recipient
ON
zerver_message.recipient_id = zerver_recipient.id
LEFT JOIN zerver_huddle
ON
zerver_recipient.type_id = zerver_huddle.id
LEFT JOIN zerver_stream
ON
zerver_recipient.type_id = zerver_stream.id
GROUP BY
zerver_userprofile.realm_id, zerver_userprofile.id,
zerver_recipient.type, zerver_stream.invite_only, zerver_huddle.group_size
zerver_recipient.type, zerver_stream.invite_only
) AS subquery
GROUP BY realm_id, id, message_type
"""

View File

@@ -22,11 +22,10 @@ from zerver.lib.create_user import create_user
from zerver.lib.management import ZulipBaseCommand
from zerver.lib.storage import static_path
from zerver.lib.stream_color import STREAM_ASSIGNMENT_COLORS
from zerver.lib.stream_subscription import create_stream_subscription
from zerver.lib.streams import get_default_values_for_stream_permission_group_settings
from zerver.lib.timestamp import floor_to_day
from zerver.lib.upload import upload_message_attachment_from_request
from zerver.models import Client, Realm, RealmAuditLog, Recipient, Stream, UserProfile
from zerver.models import Client, Realm, RealmAuditLog, Recipient, Stream, Subscription, UserProfile
from zerver.models.groups import NamedUserGroup, SystemGroups, UserGroupMembership
from zerver.models.realm_audit_logs import AuditLogEventType
@@ -126,10 +125,10 @@ class Command(ZulipBaseCommand):
stream.save(update_fields=["recipient"])
# Subscribe shylock to the stream to avoid invariant failures.
create_stream_subscription(
user_profile=shylock,
Subscription.objects.create(
recipient=recipient,
stream=stream,
user_profile=shylock,
is_user_active=shylock.is_active,
color=STREAM_ASSIGNMENT_COLORS[0],
)
RealmAuditLog.objects.create(

View File

@@ -10,7 +10,7 @@ from django.utils.timezone import now as timezone_now
from typing_extensions import override
from analytics.lib.counts import ALL_COUNT_STATS, logger, process_count_stat
from zerver.lib.management import ZulipBaseCommand, abort_cron_during_deploy, abort_unless_locked
from zerver.lib.management import ZulipBaseCommand, abort_unless_locked
from zerver.lib.remote_server import send_server_data_to_push_bouncer, should_send_analytics_data
from zerver.lib.timestamp import floor_to_hour
from zerver.models import Realm
@@ -38,7 +38,6 @@ class Command(ZulipBaseCommand):
)
@override
@abort_cron_during_deploy
@abort_unless_locked
def handle(self, *args: Any, **options: Any) -> None:
self.run_update_analytics_counts(options)

View File

@@ -50,7 +50,11 @@ from zerver.actions.user_activity import update_user_activity_interval
from zerver.actions.users import do_deactivate_user
from zerver.lib.create_user import create_user
from zerver.lib.exceptions import InvitationError
from zerver.lib.push_notifications import get_message_payload_apns, get_message_payload_gcm
from zerver.lib.push_notifications import (
get_message_payload_apns,
get_message_payload_gcm,
hex_to_b64,
)
from zerver.lib.streams import get_default_values_for_stream_permission_group_settings
from zerver.lib.test_classes import ZulipTestCase
from zerver.lib.test_helpers import activate_push_notification_service
@@ -72,9 +76,8 @@ from zerver.models import (
from zerver.models.clients import get_client
from zerver.models.messages import Attachment
from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.models.recipients import get_or_create_direct_message_group
from zerver.models.scheduled_jobs import NotificationTriggers
from zerver.models.users import get_user_by_delivery_email, is_cross_realm_bot_email
from zerver.models.users import get_user, is_cross_realm_bot_email
from zilencer.models import (
RemoteInstallationCount,
RemotePushDeviceToken,
@@ -504,8 +507,8 @@ class TestCountStats(AnalyticsTestCase):
name=f"stream {minutes_ago}", realm=self.second_realm, date_created=creation_time
)[1]
self.create_message(user, recipient, date_sent=creation_time)
self.hourly_user = get_user_by_delivery_email("user-1@second.analytics", self.second_realm)
self.daily_user = get_user_by_delivery_email("user-61@second.analytics", self.second_realm)
self.hourly_user = get_user("user-1@second.analytics", self.second_realm)
self.daily_user = get_user("user-61@second.analytics", self.second_realm)
# This realm should not show up in the *Count tables for any of the
# messages_* CountStats
@@ -718,56 +721,6 @@ class TestCountStats(AnalyticsTestCase):
)
self.assertTableState(StreamCount, [], [])
def test_1_to_1_and_self_messages_sent_by_message_type_using_direct_group_message(self) -> None:
stat = COUNT_STATS["messages_sent:message_type:day"]
self.current_property = stat.property
user1 = self.create_user(is_bot=True)
user2 = self.create_user()
user3 = self.create_user()
user1_and_user2_dm_group = get_or_create_direct_message_group([user1.id, user2.id])
user2_and_user3_dm_group = get_or_create_direct_message_group([user2.id, user3.id])
user2_dm_group = get_or_create_direct_message_group([user2.id])
assert user1_and_user2_dm_group.recipient is not None
assert user2_and_user3_dm_group.recipient is not None
assert user2_dm_group.recipient is not None
self.create_message(user1, user1_and_user2_dm_group.recipient)
self.create_message(user2, user2_and_user3_dm_group.recipient)
self.create_message(user2, user2_dm_group.recipient)
do_fill_count_stat_at_hour(stat, self.TIME_ZERO)
self.assertTableState(
UserCount,
["value", "subgroup", "user"],
[
[1, "private_message", user1],
[2, "private_message", user2],
[1, "public_stream", self.hourly_user],
[1, "public_stream", self.daily_user],
],
)
self.assertTableState(
RealmCount,
["value", "subgroup", "realm"],
[
[3, "private_message"],
[2, "public_stream", self.second_realm],
],
)
self.assertTableState(
InstallationCount,
["value", "subgroup"],
[
[3, "private_message"],
[2, "public_stream"],
],
)
self.assertTableState(StreamCount, [], [])
def test_messages_sent_by_message_type_realm_constraint(self) -> None:
# For single Realm
@@ -1416,19 +1369,19 @@ class TestLoggingCountStats(AnalyticsTestCase):
RemotePushDeviceToken.objects.create(
kind=RemotePushDeviceToken.FCM,
token=token,
token=hex_to_b64(token),
user_uuid=(hamlet.uuid),
server=self.server,
)
RemotePushDeviceToken.objects.create(
kind=RemotePushDeviceToken.FCM,
token=token + "aa",
token=hex_to_b64(token + "aa"),
user_uuid=(hamlet.uuid),
server=self.server,
)
RemotePushDeviceToken.objects.create(
kind=RemotePushDeviceToken.APNS,
token=token,
token=hex_to_b64(token),
user_uuid=str(hamlet.uuid),
server=self.server,
)

View File

@@ -675,15 +675,15 @@ class TestMapArrays(ZulipTestCase):
result,
{
"Old desktop app": [32, 36, 39],
"Ancient iOS app": [1, 2, 3],
"Old iOS app": [1, 2, 3],
"Desktop app": [2, 5, 7],
"Old mobile app (React Native)": [1, 2, 3],
"Mobile app (Flutter)": [2, 2, 2],
"Mobile app (React Native)": [1, 2, 3],
"Mobile app beta (Flutter)": [2, 2, 2],
"Web app": [1, 2, 3],
"Python API": [2, 4, 6],
"SomethingRandom": [4, 5, 6],
"GitHub webhook": [7, 7, 9],
"Ancient Android app": [64, 63, 65],
"Old Android app": [64, 63, 65],
"Terminal app": [9, 10, 11],
},
)

View File

@@ -599,13 +599,13 @@ def client_label_map(name: str) -> str:
if name == "ZulipTerminal":
return "Terminal app"
if name == "ZulipAndroid":
return "Ancient Android app"
return "Old Android app"
if name == "ZulipiOS":
return "Ancient iOS app"
return "Old iOS app"
if name == "ZulipMobile":
return "Old mobile app (React Native)"
return "Mobile app (React Native)"
if name in ["ZulipFlutter", "ZulipMobile/flutter"]:
return "Mobile app (Flutter)"
return "Mobile app beta (Flutter)"
if name in ["ZulipPython", "API: Python"]:
return "Python API"
if name.startswith("Zulip") and name.endswith("Webhook"):
@@ -619,9 +619,9 @@ def rewrite_client_arrays(value_arrays: dict[str, list[int]]) -> dict[str, list[
mapped_label = client_label_map(label)
if mapped_label in mapped_arrays:
for i in range(len(array)):
mapped_arrays[mapped_label][i] += array[i]
mapped_arrays[mapped_label][i] += value_arrays[label][i]
else:
mapped_arrays[mapped_label] = array.copy()
mapped_arrays[mapped_label] = [value_arrays[label][i] for i in range(len(array))]
return mapped_arrays

View File

@@ -18,480 +18,13 @@ clients should check the `zulip_feature_level` field, present in the
/register`](/api/register-queue) responses, to determine the API
format used by the Zulip server that they are interacting with.
## Changes in Zulip 12.0
Feature levels 421-424 reserved for future use in 11.x maintenance
releases.
## Changes in Zulip 11.0
**Feature level 421**
No changes; API feature level used for the Zulip 11.0 release.
**Feature level 420**
* [`POST /mobile_push/e2ee/test_notification`](/api/e2ee-test-notify):
Added a new endpoint to send an end-to-end encrypted test push notification
to the user's selected mobile device or all of their mobile devices.
**Feature level 419**
* [`POST /register`](/api/register-queue): Added `simplified_presence_events`
[client capability](/api/register-queue#parameter-client_capabilities),
which allows clients to specify whether they support receiving the
`presence` event type with user presence data in the modern API format.
* [`GET /events`](/api/get-events): Added the `presences` field to the
`presence` event type for clients that support the `simplified_presence_events`
[client capability](/api/register-queue#parameter-client_capabilities).
The `presences` field will have the user presence data in the modern
API format. For clients that don't support that client capability the
event will contain fields with the legacy format for user presence data.
**Feature level 418**
* [`GET /events`](/api/get-events): An event with `type: "channel_folder"`
and `op: "reorder"` is sent when channel folders are reordered.
**Feature level 417**
* [`POST channels/create`](/api/create-channel): Added a dedicated
endpoint for creating a new channel. Previously, channel creation
was done entirely through
[`POST /users/me/subscriptions`](/api/subscribe).
**Feature level 416**
* [`POST /invites`](/api/send-invites), [`POST
/invites/multiuse`](/api/create-invite-link): Added a new parameter
`welcome_message_custom_text` which allows the users to add a
Welcome Bot custom message for new users through invitations.
* [`POST /register`](/api/register-queue), [`POST /events`](/api/get-events),
`PATCH /realm`: Added `welcome_message_custom_text` realm setting which is the
default custom message for the Welcome Bot when sending invitations to new users.
* [`POST /realm/test_welcome_bot_custom_message`](/api/test-welcome-bot-custom-message):
Added new endpoint test messages with the Welcome Bot custom message. The test
messages are sent to the acting administrator, allowing them to preview how the
custom welcome message will appear to new users upon joining the organization.
**Feature level 415**
* [`POST /reminders`](/api/create-message-reminder): Added parameter
`note` to allow users to add notes to their reminders.
* [`POST /register`](/api/register-queue): Added `max_reminder_note_length`
for clients to restrict the reminder note length before sending it to
the server.
**Feature level 414**
* [`POST /channel_folders/create`](/api/create-channel-folder),
[`GET /channel_folders`](/api/get-channel-folders),
[`PATCH /channel_folders/{channel_folder_id}`](/api/update-channel-folder):
Added a new field `order` to show in which order should channel folders be
displayed. The list is 0-indexed and works similar to the `order` field of
custom profile fields.
* [`PATCH /channel_folders`](/api/patch-channel-folders): Added a new
endpoint for reordering channel folders. It accepts an array of channel
folder IDs arranged in the order the user desires it to be in.
* [`GET /channel_folders`](/api/get-channel-folders): Channel folders will
be ordered by the `order` field instead of `id` field when being returned.
**Feature level 413**
* Mobile push notification payloads for APNs no longer contain the
`server` and `realm_id` fields, which were unused.
* Mobile push notification payloads for FCM to remove push
notifications no longer contain the legacy pre-2019
`zulip_message_id` field; all functional clients support the newer
`zulip_message_ids`.
* Mobile push notification payloads for FCM to for new messages no
longer contain the (unused) `content_truncated` boolean field.
- E2EE mobile push notification payloads now have a [modernized and
documented format](/api/mobile-notifications).
**Feature level 412**
* [`POST /register`](/api/register-queue),
[`GET /users/me/subscriptions`](/api/get-subscriptions):
Added support for passing `partial` as argument to `include_subscribers`
parameter to get only partial subscribers data of the channel.
* [`POST /register`](/api/register-queue),
[`GET /users/me/subscriptions`](/api/get-subscriptions):
Added `partial_subscribers` field in `subscription` objects.
**Feature level 411**
* [`POST /register`](/api/register-queue), [`PATCH /settings`](/api/update-settings),
[`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults):
Added new `web_left_sidebar_show_channel_folders` display setting,
controlling whether any [channel folders](/help/channel-folders)
configured by the organization are displayed in the left sidebar.
**Feature level 410**
* [`POST /register`](/api/register-queue): Added
`max_channel_folder_name_length` and
`max_channel_folder_description_length` fields to the response.
* Mobile push notification payloads for APNs no longer contain the
`time` field, which was unused.
**Feature level 409**
* `PATCH /realm`, [`POST /register`](/api/register-queue),
[`GET /events`](/api/get-events): Added a new
`require_e2ee_push_notifications` realm setting.
**Feature level 407**
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
[`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added `can_delete_any_message_group`
field which is a [group-setting value](/api/group-setting-values) describing the
set of users with permissions to delete any message in the channel.
* [`POST /users/me/subscriptions`](/api/subscribe),
[`PATCH /streams/{stream_id}`](/api/update-stream): Added
`can_delete_any_message_group` parameter to support setting and
changing the user group whose members can delete any message in the specified
channel.
* `PATCH /realm`, [`POST /register`](/api/register-queue),
[`GET /events`](/api/get-events): Added `can_set_delete_message_policy_group`
realm setting, which is a [group-setting value](/api/group-setting-values)
describing the set of users with permission to change per-channel
`can_delete_any_message_group` and `can_delete_own_message_group` settings.
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
[`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added `can_delete_own_message_group`
field which is a [group-setting value](/api/group-setting-values) describing the
set of users with permissions to delete the messages they have sent in the channel.
* [`POST /users/me/subscriptions`](/api/subscribe),
[`PATCH /streams/{stream_id}`](/api/update-stream): Added
`can_delete_own_message_group` parameter to support setting and
changing the user group whose members can delete the messages they have sent
in the channel.
- [`POST /users/{user_id}/status`](/api/update-status-for-user): Added
new API endpoint for an administrator to update the status for
another user.
**Feature level 406**
* [`POST /register`](/api/register-queue): Added `push_devices`
field to response.
* [`GET /events`](/api/get-events): A `push_device` event is sent
to clients when registration to bouncer either succeeds or fails.
* [`POST /mobile_push/register`](/api/register-push-device): Added
an endpoint to register a device to receive end-to-end encrypted
mobile push notifications.
**Feature level 405**
* [Message formatting](/api/message-formatting): Added new HTML
formatting for uploaded audio files generating a player experience.
**Feature level 404**
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
[`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added new `"empty_topic_only"`
option to the `topics_policy` field on Stream and Subscription
objects.
* [`POST /users/me/subscriptions`](/api/subscribe),
[`PATCH /streams/{stream_id}`](/api/update-stream): Added new
`"empty_topic_only"` option to `topics_policy` parameter for
["general chat" channels](/help/general-chat-channels).
**Feature level 403**
* [`POST /register`](/api/register-queue): Added a `url_options` object
to the `realm_incoming_webhook_bots` object for incoming webhook
integration URL parameter options. Previously, these optional URL
parameters were included in the `config_options` field (see feature
level 318 entry). The `config_options` object is now reserved for
configuration data that can be set when creating an bot user for a
specific incoming webhook integration.
**Feature level 402**
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
[`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added `can_resolve_topics_group`
which is a [group-setting value](/api/group-setting-values) describing the
set of users with permissions to resolve topics in the channel.
* [`POST /users/me/subscriptions`](/api/subscribe),
[`PATCH /streams/{stream_id}`](/api/update-stream): Added `can_resolve_topics_group`
which is a [group-setting value](/api/group-setting-values) describing the
set of users with permissions to resolve topics in the channel.
**Feature level 401**
* [`POST /register`](/api/register-queue), [`PATCH
/settings`](/api/update-settings), [`PATCH
/realm/user_settings_defaults`](/api/update-realm-user-settings-defaults):
Added new option in user setting `web_channel_default_view`, to navigate
to top unread topic in channel.
**Feature level 400**
* [Markdown message formatting](/api/message-formatting#links-to-channels-topics-and-messages):
The server now prefers the latest message in a topic, not the
oldest, when constructing topic permalinks using the `/with/` operator.
**Feature level 399**
* [`GET /events`](/api/get-events):
Added `reminders` events sent to clients when a user creates
or deletes scheduled messages.
* [`GET /reminders`](/api/get-reminders):
Clients can now request `/reminders` endpoint to fetch all
scheduled reminders.
* [`DELETE /reminders/{reminder_id}`](/api/delete-reminder):
Clients can now delete a scheduled reminder.
**Feature level 398**
* [`POST /register`](/api/register-queue), [`PATCH /settings`](/api/update-settings),
[`PATCH /realm/user_settings_defaults`](/api/update-realm-user-settings-defaults):
Added new `web_left_sidebar_unreads_count_summary` display setting,
controlling whether summary unread counts are displayed in the left sidebar.
**Feature level 397**
* [`POST /users/me/subscriptions`](/api/subscribe): Added parameter
`send_new_subscription_messages` which determines whether the user
would like Notification Bot to notify other users who the request
adds to a channel.
* [`POST /users/me/subscriptions`](/api/subscribe): Added
`new_subscription_messages_sent` to the response, which is only
present if `send_new_subscription_messages` was `true` in the request.
* [`POST /register`](/api/register-queue): Added `max_bulk_new_subscription_messages`
to the response.
**Feature level 396**
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
[`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added `can_move_messages_within_channel_group`
field which is a [group-setting value](/api/group-setting-values) describing the
set of users with permissions to move messages within the channel.
* [`POST /users/me/subscriptions`](/api/subscribe),
[`PATCH /streams/{stream_id}`](/api/update-stream): Added
`can_move_messages_within_channel_group` parameter to support setting and
changing the user group whose members can move messages within the specified
channel.
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
[`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added `can_move_messages_out_of_channel_group`
field which is a [group-setting value](/api/group-setting-values) describing the
set of users with permissions to move messages out of the channel.
* [`POST /users/me/subscriptions`](/api/subscribe),
[`PATCH /streams/{stream_id}`](/api/update-stream): Added
`can_move_messages_out_of_channel_group` parameter to support setting and
changing the user group whose members can move messages out of the specified
channel.
**Feature level 395**
* [Markdown message
formatting](/api/message-formatting#removed-features): Previously,
Zulip's Markdown syntax had special support for previewing Dropbox
albums. Dropbox albums no longer exist, and links to Dropbox folders
now consistently use Zulip's standard open graph preview markup.
**Feature level 394**
* [`POST /register`](/api/register-queue), [`GET
/events`](/api/get-events), [`GET /streams`](/api/get-streams),
[`GET /streams/{stream_id}`](/api/get-stream-by-id): Added a new
field `subscriber_count` to Stream and Subscription objects with the
total number of non-deactivated users who are subscribed to the
channel.
**Feature level 393**
* [`PATCH /messages/{message_id}`](/api/delete-message),
[`POST /register`](/api/register-queue), [`GET /events`](/api/get-events):
In `delete_message` event, all the `message_ids` will now be sorted in
increasing order.
* [`PATCH /messages/{message_id}`](/api/update-message),
[`POST /register`](/api/register-queue), [`GET /events`](/api/get-events):
In `update_message` event, all the `message_ids` will now be sorted in
increasing order.
**Feature level 392**
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
[`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added the `topics_policy`
field to Stream and Subscription objects to support channel-level
configurations for sending messages to the empty ["general chat"
topic](/help/general-chat-topic).
* [`POST /users/me/subscriptions`](/api/subscribe),
[`PATCH /streams/{stream_id}`](/api/update-stream): Added
`topics_policy` parameter to support setting and updating the
channel-level configuration for sending messages to the
empty ["general chat" topic](/help/general-chat-topic).
* `PATCH /realm`, [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added
`can_set_topics_policy_group` realm setting, which is a
[group-setting value](/api/group-setting-values) describing the set
of users with permission to change the per-channel `topics_policy`
setting.
* `PATCH /realm`, [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue):
Added a new realm `topics_policy` setting for the organization's
default policy for sending channel messages to the empty ["general
chat" topic](/help/general-chat-topic).
* [`GET /events`](/api/get-events), [`POST /register`](/api/register-queue):
Deprecated the realm `mandatory_topics` setting in favor of the new
realm `topics_policy` setting.
* `PATCH /realm`: Removed the `mandatory_topics` parameter as it is now
replaced by the realm `topics_policy` setting.
**Feature level 391**
* [`POST /user_groups/{user_group_id}/members`](/api/update-user-group-members),
[`POST /user_groups/{user_group_id}/subgroups`](/api/update-user-group-subgroups):
Adding/removing members and subgroups to a deactivated group is now allowed.
**Feature level 390**
* [`GET /events`](/api/get-events): Events with `type: "navigation_view"` are
sent to the user when a navigation view is created, updated, or removed.
* [`POST /register`](/api/register-queue): Added `navigation_views` field in
response.
* [`GET /navigation_views`](/api/get-navigation-views): Added a new endpoint for
fetching all navigation views of the user.
* [`POST /navigation_views`](/api/add-navigation-view): Added a new endpoint for
creating a new navigation view.
* [`PATCH /navigation_views/{fragment}`](/api/edit-navigation-view): Added a new
endpoint for editing the details of a navigation view.
* [`DELETE /navigation_views/{fragment}`](/api/remove-navigation-view): Added a new
endpoint for removing a navigation view.
**Feature level 389**
* [`POST /channel_folders/create`](/api/create-channel-folder): Added
a new endpoint for creating a new channel folder.
* [`GET /channel_folders`](/api/get-channel-folders): Added a new endpoint
to get all channel folders in the realm.
* [`PATCH /channel_folders/{channel_folder_id}`](/api/update-channel-folder):
Added a new endpoint to update channel folder.
* [`POST /register`](/api/register-queue): Added `channel_folders` field to
response.
* [`GET /events`](/api/get-events): An event with `type: "channel_folder"` is
sent to all users when a channel folder is created.
* [`GET /users/me/subscriptions`](/api/get-subscriptions),
[`GET /streams`](/api/get-streams), [`GET /events`](/api/get-events),
[`POST /register`](/api/register-queue): Added `folder_id` field
to Stream and Subscription objects.
* [`POST /users/me/subscriptions`](/api/subscribe): Added support to add
newly created channels to folder using `folder_id` parameter.
* [`PATCH /streams/{stream_id}`](/api/update-stream): Added support
for updating folder to which the channel belongs.
* [`GET /events`](/api/get-events): An event with `type: "channel_folder"` is
sent to all users when a channel folder is updated.
* [`GET /events`](/api/get-events): `value` field in `stream/update`
events can have `null` when channel is removed from a folder.
**Feature level 388**
* [`PATCH /streams/{stream_id}`](/api/update-stream): Added
`is_archived` parameter to support unarchiving previously archived
channels.
**Feature level 387**
* [`GET /users`](/api/get-users): This endpoint no longer requires
authentication for organizations using the [public access
option](/help/public-access-option).
**Feature level 386**
* [`PATCH /user_groups/{user_group_id}`](/api/update-user-group):
Added support to reactivate groups by passing `deactivated`
parameter as `False`.
**Feature level 385**
* [`POST /register`](/api/register-queue), [`PATCH/settings`](/api/update-settings),
[`PATCH/realm/user_settings_defaults`](/api/update-realm-user-settings-defaults):
Added new `resolved_topic_notice_auto_read_policy` setting, which controls
how resolved-topic notices are marked as read for a user.
**Feature level 384**
* [`GET /users`](/api/get-users): Added `user_ids` query parameter to
fetch data only for the provided `user_ids`.
**Feature level 383**
* [`POST /register`](/api/register-queue), [`PATCH
/settings`](/api/update-settings), [`PATCH
/realm/user_settings_defaults`](/api/update-realm-user-settings-defaults):
Added new option in user setting `web_channel_default_view`, to show
inbox view style list of topics.
**Feature level 382**
* `POST /message/{message_id}/report`: Added a new endpoint for submitting
a moderation request for a message.
**Feature level 381**
* [`POST /reminders`](/api/create-message-reminder): Added a new endpoint to
schedule personal reminder for a message.
**Feature level 380**
* [`POST /register`](/api/register-queue), [`GET
/events`](/api/get-events): The `is_moderator` convenience field now
is true for organization administrators, matching how `is_admin`
works for organization owners.
**Feature level 379**
* [`PATCH /messages/{message_id}`](/api/update-message): Added
optional parameter `prev_content_sha256`, which clients can use to
prevent races with the message being edited by another client.
**Feature level 378**
* [`GET /events`](/api/get-events): Archiving and unarchiving
streams now send `update` events to clients that declared
the `archived_channels` client capability. `delete` and `create`
events are still sent to clients that did not declare
`archived_channels` client capability.
* [`POST /register`](/api/register-queue): The `streams` data
structure now includes archived channels for clients that
declared the `archived_channels` client capability.
**Feature level 377**
* [`GET /events`](/api/get-events): When a user is deactivate, send
`peer_remove` event to all the subscribers of the streams that the
user was subscribed to.
Feature levels 373-376 reserved for future use in 10.x maintenance
releases.
## Changes in Zulip 10.1
## Changes in Zulip 10.0
**Feature level 372**
* [`POST /typing`](/api/set-typing-status): The `"(no topic)"` value
when used for `topic` parameter is now interpreted as an empty string.
## Changes in Zulip 10.0
**Feature level 371**
No changes; feature level used for Zulip 10.0 release.
@@ -1069,14 +602,10 @@ deactivated groups.
**Feature level 318**
* [`POST /register`](/api/register-queue): Renamed the `config` object in the
`realm_incoming_webhook_bots` object to `config_options`. This object now
includes details about optional URL parameters that can be configured when
[generating a URL](/help/generate-integration-url) for an incoming webhook
integration. Previously, this object was reserved for key-value pairs that
indicated that a bot user could be created with additional configuration
data (such as an API key) for that incoming webhook integration, but this
functionality has not been implemented for any existing integrations.
* [`POST /register`](/api/register-queue): Updated
`realm_incoming_webhook_bots` with a new `config_options` key,
defining which options should be offered when creating URLs for this
integration.
**Feature level 317**
@@ -1328,7 +857,7 @@ deactivated groups.
* [`DELETE /saved_snippets/{saved_snippet_id}`](/api/delete-saved-snippet): Added
a new endpoint for deleting saved snippets.
**Feature level 296**
**Feature level 296**:
* [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events),
[`POST /realm/profile_fields`](/api/create-custom-profile-field),
@@ -1492,10 +1021,10 @@ deactivated groups.
now contains the superset of the true value that best approximates the actual
permission setting.
Feature level 279 is reserved for future use in 9.x maintenance
Feature levels 278-279 are reserved for future use in 9.x maintenance
releases.
## Changes in Zulip 9.2
## Changes in Zulip 9.0
**Feature level 278**
@@ -1505,8 +1034,6 @@ releases.
(`image-loading-placeholder`), containing the dimensions of the
original image. Backported change from feature level 287.
## Changes in Zulip 9.0
**Feature level 277**
No changes; feature level used for Zulip 9.0 release.

View File

@@ -42,7 +42,7 @@ Zulip API.
Note that many narrows, including all that lack a `channel` or `channels`
operator, search the current user's personal message history. See
[searching shared history](/help/search-for-messages#search-shared-history)
[searching shared history](/help/search-for-messages#searching-shared-history)
for details.
Clients should note that the `is:unread` filter takes advantage of the
@@ -58,7 +58,7 @@ as an empty string.
## Changes
* In Zulip 10.0 (feature level 366), support was added for a new
* In Zulip 10.0 (feature level ZF-f80735), support was added for a new
`is:muted` operator combination, matching messages in topics and
channels that the user has [muted](/help/mute-a-topic).

View File

@@ -42,7 +42,7 @@ Botserver interaction are:
@**My Bot User** hello world
```
1. The Zulip server sends a POST request to your Botserver endpoint URL:
1. The Zulip server sends a POST request to the Botserver on `https://bot-server.example.com/`:
```
{
@@ -60,7 +60,9 @@ Botserver interaction are:
1. The Botserver searches for a bot to handle the message, and executes your
bot's `handle_message` code.
Your bot's code should work just like it does with `zulip-run-bot`.
Your bot's code should work just like it does with `zulip-run-bot`;
for example, you reply using
[bot_handler.send_reply](writing-bots#bot_handlersend_reply)).
### Installing the Zulip Botserver
@@ -70,34 +72,30 @@ Install the `zulip_botserver` package:
pip3 install zulip_botserver
```
### Create a bot in your Zulip organization
{start_tabs}
1. Navigate to the **Bots** tab of the **Personal settings** menu, and click
**Add a new bot**.
1. Set the **Bot type** to **Outgoing webhook**.
1. Set the **endpoint URL** to `https://<host>:<port>` where `host` is the
hostname of the server you'll be running the Botserver on, and `port` is
the port number. The default port is `5002`.
1. Click **Create bot**. You should see the new bot user in the
**Active bots** panel.
{end_tabs}
### Running a bot using the Zulip Botserver
{start_tabs}
1. [Create your bot](#create-a-bot-in-your-zulip-organization) in your Zulip
organization.
1. Construct the URL for your bot, which will be of the form:
1. Download the `zuliprc` file for the bot created above from the
**Bots** tab of the **Personal settings** menu, by clicking the download
(<i class="fa fa-download"></i>) icon under the bot's name.
```
http://<hostname>:<port>
```
where the `hostname` is the hostname you'll be running the bot
server on, and `port` is the port for it (the recommended default
is `5002`).
1. Register new bot users on the Zulip server's web interface.
* Log in to the Zulip server.
* Navigate to *Personal settings (<i class="zulip-icon zulip-icon-gear"></i>)* -> *Bots* -> *Add a new bot*.
Select *Outgoing webhook* for bot type, fill out the form (using
the URL from above) and click on *Create bot*.
* A new bot user should appear in the *Active bots* panel.
1. Download the `zuliprc` file for your bot from the *Active Bots*
panel, using the download button.
1. Run the Botserver, where `helloworld` is the name of the bot you
want to run:
@@ -107,27 +105,36 @@ pip3 install zulip_botserver
You can specify the port number and various other options; run
`zulip-botserver --help` to see how to do this.
{end_tabs}
1. Congrats, everything is set up! Test your Botserver like you would
test a normal bot.
Congrats, everything is set up! Test your Botserver like you would
test a normal bot.
{end_tabs}
### Running multiple bots using the Zulip Botserver
The Zulip Botserver also supports running multiple bots from a single
Botserver process.
Botserver process. You can do this with the following procedure.
{start_tabs}
1. [Create your bots](#create-a-bot-in-your-zulip-organization)
in your Zulip organization.
1. Download the `botserverrc` from the `your-bots` settings page, using
the "Download config of all active outgoing webhook bots in Zulip
Botserver format." option at the top.
1. Download the `botserverrc` file from the **Bots** tab of the
**Personal settings** menu, using the **Download config of all active
outgoing webhook bots in Zulip Botserver format** option.
1. Open the `botserverrc`. It should contain one or more sections that look like this:
1. Open the `botserverrc`. It should contain one or more sections that look
like this:
```
[]
email=foo-bot@hostname
key=dOHHlyqgpt5g0tVuVl6NHxDLlc9eFRX4
site=http://hostname
token=aQVQmSd6j6IHphJ9m1jhgHdbnhl5ZcsY
```
Each section contains the configuration for an outgoing webhook bot. For each
bot, enter the name of the bot you want to run in the square brackets `[]`.
For example, if we want `foo-bot@hostname` to run the `helloworld` bot, our
new section would look like this:
```
[helloworld]
@@ -135,25 +142,24 @@ Botserver process.
key=dOHHlyqgpt5g0tVuVl6NHxDLlc9eFRX4
site=http://hostname
token=aQVQmSd6j6IHphJ9m1jhgHdbnhl5ZcsY
bot-config-file=~/path/to/helloworld.conf
```
Each section contains the configuration for an outgoing webhook bot.
To run an external bot, enter the path to the bot's python file in the square
brackets `[]`. For example, if we want to run `~/Documents/my_new_bot.py`, our
new section could look like this:
1. For each bot, enter the name of the bot you want to run in the square
brackets `[]`, e.g., the above example applies to the `helloworld` bot.
To run an external bot, enter the path to the bot's python file instead,
e.g., `[~/Documents/my_bot_script.py]`.
```
[~/Documents/my_new_bot.py]
email=foo-bot@hostname
key=dOHHlyqgpt5g0tVuVl6NHxDLlc9eFRX4
site=http://hostname
```
!!! tip ""
The `bot-config-file` setting is needed only for bots that
use a config file.
1. Run the Zulip Botserver by passing the `botserverrc` to it.
1. Run the Zulip Botserver by passing the `botserverrc` to it. The
command format is:
```
zulip-botserver --config-file <path-to-botserverrc> --hostname <address> --port <port>
zulip-botserver --config-file <path_to_botserverrc>
```
If omitted, `hostname` defaults to `127.0.0.1` and `port` to `5002`.

View File

@@ -48,7 +48,7 @@ client = zulip.Client(
If you are working on an integration that you plan to share outside
your organization, you can get help picking a good name in
[#integrations][integrations-channel] in the [Zulip development
`#integrations` in the [Zulip development
community](https://zulip.com/development-community/).
## Rate-limiting response headers
@@ -78,4 +78,3 @@ to a given request, the values returned will be for the strictest
limit.
[rate-limiting-rules]: https://zulip.readthedocs.io/en/latest/production/security-model.html#rate-limiting
[integrations-channel]: https://chat.zulip.org/#narrow/channel/127-integrations/

View File

@@ -18,7 +18,6 @@
* [Mark messages in a channel as read](/api/mark-stream-as-read)
* [Mark messages in a topic as read](/api/mark-topic-as-read)
* [Get a message's read receipts](/api/get-read-receipts)
* [Report a message](/api/report-message)
#### Scheduled messages
@@ -27,12 +26,6 @@
* [Edit a scheduled message](/api/update-scheduled-message)
* [Delete a scheduled message](/api/delete-scheduled-message)
#### Message reminders
* [Create a message reminder](/api/create-message-reminder)
* [Get reminders](/api/get-reminders)
* [Delete a reminder](/api/delete-reminder)
#### Drafts
* [Get drafts](/api/get-drafts)
@@ -44,13 +37,6 @@
* [Edit a saved snippet](/api/edit-saved-snippet)
* [Delete a saved snippet](/api/delete-saved-snippet)
#### Navigation views
* [Get all navigation views](/api/get-navigation-views)
* [Add a navigation view](/api/add-navigation-view)
* [Update the navigation view](/api/edit-navigation-view)
* [Remove a navigation view](/api/remove-navigation-view)
#### Channels
* [Get subscribed channels](/api/get-subscriptions)
@@ -62,7 +48,7 @@
* [Get all channels](/api/get-streams)
* [Get a channel by ID](/api/get-stream-by-id)
* [Get channel ID](/api/get-stream-id)
* [Create a channel](/api/create-channel)
* [Create a channel](/api/create-stream)
* [Update a channel](/api/update-stream)
* [Archive a channel](/api/archive-stream)
* [Get channel's email address](/api/get-stream-email-address)
@@ -72,17 +58,13 @@
* [Delete a topic](/api/delete-topic)
* [Add a default channel](/api/add-default-stream)
* [Remove a default channel](/api/remove-default-stream)
* [Create a channel folder](/api/create-channel-folder)
* [Get channel folders](/api/get-channel-folders)
* [Reorder channel folders](/api/patch-channel-folders)
* [Update a channel folder](/api/update-channel-folder)
#### Users
* [Get a user](/api/get-user)
* [Get a user by email](/api/get-user-by-email)
* [Get own user](/api/get-own-user)
* [Get users](/api/get-users)
* [Get all users](/api/get-users)
* [Create a user](/api/create-user)
* [Update a user](/api/update-user)
* [Update a user by email](/api/update-user-by-email)
@@ -91,7 +73,6 @@
* [Reactivate a user](/api/reactivate-user)
* [Get a user's status](/api/get-user-status)
* [Update your status](/api/update-status)
* [Update user status](/api/update-status-for-user)
* [Set "typing" status](/api/set-typing-status)
* [Set "typing" status for message editing](/api/set-typing-status-for-message-edit)
* [Get a user's presence](/api/get-user-presence)
@@ -144,7 +125,6 @@
* [Get all data exports](/api/get-realm-exports)
* [Create a data export](/api/export-realm)
* [Get data export consent state](/api/get-realm-export-consents)
* [Test welcome bot custom message](/api/test-welcome-bot-custom-message)
#### Real-time events
@@ -157,9 +137,6 @@
* [Fetch an API key (production)](/api/fetch-api-key)
* [Fetch an API key (development only)](/api/dev-fetch-api-key)
* [Send an E2EE test notification to mobile device(s)](/api/e2ee-test-notify)
* [Register E2EE push device](/api/register-push-device)
* [Mobile notifications](/api/mobile-notifications)
* [Send a test notification to mobile device(s)](/api/test-notify)
* [Add an APNs device token](/api/add-apns-token)
* [Remove an APNs device token](/api/remove-apns-token)

View File

@@ -210,108 +210,26 @@ tools which you can use to test your webhook - 2 command line tools and a GUI.
### Webhooks requiring custom configuration
In cases where an incoming webhook integration supports optional URL parameters,
one can use the `url_options` feature. It's a field in the `WebhookIntegration`
class that is used when [generating a URL for an integration](/help/generate-integration-url)
in the web app, which encodes the user input for each URL parameter in the
incoming webhook's URL.
In rare cases, it's necessary for an incoming webhook to require
additional user configuration beyond what is specified in the post
URL. The typical use case for this is APIs like the Stripe API that
require clients to do a callback to get details beyond an opaque
object ID that one would want to include in a Zulip notification.
These URL options are declared as follows:
These configuration options are declared as follows:
```python
WebhookIntegration(
'helloworld',
...
url_options=[
WebhookUrlOption(
name='ignore_private_repositories',
label='Exclude notifications from private repositories',
validator=check_string
),
],
)
WebhookIntegration('helloworld', ['misc'], display_name='Hello World',
config_options=[('HelloWorld API key', 'hw_api_key', check_string)])
```
`url_options` is a list describing the parameters the web app UI should offer when
generating the incoming webhook URL:
`config_options` is a list describing the parameters the user should
configure:
1. A user-facing string describing the field to display to users.
2. The field name you'll use to access this from your `view.py` function.
3. A Validator, used to verify the input is valid.
- `name`: The parameter name that is used to encode the user input in the
integration's webhook URL.
- `label`: A short descriptive label for this URL parameter in the web app UI.
- `validator`: A validator function, which is used to determine the input type
for this option in the UI, and to indicate how to validate the input.
Currently, the web app UI only supports these validators:
- `check_bool` for checkbox/select input.
- `check_string` for text input.
!!! warn ""
**Note**: To add support for other validators, you can update
`web/src/integration_url_modal.ts`. Common validators are available in
`zerver/lib/validator.py`.
In rare cases, it may be necessary for an incoming webhook to require
additional user configuration beyond what is specified in the POST
URL. A typical use case for this would be APIs that require clients
to do a callback to get details beyond an opaque object ID that one
would want to include in a Zulip notification message.
The `config_options` field in the `WebhookIntegration` class is reserved
for this use case.
### WebhookUrlOption presets
The `build_preset_config` method creates `WebhookUrlOption` objects with
pre-configured fields. These preset URL options primarily serve two
purposes:
- To construct common `WebhookUrlOption` objects that are used in various
incoming webhook integrations.
- To construct `WebhookUrlOption` objects with special UI in the web-app
for [generating incoming webhook URLs](/help/generate-integration-url).
Using a preset URL option with the `build_preset_config` method:
```python
# zerver/lib/integrations.py
from zerver.lib.webhooks.common import PresetUrlOption, WebhookUrlOption
# -- snip --
WebhookIntegration(
"github",
# -- snip --
url_options=[
WebhookUrlOption.build_preset_config(PresetUrlOption.BRANCHES),
],
),
```
Currently configured preset URL options:
- **`BRANCHES`**: This preset is intended to be used for [version control
integrations](/integrations/version-control), and adds UI for the user to
configure which branches of a project's repository will trigger Zulip
notification messages. When the user specifies which branches to receive
notifications from, the `branches` parameter will be added to the [generated
integration URL](/help/generate-integration-url). For example, if the user
input `main` and `dev` for the branches of their repository, then
`&branches=main%2Cdev` would be appended to the generated integration URL.
- **`IGNORE_PRIVATE_REPOSITORIES`**: This preset is intended to be used for
[version control integrations](/integrations/version-control), and adds UI
for the user exclude private repositories from triggering Zulip
notification messages. When the user selects this option, the
`ignore_private_repositories` boolean parameter will be added to the
[generated integration URL](/help/generate-integration-url).
- **`MAPPING`**: This preset is intended to be used for [chat-app
integrations](/integrations/communication) (like Slack), and adds a
special option, **Matching Zulip channel**, to the UI for where to send
Zulip notification messages. This special option maps the notification
messages to Zulip channels that match the messages' original channel
name in the third-party app. When selected, this requires setting a
single topic for notification messages, and adds `&mapping=channels`
to the [generated integration URL](/help/generate-integration-url).
Common validators are available in `zerver/lib/validators.py`.
## Step 4: Manually testing the webhook

View File

@@ -17,8 +17,10 @@ If you don't find an integration you need, you can:
pull
request](https://zulip.readthedocs.io/en/latest/contributing/reviewable-prs.html)
to get your integration merged into the main Zulip repository.
- [File an issue](https://github.com/zulip/zulip/issues/new/choose) to request
an integration (if it's a nice-to-have).
- [Contact Zulip Sales](mailto:sales@zulip.com) to inquire about a custom
development contract.
@@ -44,7 +46,7 @@ additional integrations through [Zapier](https://zapier.com/apps) and
{start_tabs}
1. Search [Zapier](https://zapier.com/apps) or [IFTTT](https://ifttt.com/search)
for the product you'd like to connect to Zulip.
for the product you'd like to connect to Zulip.
1. Follow the integration instructions for [Zapier](/integrations/doc/zapier) or
[IFTTT](/integrations/doc/ifttt).
@@ -104,10 +106,13 @@ Sales](mailto:sales@zulip.com).
* If the third-party service supports outgoing webhooks, you likely want to
build an [incoming webhook integration](/api/incoming-webhooks-overview).
* If it doesn't, you may want to write a
[script or plugin integration](/api/non-webhook-integrations).
* The [`zulip-send` tool](/api/send-message) makes it easy to send Zulip
messages from shell scripts.
* Finally, you can
[send messages using Zulip's API](/api/send-message), with bindings for
Python, JavaScript and [other languages](/api/client-libraries).
@@ -117,6 +122,7 @@ Sales](mailto:sales@zulip.com).
* To react to activity inside Zulip, look at Zulip's
[Python framework for interactive bots](/api/running-bots) or
[Zulip's real-time events API](/api/get-events).
* If what you want isn't covered by the above, check out the full
[REST API](/api/rest). The web, mobile, desktop, and terminal apps are
built on top of this API, so it can do anything a human user can do. Most

View File

@@ -62,8 +62,10 @@ The `near` and `with` operators are documented in more detail in the
topic links with the `with` operator, the code doing the rendering may
pick the ID arbitrarily among messages accessible to the client and/or
acting user at the time of rendering. Currently, the server chooses
the message ID to use for `with` operators as the latest message ID in
the topic accessible to the user who wrote the message.
the message ID to use for `with` operators as the oldest message ID in
the topic accessible to the user who wrote the message. In channels
with protected history, this means the same Markdown syntax may be
rendered differently for users who joined at different times.
The older stream/topic link elements include a `data-stream-id`, which
historically was used in order to display the current channel name if
@@ -99,11 +101,7 @@ are as follows:
</a>
```
**Changes**: In Zulip 11.0 (feature level 400), the server switched
its strategy for `with` URL construction to choose the latest
accessible message ID in a topic. Previously, it used the oldest.
Before Zulip 10.0 (feature level 347), the `with` field
**Changes**: Before Zulip 10.0 (feature level 347), the `with` field
was never used in topic link URLs generated by the server; the markup
currently used only for empty topics was used for all topic links.
@@ -115,12 +113,6 @@ In Zulip 10.0 (feature level 319), added Markdown syntax
for linking to a specific message in a conversation. Declared the
`data-stream-id` field to be deprecated as detailed above.
In Zulip 11.0 (feature level 383), clients can decide what
channel view a.stream channel link elements take you to -- i.e.,
the href for those is the default behavior of the link that also
encodes the channel alongside the data-stream-id field, but clients
can override that default based on `web_channel_default_view` setting.
## Image previews
When a Zulip message is sent linking to an uploaded image, Zulip will
@@ -243,55 +235,6 @@ assume that messages sent prior to the introduction of thumbnailing
have been re-rendered to use the new format or have thumbnails
available.
## Video embeddings and previews
When a Zulip message is sent linking to an uploaded video, Zulip may
generate a video preview element with the following format.
``` html
<div class="message_inline_image message_inline_video">
<a href="/user_uploads/path/to/video.mp4">
<video preload="metadata" src="/user_uploads/path/to/video.mp4">
</video>
</a>
</div>
```
## Audio Players
When the Markdown media syntax is used with an uploaded file with an
audio `Content-Type`, Zulip will generate an HTML5 `<audio>` player
element. Supported MIME types are currently `audio/aac`, `audio/flac`,
`audio/mpeg`, and `audio/wav`.
For example, `[file.mp3](/user_uploads/path/to/file.mp3)` renders as:
``` html
<audio controls preload="metadata"
src="/user_uploads/path/to/file.mp3" title="file.mp3">
</audio>
```
If the Zulip server has rewritten the URL of the audio file, it will
provide the URL in a `data-original-url` parameter. The Zulip server
does this for all non-uploaded file audio URLs.
``` html
<audio controls preload="metadata"
data-original-url="https://example.com/path/to/original/file.mp3"
src="https://zulipcdn.example.com/path/to/playable/file.mp3" title="file.mp3">
</audio>
```
Clients that cannot render an audio player are recommended to convert
audio elements into a link to the original URL.
The Zulip server does not validate whether uploaded files with an
audio `Content-Type` are actually playable.
**Changes**: New in Zulip 11.0 (feature level 405).
## Mentions and silent mentions
Zulip markup supports [mentioning](/help/mention-a-user-or-group)
@@ -402,33 +345,7 @@ features.
## Removed features
### Removed legacy Dropbox link preview markup
In Zulip 11.0 (feature level 395), the Zulip server stopped generating
legacy Dropbox link previews. Dropbox links are now previewed just
like standard Zulip image/link previews. However, some legacy Dropbox
previews may exist in existing messages.
Clients are recommended to prune these previews from message HTML;
since they always appear after the actual link, there is no loss of
information/functionality. They can be recognized via the classes
`message_inline_ref`, `message_inline_image_desc`, and
`message_inline_image_title`:
``` html
<div class="message_inline_ref">
<a href="https://www.dropbox.com/sh/cm39k9e04z7fhim/AAAII5NK-9daee3FcF41anEua?dl=" title="Saves">
<img src="/path/to/folder_dropbox.png">
</a>
<div><div class="message_inline_image_title">Saves</div>
<desc class="message_inline_image_desc"></desc>
</div>
</div>
```
### Removed legacy avatar markup
In Zulip 4.0 (feature level 24), the rarely used `!avatar()`
**Changes**: In Zulip 4.0 (feature level 24), the rarely used `!avatar()`
and `!gravatar()` markup syntax, which was never documented and had an
inconsistent syntax, were removed.

View File

@@ -1,130 +0,0 @@
# Mobile notifications
Zulip Server 11.0+ supports end-to-end encryption (E2EE) for mobile
push notifications. Mobile push notifications sent by all Zulip
servers go through Zulip's mobile push notifications service, which
then delivers the notifications through the appropriate
platform-specific push notification service (Google's FCM or Apple's
APNs). E2EE push notifications ensure that mobile notification message
content and metadata is not visible to intermediaries.
Mobile clients that have [registered an E2EE push
device](/api/register-push-device) will receive mobile notifications
end-to-end encrypted by their Zulip server.
This page documents the format of the encrypted JSON-format payloads
that the client will receive through this protocol. The same encrypted
payload formats are used for both Firebase Cloud Messaging (FCM) and
Apple Push Notification service (APNs).
## Payload examples
### New channel message
Sample JSON data that gets encrypted:
```json
{
"channel_id": 10,
"channel_name": "Denmark",
"content": "@test_user_group",
"mentioned_user_group_id": 41,
"mentioned_user_group_name": "test_user_group",
"message_id": 45,
"realm_name": "Zulip Dev",
"realm_url": "http://zulip.testserver",
"recipient_type": "channel",
"sender_avatar_url": "https://secure.gravatar.com/avatar/818c212b9f8830dfef491b3f7da99a14?d=identicon&version=1",
"sender_full_name": "aaron",
"sender_id": 6,
"time": 1754385395,
"topic": "test",
"type": "message",
"user_id": 10
}
```
- The `mentioned_user_group_id` and `mentioned_user_group_name` fields
are only present for messages that mention a group containing the
current user, and triggered a mobile notification because of that
group mention. For example, messages that mention both the user
directly and a group containing the user, these fields will not be
present in the payload, because the direct mention has precedence.
**Changes**: New in Zulip 11.0 (feature level 413).
### New direct message
Sample JSON data that gets encrypted:
```json
{
"content": "test content",
"message_id": 46,
"pm_users": "6,10,12,15",
"realm_name": "Zulip Dev",
"realm_url": "http://zulip.testserver",
"recipient_type": "direct",
"sender_avatar_url": "https://secure.gravatar.com/avatar/818c212b9f8830dfef491b3f7da99a14?d=identicon&version=1",
"sender_full_name": "aaron",
"sender_id": 6,
"time": 1754385290,
"type": "message",
"user_id": 10
}
```
- **Group direct messages**: The `pm_users` string field is only
present for group direct messages, containing a sorted comma-separated
list of all user IDs in the group direct message conversation,
including both `user_id` and `sender_id`.
**Changes**: New in Zulip 11.0 (feature level 413).
### New group direct message
### Remove notifications
When a batch of messages that had previously been included in mobile
notifications are marked as read, are deleted, become inaccessible, or
otherwise should no longer be displayed to the user, a removal
notification is sent.
Sample JSON data that gets encrypted:
```json
{
"message_ids": [
31,
32
],
"realm_name": "Zulip Dev",
"realm_url": "http://zulip.testserver",
"type": "remove",
"user_id": 10
}
```
[zulip-bouncer]: https://zulip.readthedocs.io/en/latest/production/mobile-push-notifications.html#mobile-push-notification-service
**Changes**: New in Zulip 11.0 (feature level 413).
### Test push notification
A user can trigger [sending an E2EE test push notification](/api/e2ee-test-notify)
to the user's selected mobile device or all of their mobile devices.
Sample JSON data that gets encrypted:
```json
{
"realm_name": "Zulip Dev",
"realm_url": "http://zulip.testserver",
"time": 1754577820,
"type": "test",
"user_id": 10
}
```
**Changes**: New in Zulip 11.0 (feature level 420).
## Future work
This page will eventually also document the formats of the APNs and
FCM payloads wrapping the encrypted content.

View File

@@ -93,13 +93,13 @@ Note that an outgoing webhook bot can use the [Zulip REST
API](/api/rest) with its API key in case your bot needs to do
something else, like add an emoji reaction or upload a file.
## Slack-compatible webhook format
## Slack-format webhook format
The Slack-compatible webhook format allows immediate integration with
many third-party systems that already support Slack outgoing webhooks.
The following table details how the Zulip server translates a Zulip
message into the Slack-compatible webhook format.
This interface translates Zulip's outgoing webhook's request into the
format that Slack's outgoing webhook interface sends. As a result,
one should be able to use this to interact with third-party
integrations designed to work with Slack's outgoing webhook interface.
Here's how we fill in the fields that a Slack-format webhook expects:
<table class="table">
<thead>

View File

@@ -36,8 +36,7 @@ by the [events API](/api/get-events).
Note that [`POST /register`](/api/register-queue) also returns an
`is_moderator` boolean property specifying whether the current user is
at least an organization moderator. The property will be true for admins
and owners too.
an organization moderator.
Additionally, user account data include an `is_billing_admin` property
specifying whether the user is a billing administrator for the Zulip

View File

@@ -13,7 +13,7 @@ You'll need:
* An account in a Zulip organization
(e.g., [the Zulip development community](https://zulip.com/development-community/),
`{{ display_host }}`, or a Zulip organization on your own
`<yourSubdomain>.zulipchat.com`, or a Zulip organization on your own
[development](https://zulip.readthedocs.io/en/latest/development/overview.html) or
[production](https://zulip.readthedocs.io/en/latest/production/install.html) server).
* A computer where you're running the bot from.

View File

@@ -0,0 +1,3 @@
* [`POST /register`](/api/register-queue): The `streams` data
structure now includes archived channels for clients that
declared the `archived_channels` client capability.

View File

@@ -0,0 +1,5 @@
* [`GET /events`](/api/get-events): Archiving and unarchiving
streams now send `update` events to clients that declared
the `archived_channels` client capability. `delete` and `create`
events are still sent to clients that did not declare
`archived_channels` client capability.

View File

@@ -1,112 +0,0 @@
# Generated by Django 5.2.3 on 2025-07-04 20:33
from datetime import timedelta
from django.conf import settings
from django.db import migrations, transaction
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.db.models import Max
def migrate_realmcreationkey_to_realmcreationstatus(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
"""
This migration is for switching from using a separate RealmCreationKey class
for realm creation keys to just using the Confirmation system.
The aim is to iterate through all the existing RealmCreationKey and create
a corresponding Confirmation+RealmCreationStatus.
For validity of these objects, we only need to worry about RealmCreationKeys
expired due to time. This is taken care of by making sure we set expiry_date
on the Confirmation we're creating.
The way the RealmCreationKey system worked was to .delete() the RealmCreationKey
objects when they were used - so those simply no longer exist and we don't need to
worry about this case.
"""
CAN_CREATE_REALM = 11
Confirmation = apps.get_model("confirmation", "Confirmation")
ContentType = apps.get_model("contenttypes", "ContentType")
RealmCreationKey = apps.get_model("confirmation", "RealmCreationKey")
RealmCreationStatus = apps.get_model("zerver", "RealmCreationStatus")
realm_creation_status_content_type, created = ContentType.objects.get_or_create(
model="realmcreationstatus", app_label="zerver"
)
BATCH_SIZE = 10000
max_id = RealmCreationKey.objects.aggregate(Max("id"))["id__max"]
if max_id is None:
# Nothing to do.
return
lower_bound = 1
while lower_bound <= max_id + (BATCH_SIZE / 2):
upper_bound = lower_bound + BATCH_SIZE - 1
creation_keys = RealmCreationKey.objects.filter(id__range=(lower_bound, upper_bound))
creation_statuses_to_create = []
confirmations_to_create = []
for creation_key in creation_keys:
# These keys were generated in the same way as keys for Confirmation objects,
# so we can copy them over without breaking anything.
key = creation_key.creation_key
date_created = creation_key.date_created
presume_email_valid = creation_key.presume_email_valid
creation_status = RealmCreationStatus(
status=0, date_created=date_created, presume_email_valid=presume_email_valid
)
confirmation = Confirmation(
content_type=realm_creation_status_content_type,
type=CAN_CREATE_REALM,
confirmation_key=key,
date_sent=date_created,
expiry_date=date_created
+ timedelta(days=settings.CAN_CREATE_REALM_LINK_VALIDITY_DAYS),
)
# To attach the Confirmations to RealmCreationStatus objects we need to set the
# confirmation.object_id to their respective ids. But we haven't saved
# the RealmCreationStatus objs to the database yet - so we don't have ids.
#
# After we .bulk_create() them, their .id attributes will be populated.
# So for now we just link the RealmCreationStatus to the Confirmation
# via a temporary ._object attribute - which we'll use later to set
# the .object_id as intended.
confirmation._object = creation_status
creation_statuses_to_create.append(creation_status)
confirmations_to_create.append(confirmation)
with transaction.atomic():
RealmCreationStatus.objects.bulk_create(creation_statuses_to_create)
# Now the objects in creation_statuses_to_create have had their .id
# attrs populated. For every confirmation, confirmation._object
# points to its corresponding RealmCreationStatus - so now we can
# set the confirmation.object_id values and clear out the temporary
# ._object attr.
for confirmation in confirmations_to_create:
confirmation.object_id = confirmation._object.id
delattr(confirmation, "_object")
Confirmation.objects.bulk_create(confirmations_to_create)
lower_bound += BATCH_SIZE
class Migration(migrations.Migration):
atomic = False
dependencies = [
("confirmation", "0015_alter_confirmation_object_id"),
("zerver", "0747_realmcreationstatus"),
]
operations = [
migrations.RunPython(
migrate_realmcreationkey_to_realmcreationstatus,
reverse_code=migrations.RunPython.noop,
elidable=True,
),
]

View File

@@ -1,15 +0,0 @@
# Generated by Django 5.2.3 on 2025-07-04 19:08
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("confirmation", "0016_realmcreationkey_to_realmcreationstatus"),
]
operations = [
migrations.DeleteModel(
name="RealmCreationKey",
),
]

View File

@@ -5,7 +5,7 @@ import secrets
from base64 import b32encode
from collections.abc import Mapping
from datetime import timedelta
from typing import TypeAlias, Union, cast
from typing import Optional, TypeAlias, Union, cast
from urllib.parse import urljoin
from django.conf import settings
@@ -30,7 +30,6 @@ from zerver.models import (
RealmReactivationStatus,
UserProfile,
)
from zerver.models.prereg_users import RealmCreationStatus
if settings.ZILENCER_ENABLED:
from zilencer.models import (
@@ -71,7 +70,6 @@ NoZilencerConfirmationObjT: TypeAlias = (
| EmailChangeStatus
| UserProfile
| RealmReactivationStatus
| RealmCreationStatus
)
ZilencerConfirmationObjT: TypeAlias = Union[
NoZilencerConfirmationObjT,
@@ -155,7 +153,7 @@ def create_confirmation_object(
realm = None
else:
obj = cast(NoZilencerConfirmationObjT, obj)
assert not isinstance(obj, PreregistrationRealm | RealmCreationStatus)
assert not isinstance(obj, PreregistrationRealm)
realm = obj.realm
current_time = timezone_now()
@@ -187,17 +185,15 @@ def create_confirmation_link(
url_args: Mapping[str, str] = {},
no_associated_realm_object: bool = False,
) -> str:
conf = create_confirmation_object(
obj,
confirmation_type,
validity_in_minutes=validity_in_minutes,
no_associated_realm_object=no_associated_realm_object,
)
result = confirmation_url_for(
conf,
return confirmation_url_for(
create_confirmation_object(
obj,
confirmation_type,
validity_in_minutes=validity_in_minutes,
no_associated_realm_object=no_associated_realm_object,
),
url_args=url_args,
)
return result
def confirmation_url_for(confirmation_obj: "Confirmation", url_args: Mapping[str, str] = {}) -> str:
@@ -236,11 +232,10 @@ class Confirmation(models.Model):
UNSUBSCRIBE = 4
SERVER_REGISTRATION = 5
MULTIUSE_INVITE = 6
NEW_REALM_USER_REGISTRATION = 7
REALM_CREATION = 7
REALM_REACTIVATION = 8
REMOTE_SERVER_BILLING_LEGACY_LOGIN = 9
REMOTE_REALM_BILLING_LEGACY_LOGIN = 10
CAN_CREATE_REALM = 11
type = models.PositiveSmallIntegerField()
class Meta:
@@ -269,7 +264,7 @@ _properties = {
Confirmation.INVITATION: ConfirmationType(
"get_prereg_key_and_redirect", validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS
),
Confirmation.EMAIL_CHANGE: ConfirmationType("confirm_email_change_get"),
Confirmation.EMAIL_CHANGE: ConfirmationType("confirm_email_change"),
Confirmation.UNSUBSCRIBE: ConfirmationType(
"unsubscribe",
validity_in_days=1000000, # should never expire
@@ -277,11 +272,8 @@ _properties = {
Confirmation.MULTIUSE_INVITE: ConfirmationType(
"join", validity_in_days=settings.INVITATION_LINK_VALIDITY_DAYS
),
Confirmation.CAN_CREATE_REALM: ConfirmationType(
"create_realm", validity_in_days=settings.CAN_CREATE_REALM_LINK_VALIDITY_DAYS
),
Confirmation.NEW_REALM_USER_REGISTRATION: ConfirmationType("get_prereg_key_and_redirect"),
Confirmation.REALM_REACTIVATION: ConfirmationType("realm_reactivation_get"),
Confirmation.REALM_CREATION: ConfirmationType("get_prereg_key_and_redirect"),
Confirmation.REALM_REACTIVATION: ConfirmationType("realm_reactivation"),
}
if settings.ZILENCER_ENABLED:
_properties[Confirmation.REMOTE_SERVER_BILLING_LEGACY_LOGIN] = ConfirmationType(
@@ -302,7 +294,47 @@ def one_click_unsubscribe_link(user_profile: UserProfile, email_type: str) -> st
)
def generate_realm_creation_url(by_admin: bool = False) -> str:
from zerver.views.registration import prepare_realm_creation_url
# 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
# form, where they will enter their email and go through the regular
# Confirmation.REALM_CREATION pathway.
# Arguably RealmCreationKey should just be another ConfirmationObjT and we should
# add another Confirmation.type for this; it's this way for historical reasons.
return prepare_realm_creation_url(presume_email_valid=by_admin)
def validate_key(creation_key: str | None) -> Optional["RealmCreationKey"]:
"""Get the record for this key, raising InvalidCreationKey if non-None but invalid."""
if creation_key is None:
return None
try:
key_record = RealmCreationKey.objects.get(creation_key=creation_key)
except RealmCreationKey.DoesNotExist:
raise RealmCreationKey.InvalidError
time_elapsed = timezone_now() - key_record.date_created
if time_elapsed.total_seconds() > settings.REALM_CREATION_LINK_VALIDITY_DAYS * 24 * 3600:
raise RealmCreationKey.InvalidError
return key_record
def generate_realm_creation_url(by_admin: bool = False) -> str:
key = generate_key()
RealmCreationKey.objects.create(
creation_key=key, date_created=timezone_now(), presume_email_valid=by_admin
)
return urljoin(
settings.ROOT_DOMAIN_URI,
reverse("create_realm", kwargs={"creation_key": key}),
)
class RealmCreationKey(models.Model):
creation_key = models.CharField("activation key", db_index=True, max_length=40)
date_created = models.DateTimeField("created", default=timezone_now)
# True just if we should presume the email address the user enters
# is theirs, and skip sending mail to it to confirm that.
presume_email_valid = models.BooleanField(default=False)
class InvalidError(Exception):
pass

View File

@@ -4,6 +4,7 @@ from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from typing import Any
from urllib.parse import urlencode
from django.conf import settings
from django.db import connection
@@ -15,9 +16,9 @@ from django.utils.timezone import now as timezone_now
from markupsafe import Markup
from psycopg2.sql import Composable
from corporate.models.licenses import LicenseLedger
from corporate.models.plans import CustomerPlan
from corporate.models import CustomerPlan, LicenseLedger
from zerver.lib.pysa import mark_sanitized
from zerver.lib.url_encoding import append_url_query_string
from zerver.models import Realm
from zilencer.models import (
RemoteCustomerUserCount,
@@ -137,12 +138,16 @@ def realm_stats_link(realm_str: str) -> Markup:
def user_support_link(email: str) -> Markup:
url = reverse("support", query={"q": email})
support_url = reverse("support")
query = urlencode({"q": email})
url = append_url_query_string(support_url, query)
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
def realm_support_link(realm_str: str) -> Markup:
url = reverse("support", query={"q": realm_str})
support_url = reverse("support")
query = urlencode({"q": realm_str})
url = append_url_query_string(support_url, query)
return Markup('<a href="{url}">{realm}</i></a>').format(url=url, realm=realm_str)
@@ -160,17 +165,13 @@ def remote_installation_stats_link(server_id: int) -> Markup:
def remote_installation_support_link(hostname: str) -> Markup:
url = reverse("remote_servers_support", query={"q": hostname})
support_url = reverse("remote_servers_support")
query = urlencode({"q": hostname})
url = append_url_query_string(support_url, query)
return Markup('<a href="{url}"><i class="fa fa-gear"></i></a>').format(url=url)
def get_plan_rate_percentage(discount: str | None, has_fixed_price: bool) -> str:
# We want to clearly note plans with a fixed price, and not show
# them as paying 100%, as they are usually a special, negotiated
# rate with the customer.
if has_fixed_price:
return "Fixed"
def get_plan_rate_percentage(discount: str | None) -> str:
# CustomerPlan.discount is a string field that stores the discount.
if discount is None or discount == "0":
return "100%"
@@ -192,7 +193,6 @@ def get_remote_activity_plan_data(
) -> RemoteActivityPlanData:
from corporate.lib.stripe import RemoteRealmBillingSession, RemoteServerBillingSession
has_fixed_price = plan.fixed_price is not None
if plan.tier == CustomerPlan.TIER_SELF_HOSTED_LEGACY or plan.status in (
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
@@ -206,13 +206,13 @@ def get_remote_activity_plan_data(
renewal_cents = RemoteRealmBillingSession(
remote_realm=remote_realm
).get_annual_recurring_revenue_for_support_data(plan, license_ledger)
current_rate = get_plan_rate_percentage(plan.discount, has_fixed_price)
current_rate = get_plan_rate_percentage(plan.discount)
else:
assert remote_server is not None
renewal_cents = RemoteServerBillingSession(
remote_server=remote_server
).get_annual_recurring_revenue_for_support_data(plan, license_ledger)
current_rate = get_plan_rate_percentage(plan.discount, has_fixed_price)
current_rate = get_plan_rate_percentage(plan.discount)
return RemoteActivityPlanData(
current_status=plan.get_plan_status_as_text(),
@@ -253,10 +253,7 @@ def get_estimated_arr_and_rate_by_realm() -> tuple[dict[str, int], dict[str, str
realm=plan.customer.realm
).get_annual_recurring_revenue_for_support_data(plan, latest_ledger_entry)
annual_revenue[plan.customer.realm.string_id] = renewal_cents
has_fixed_price = plan.fixed_price is not None
plan_rate[plan.customer.realm.string_id] = get_plan_rate_percentage(
plan.discount, has_fixed_price
)
plan_rate[plan.customer.realm.string_id] = get_plan_rate_percentage(plan.discount)
return annual_revenue, plan_rate

View File

@@ -123,9 +123,7 @@ def authenticated_remote_realm_management_endpoint(
url = append_url_query_string(url, query)
# Return error for AJAX requests with url.
if (
request.get_preferred_type(["application/json", "text/html"]) != "text/html"
): # nocoverage
if request.headers.get("x-requested-with") == "XMLHttpRequest": # nocoverage
return session_expired_ajax_response(url)
return HttpResponseRedirect(url)
@@ -202,16 +200,14 @@ def authenticated_remote_server_management_endpoint(
# That means that we can do it universally whether the user has an expired
# identity_dict, or just lacks any form of authentication info at all - there
# are no security concerns since this is just a local redirect.
url = reverse("remote_billing_legacy_server_login")
page_type = get_next_page_param_from_request_path(request)
url = reverse(
"remote_billing_legacy_server_login",
query=None if page_type is None else {"next_page": page_type},
)
if page_type is not None:
query = urlencode({"next_page": page_type})
url = append_url_query_string(url, query)
# Return error for AJAX requests with url.
if (
request.get_preferred_type(["application/json", "text/html"]) != "text/html"
): # nocoverage
if request.headers.get("x-requested-with") == "XMLHttpRequest": # nocoverage
return session_expired_ajax_response(url)
return HttpResponseRedirect(url)

View File

@@ -2,7 +2,7 @@ from django.conf import settings
from django.utils.translation import gettext as _
from corporate.lib.stripe import LicenseLimitError, get_latest_seat_count, get_seat_count
from corporate.models.plans import CustomerPlan, get_current_plan_by_realm
from corporate.models import CustomerPlan, get_current_plan_by_realm
from zerver.actions.create_user import send_group_direct_message_to_admins
from zerver.lib.exceptions import InvitationError, JsonableError
from zerver.models import Realm, UserProfile

View File

@@ -28,21 +28,21 @@ from django.utils.translation import override as override_language
from typing_extensions import ParamSpec, override
from corporate.lib.billing_types import BillingModality, BillingSchedule, LicenseManagement
from corporate.models.customers import (
from corporate.models import (
Customer,
CustomerPlan,
CustomerPlanOffer,
Invoice,
LicenseLedger,
Session,
SponsoredPlanTypes,
ZulipSponsorshipRequest,
get_current_plan_by_customer,
get_current_plan_by_realm,
get_customer_by_realm,
get_customer_by_remote_realm,
get_customer_by_remote_server,
)
from corporate.models.licenses import LicenseLedger
from corporate.models.plans import (
CustomerPlan,
CustomerPlanOffer,
get_current_plan_by_customer,
get_current_plan_by_realm,
)
from corporate.models.sponsorships import SponsoredPlanTypes, ZulipSponsorshipRequest
from corporate.models.stripe_state import Invoice, Session
from zerver.lib.cache import cache_with_key, get_realm_seat_count_cache_key
from zerver.lib.exceptions import JsonableError
from zerver.lib.logging_util import log_to_file
@@ -52,6 +52,7 @@ from zerver.lib.send_email import (
send_email_to_users_with_billing_access_and_realm_owners,
)
from zerver.lib.timestamp import datetime_to_timestamp, timestamp_to_datetime
from zerver.lib.url_encoding import append_url_query_string
from zerver.lib.utils import assert_is_not_none
from zerver.models import Realm, RealmAuditLog, Stream, UserProfile
from zerver.models.realm_audit_logs import AuditLogEventType
@@ -102,7 +103,7 @@ CARD_CAPITALIZATION = {
}
# The version of Stripe API the billing system supports.
STRIPE_API_VERSION = "2025-04-30.basil"
STRIPE_API_VERSION = "2020-08-27"
stripe.api_version = STRIPE_API_VERSION
@@ -377,7 +378,10 @@ def payment_method_string(stripe_customer: stripe.Customer) -> str:
def build_support_url(support_view: str, query_text: str) -> str:
support_realm_url = get_realm(settings.STAFF_SUBDOMAIN).url
return urljoin(support_realm_url, reverse(support_view, query={"q": query_text}))
support_url = urljoin(support_realm_url, reverse(support_view))
query = urlencode({"q": query_text})
support_url = append_url_query_string(support_url, query)
return support_url
def get_configured_fixed_price_plan_offer(
@@ -517,7 +521,7 @@ def sponsorship_org_type_key_helper(d: Any) -> int:
class PriceArgs(TypedDict, total=False):
amount: int
unit_amount_decimal: str
unit_amount: int
quantity: int
@@ -623,6 +627,7 @@ class BillingSessionAuditLogEventError(Exception):
class UpgradePageParams(TypedDict):
page_type: Literal["upgrade"]
annual_price: int
demo_organization_scheduled_deletion_date: datetime | None
monthly_price: int
seat_count: int
billing_base_url: str
@@ -639,6 +644,8 @@ class UpgradePageParams(TypedDict):
class UpgradePageSessionTypeSpecificContext(TypedDict):
customer_name: str
email: str
is_demo_organization: bool
demo_organization_scheduled_deletion_date: datetime | None
is_self_hosting: bool
@@ -661,6 +668,7 @@ class UpgradePageContext(TypedDict):
stripe_email: str
exempt_from_license_number_check: bool
free_trial_end_date: str | None
is_demo_organization: bool
manual_license_management: bool
using_min_licenses_for_plan: bool
min_licenses_for_plan: int
@@ -863,6 +871,42 @@ class BillingSession(ABC):
assert customer.stripe_customer_id is not None
plan_name = CustomerPlan.name_from_tier(plan_tier)
assert price_per_license is None or fixed_price is None
price_args: PriceArgs = {}
if fixed_price is None:
assert price_per_license is not None
price_args = {
"quantity": licenses,
"unit_amount": price_per_license,
}
else:
assert fixed_price is not None
amount_due = get_amount_due_fixed_price_plan(fixed_price, billing_schedule)
price_args = {"amount": amount_due}
stripe.InvoiceItem.create(
currency="usd",
customer=customer.stripe_customer_id,
description=plan_name,
discountable=False,
period=invoice_period,
**price_args,
)
if fixed_price is None and customer.flat_discounted_months > 0:
num_months = 12 if billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL else 1
flat_discounted_months = min(customer.flat_discounted_months, num_months)
discount = customer.flat_discount * flat_discounted_months
customer.flat_discounted_months -= flat_discounted_months
customer.save(update_fields=["flat_discounted_months"])
stripe.InvoiceItem.create(
currency="usd",
customer=customer.stripe_customer_id,
description=f"${cents_to_dollar_string(customer.flat_discount)}/month new customer discount",
# Negative value to apply discount.
amount=(-1 * discount),
period=invoice_period,
)
if charge_automatically:
collection_method: Literal["charge_automatically", "send_invoice"] = (
@@ -902,46 +946,6 @@ class BillingSession(ABC):
if days_until_due is not None:
invoice_params["days_until_due"] = days_until_due
stripe_invoice = stripe.Invoice.create(**invoice_params)
assert stripe_invoice.id is not None
price_args: PriceArgs = {}
if fixed_price is None:
assert price_per_license is not None
price_args = {
"quantity": licenses,
"unit_amount_decimal": str(price_per_license),
}
else:
assert fixed_price is not None
amount_due = get_amount_due_fixed_price_plan(fixed_price, billing_schedule)
price_args = {"amount": amount_due}
stripe.InvoiceItem.create(
invoice=stripe_invoice.id,
currency="usd",
customer=customer.stripe_customer_id,
description=plan_name,
discountable=False,
period=invoice_period,
**price_args,
)
if fixed_price is None and customer.flat_discounted_months > 0:
num_months = 12 if billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL else 1
flat_discounted_months = min(customer.flat_discounted_months, num_months)
discount = customer.flat_discount * flat_discounted_months
customer.flat_discounted_months -= flat_discounted_months
customer.save(update_fields=["flat_discounted_months"])
stripe.InvoiceItem.create(
invoice=stripe_invoice.id,
currency="usd",
customer=customer.stripe_customer_id,
description=f"${cents_to_dollar_string(customer.flat_discount)}/month new customer discount",
# Negative value to apply discount.
amount=(-1 * discount),
period=invoice_period,
)
stripe.Invoice.finalize_invoice(stripe_invoice)
return stripe_invoice
@@ -1189,8 +1193,8 @@ class BillingSession(ABC):
is_created_for_free_trial_upgrade=current_plan_id is not None and on_free_trial,
)
if stripe_invoice.status != "paid" and charge_automatically:
# Stripe can take its sweet hour to charge customers after creating an invoice.
if charge_automatically:
# Stripe takes its sweet hour to charge customers after creating an invoice.
# Since we want to charge customers immediately, we charge them manually.
# Then poll for the status of the invoice to see if the payment succeeded.
stripe_invoice = stripe.Invoice.pay(stripe_invoice.id)
@@ -2301,24 +2305,6 @@ class BillingSession(ABC):
# This will create invoice for any additional licenses that user has at the time of
# switching from free trial to paid plan since they already paid for the plan's this billing cycle.
is_renewal = False
# Since we need to move the `billing_cycle_anchor` forward below, we also
# need to update the `event_time` of the last renewal ledger entry to avoid
# our logic from thinking that licenses for the current billing cycle hasn't
# been paid for.
last_renewal_ledger_entry = (
LicenseLedger.objects.filter(
plan=plan,
is_renewal=True,
)
.order_by("-id")
.first()
)
assert last_renewal_ledger_entry is not None
last_renewal_ledger_entry.event_time = next_billing_cycle.replace(
microsecond=0
)
last_renewal_ledger_entry.save(update_fields=["event_time"])
else:
# We end the free trial since customer hasn't paid.
plan.status = CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL
@@ -2839,11 +2825,15 @@ class BillingSession(ABC):
"stripe_email": stripe_email,
"exempt_from_license_number_check": exempt_from_license_number_check,
"free_trial_end_date": free_trial_end_date,
"is_demo_organization": customer_specific_context["is_demo_organization"],
"complimentary_access_plan_end_date": complimentary_access_plan_end_date,
"manual_license_management": initial_upgrade_request.manual_license_management,
"page_params": {
"page_type": "upgrade",
"annual_price": annual_price,
"demo_organization_scheduled_deletion_date": customer_specific_context[
"demo_organization_scheduled_deletion_date"
],
"monthly_price": monthly_price,
"seat_count": seat_count,
"billing_base_url": self.billing_base_url,
@@ -3046,7 +3036,7 @@ class BillingSession(ABC):
licenses = update_plan_request.licenses
if licenses is not None:
if plan.is_free_trial():
if plan.is_free_trial(): # nocoverage
raise JsonableError(
_("Cannot update licenses in the current billing period for free trial plan.")
)
@@ -3230,36 +3220,25 @@ class BillingSession(ABC):
licenses_base = plan.invoiced_through.licenses
invoiced_through_id = plan.invoiced_through.id
# Mark the plan for start of invoicing process.
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_STARTED
plan.save(update_fields=["invoicing_status"])
# Invoice Variables
stripe_invoice: stripe.Invoice | None = None
need_to_invoice = False
# Track if we added renewal invoice item which is possibly eligible for discount.
renewal_invoice_period: stripe.InvoiceItem.CreateParamsPeriod | None = None
invoice_item_created = False
invoice_period: stripe.InvoiceItem.CreateParamsPeriod | None = None
for ledger_entry in LicenseLedger.objects.filter(
plan=plan, id__gt=invoiced_through_id, event_time__lte=event_time
).order_by("id"):
# InvoiceItem variables.
invoice_item_params = stripe.InvoiceItem.CreateParams(
customer=plan.customer.stripe_customer_id
)
price_args: PriceArgs = {}
if ledger_entry.is_renewal:
if plan.fixed_price is not None:
amount_due = get_amount_due_fixed_price_plan(
plan.fixed_price, plan.billing_schedule
)
invoice_item_params["amount"] = amount_due
price_args = {"amount": amount_due}
else:
assert plan.price_per_license is not None # needed for mypy
invoice_item_params["unit_amount_decimal"] = str(plan.price_per_license)
invoice_item_params["quantity"] = ledger_entry.licenses
invoice_item_params["description"] = f"{plan.name} - renewal"
need_to_invoice = True
price_args = {
"unit_amount": plan.price_per_license,
"quantity": ledger_entry.licenses,
}
description = f"{plan.name} - renewal"
elif (
plan.fixed_price is None
and licenses_base is not None
@@ -3285,67 +3264,44 @@ class BillingSession(ABC):
plan_renewal_or_end_date - ledger_entry.event_time
) / (billing_period_end - last_renewal)
unit_amount = int(plan.price_per_license * proration_fraction + 0.5)
invoice_item_params["unit_amount_decimal"] = str(unit_amount)
invoice_item_params["quantity"] = ledger_entry.licenses - licenses_base
invoice_item_params["description"] = "Additional license ({} - {})".format(
price_args = {
"unit_amount": unit_amount,
"quantity": ledger_entry.licenses - licenses_base,
}
description = "Additional license ({} - {})".format(
ledger_entry.event_time.strftime("%b %-d, %Y"),
plan_renewal_or_end_date.strftime("%b %-d, %Y"),
)
need_to_invoice = True
if stripe_invoice is None and need_to_invoice:
if plan.charge_automatically:
collection_method: Literal["charge_automatically", "send_invoice"] = (
"charge_automatically"
)
days_until_due = None
else:
collection_method = "send_invoice"
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
invoice_params = stripe.Invoice.CreateParams(
auto_advance=True,
collection_method=collection_method,
customer=plan.customer.stripe_customer_id,
statement_descriptor=plan.name,
)
if days_until_due is not None:
invoice_params["days_until_due"] = days_until_due
stripe_invoice = stripe.Invoice.create(**invoice_params)
if invoice_item_params.get("description") is not None:
invoice_period = stripe.InvoiceItem.CreateParamsPeriod(
start=datetime_to_timestamp(ledger_entry.event_time),
end=datetime_to_timestamp(
if price_args:
plan.invoiced_through = ledger_entry
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_STARTED
plan.save(update_fields=["invoicing_status", "invoiced_through"])
invoice_period = {
"start": datetime_to_timestamp(ledger_entry.event_time),
"end": datetime_to_timestamp(
get_plan_renewal_or_end_date(plan, ledger_entry.event_time)
),
}
stripe.InvoiceItem.create(
currency="usd",
customer=plan.customer.stripe_customer_id,
description=description,
discountable=False,
period=invoice_period,
idempotency_key=get_idempotency_key(ledger_entry),
**price_args,
)
if ledger_entry.is_renewal:
renewal_invoice_period = invoice_period
invoice_item_params["currency"] = "usd"
invoice_item_params["discountable"] = False
invoice_item_params["period"] = invoice_period
invoice_item_params["idempotency_key"] = get_idempotency_key(ledger_entry)
assert stripe_invoice is not None
assert stripe_invoice.id is not None
invoice_item_params["invoice"] = stripe_invoice.id
stripe.InvoiceItem.create(**invoice_item_params)
# Update license base per ledger_entry.
licenses_base = ledger_entry.licenses
invoice_item_created = True
plan.invoiced_through = ledger_entry
plan.save(update_fields=["invoiced_through"])
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_DONE
plan.save(update_fields=["invoicing_status", "invoiced_through"])
licenses_base = ledger_entry.licenses
flat_discount, flat_discounted_months = self.get_flat_discount_info(plan.customer)
if stripe_invoice is not None:
# Only apply discount if this invoice contains renewal of the plan.
if (
renewal_invoice_period is not None
and plan.fixed_price is None
and flat_discounted_months > 0
):
assert stripe_invoice.id is not None
if invoice_item_created:
assert invoice_period is not None
flat_discount, flat_discounted_months = self.get_flat_discount_info(plan.customer)
if plan.fixed_price is None and flat_discounted_months > 0:
num_months = (
12 if plan.billing_schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL else 1
)
@@ -3354,27 +3310,36 @@ class BillingSession(ABC):
plan.customer.flat_discounted_months -= flat_discounted_months
plan.customer.save(update_fields=["flat_discounted_months"])
stripe.InvoiceItem.create(
invoice=stripe_invoice.id,
currency="usd",
customer=plan.customer.stripe_customer_id,
description=f"${cents_to_dollar_string(flat_discount)}/month new customer discount",
# Negative value to apply discount.
amount=(-1 * discount),
period=renewal_invoice_period,
period=invoice_period,
)
if plan.charge_automatically:
collection_method: Literal["charge_automatically", "send_invoice"] = (
"charge_automatically"
)
days_until_due = None
else:
collection_method = "send_invoice"
days_until_due = DEFAULT_INVOICE_DAYS_UNTIL_DUE
invoice_params = stripe.Invoice.CreateParams(
auto_advance=True,
collection_method=collection_method,
customer=plan.customer.stripe_customer_id,
statement_descriptor=plan.name,
)
if days_until_due is not None:
invoice_params["days_until_due"] = days_until_due
stripe_invoice = stripe.Invoice.create(**invoice_params)
stripe.Invoice.finalize_invoice(stripe_invoice)
plan.invoicing_status = CustomerPlan.INVOICING_STATUS_DONE
plan.next_invoice_date = next_invoice_date(plan)
plan.stale_audit_log_data_email_sent = False
plan.save(
update_fields=[
"next_invoice_date",
"stale_audit_log_data_email_sent",
"invoicing_status",
]
)
plan.invoice_overdue_email_sent = False
plan.save(update_fields=["next_invoice_date", "invoice_overdue_email_sent"])
def do_change_plan_to_new_tier(self, new_plan_tier: int) -> str:
customer = self.get_customer()
@@ -3677,6 +3642,35 @@ class BillingSession(ABC):
assert isinstance(stripe_invoice.customer, str)
assert stripe_invoice.statement_descriptor is not None
assert stripe_invoice.metadata is not None
invoice_items = stripe_invoice.lines.data
# Stripe does something weird and puts the discount item first, so we need to reverse the order here.
invoice_items.reverse()
for invoice_item in invoice_items:
assert invoice_item.description is not None
price_args: PriceArgs = {}
# If amount is positive, this must be non-discount item we need to update.
if invoice_item.amount > 0:
assert invoice_item.price is not None
assert invoice_item.price.unit_amount is not None
price_args = {
"quantity": licenses,
"unit_amount": invoice_item.price.unit_amount,
}
else:
price_args = {
"amount": invoice_item.amount,
}
stripe.InvoiceItem.create(
currency=invoice_item.currency,
customer=stripe_invoice.customer,
description=invoice_item.description,
period={
"start": invoice_item.period.start,
"end": invoice_item.period.end,
},
**price_args,
)
assert plan.next_invoice_date is not None
# Difference between end of free trial and event time
days_until_due = (plan.next_invoice_date - event_time).days
@@ -3689,38 +3683,6 @@ class BillingSession(ABC):
statement_descriptor=stripe_invoice.statement_descriptor,
metadata=stripe_invoice.metadata,
)
assert new_stripe_invoice.id is not None
invoice_items = stripe_invoice.lines.data
# Stripe does something weird and puts the discount item first, so we need to reverse the order here.
invoice_items.reverse()
for invoice_item in invoice_items:
assert invoice_item.description is not None
price_args: PriceArgs = {}
# If amount is positive, this must be non-discount item we need to update.
if invoice_item.amount > 0:
assert invoice_item.pricing is not None
assert invoice_item.pricing.unit_amount_decimal is not None
price_args = {
"quantity": licenses,
"unit_amount_decimal": str(invoice_item.pricing.unit_amount_decimal),
}
else:
price_args = {
"amount": invoice_item.amount,
}
stripe.InvoiceItem.create(
invoice=new_stripe_invoice.id,
currency=invoice_item.currency,
customer=stripe_invoice.customer,
description=invoice_item.description,
period={
"start": invoice_item.period.start,
"end": invoice_item.period.end,
},
**price_args,
)
new_stripe_invoice = stripe.Invoice.finalize_invoice(new_stripe_invoice)
last_sent_invoice.stripe_invoice_id = str(new_stripe_invoice.id)
last_sent_invoice.save(update_fields=["stripe_invoice_id"])
@@ -4235,6 +4197,8 @@ class RealmBillingSession(BillingSession):
return UpgradePageSessionTypeSpecificContext(
customer_name=self.realm.name,
email=self.get_email(),
is_demo_organization=self.realm.demo_organization_scheduled_deletion_date is not None,
demo_organization_scheduled_deletion_date=self.realm.demo_organization_scheduled_deletion_date,
is_self_hosting=False,
)
@@ -4632,6 +4596,8 @@ class RemoteRealmBillingSession(BillingSession):
return UpgradePageSessionTypeSpecificContext(
customer_name=self.remote_realm.host,
email=self.get_email(),
is_demo_organization=False,
demo_organization_scheduled_deletion_date=None,
is_self_hosting=True,
)
@@ -5097,6 +5063,8 @@ class RemoteServerBillingSession(BillingSession):
return UpgradePageSessionTypeSpecificContext(
customer_name=self.remote_server.hostname,
email=self.get_email(),
is_demo_organization=False,
demo_organization_scheduled_deletion_date=None,
is_self_hosting=True,
)
@@ -5445,180 +5413,90 @@ def get_plan_renewal_or_end_date(plan: CustomerPlan, event_time: datetime) -> da
return billing_period_end
def maybe_send_fixed_price_plan_renewal_reminder_email(
plan: CustomerPlan, billing_session: BillingSession
) -> None:
# We expect to have both an end date and next invoice date
# for this CustomerPlan.
assert plan.end_date is not None
assert plan.next_invoice_date is not None
# The max gap between two months is 62 days (1 Jul - 1 Sep).
if plan.end_date - plan.next_invoice_date <= timedelta(days=62):
context = {
"billing_entity": billing_session.billing_entity_display_name,
"end_date": plan.end_date.strftime("%Y-%m-%d"),
"support_url": billing_session.support_url(),
"notice_reason": "fixed_price_plan_ends_soon",
}
send_email(
"zerver/emails/internal_billing_notice",
to_emails=[BILLING_SUPPORT_EMAIL],
from_address=FromAddress.tokenized_no_reply_address(),
context=context,
)
plan.reminder_to_review_plan_email_sent = True
plan.save(update_fields=["reminder_to_review_plan_email_sent"])
def maybe_send_stale_audit_log_data_email(
plan: CustomerPlan,
billing_session: BillingSession,
next_invoice_date: datetime,
last_audit_log_update: datetime | None,
) -> None:
if last_audit_log_update is None: # nocoverage
# We have no audit log data from the remote server at all,
# and they have a paid plan or scheduled upgrade, so email
# billing support.
context = {
"billing_entity": billing_session.billing_entity_display_name,
"support_url": billing_session.support_url(),
"last_audit_log_update": "Never uploaded",
"notice_reason": "stale_audit_log_data",
}
send_email(
"zerver/emails/internal_billing_notice",
to_emails=[BILLING_SUPPORT_EMAIL],
from_address=FromAddress.tokenized_no_reply_address(),
context=context,
)
plan.stale_audit_log_data_email_sent = True
plan.save(update_fields=["stale_audit_log_data_email_sent"])
return
if next_invoice_date - last_audit_log_update < timedelta(days=1): # nocoverage
# If it's been less than a day since the last audit log update,
# then don't email billing support as the issue with the remote
# server could be transient.
return
context = {
"billing_entity": billing_session.billing_entity_display_name,
"support_url": billing_session.support_url(),
"last_audit_log_update": last_audit_log_update.strftime("%Y-%m-%d"),
"notice_reason": "stale_audit_log_data",
}
send_email(
"zerver/emails/internal_billing_notice",
to_emails=[BILLING_SUPPORT_EMAIL],
from_address=FromAddress.tokenized_no_reply_address(),
context=context,
)
plan.stale_audit_log_data_email_sent = True
plan.save(update_fields=["stale_audit_log_data_email_sent"])
def check_remote_server_audit_log_data(
remote_server: RemoteZulipServer, plan: CustomerPlan, billing_session: BillingSession
) -> bool:
# If this is a complimentary access plan without an upgrade scheduled,
# we do not need the remote server's audit log data to downgrade the plan.
if plan.is_complimentary_access_plan() and plan.status == CustomerPlan.ACTIVE:
return True
# We expect to have a next invoice date for this CustomerPlan.
assert plan.next_invoice_date is not None
next_invoice_date = plan.next_invoice_date
last_audit_log_update = remote_server.last_audit_log_update
if last_audit_log_update is None or next_invoice_date > last_audit_log_update:
if not plan.stale_audit_log_data_email_sent:
maybe_send_stale_audit_log_data_email(
plan, billing_session, next_invoice_date, last_audit_log_update
)
# We still process free trial plans so that we can directly downgrade them.
if plan.is_free_trial() and not plan.charge_automatically: # nocoverage
return True
# We don't have current audit log data from the remote server,
# so we don't have enough information to invoice the plan.
return False
return True
def review_and_maybe_invoice_plan(
plan: CustomerPlan,
event_time: datetime,
) -> None:
remote_server: RemoteZulipServer | None = None
if plan.customer.realm is not None:
billing_session: BillingSession = RealmBillingSession(realm=plan.customer.realm)
elif plan.customer.remote_realm is not None:
remote_realm = plan.customer.remote_realm
remote_server = remote_realm.server
billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
elif plan.customer.remote_server is not None:
remote_server = plan.customer.remote_server
billing_session = RemoteServerBillingSession(remote_server=remote_server)
if (
plan.fixed_price is not None
and plan.end_date is not None
and not plan.reminder_to_review_plan_email_sent
):
maybe_send_fixed_price_plan_renewal_reminder_email(plan, billing_session)
try_to_invoice_plan = True
if remote_server:
# We need the audit log data from the remote server to be
# current enough for license checks on paid plans.
try_to_invoice_plan = check_remote_server_audit_log_data(
remote_server, plan, billing_session
)
if try_to_invoice_plan:
# plan.next_invoice_date can be None after calling invoice_plan.
while plan.next_invoice_date is not None and plan.next_invoice_date <= event_time:
billing_session.invoice_plan(plan, plan.next_invoice_date)
plan.refresh_from_db()
def invoice_plans_as_needed(event_time: datetime | None = None) -> None:
failed_customer_ids = set()
if event_time is None:
event_time = timezone_now()
# For complimentary access plans with status SWITCH_PLAN_TIER_AT_PLAN_END, we need
# to invoice the complimentary access plan followed by the new plan on the same day,
# hence ordering by ID.
for plan in CustomerPlan.objects.filter(next_invoice_date__lte=event_time).order_by("id"):
if plan.customer.id in failed_customer_ids: # nocoverage
# We've already had a failure for this customer in this
# invoicing attempt; skip it so we can process others,
# without incorrectly processing other plans on this same
# customer.
continue
remote_server: RemoteZulipServer | None = None
if plan.customer.realm is not None:
billing_session: BillingSession = RealmBillingSession(realm=plan.customer.realm)
elif plan.customer.remote_realm is not None:
remote_realm = plan.customer.remote_realm
remote_server = remote_realm.server
billing_session = RemoteRealmBillingSession(remote_realm=remote_realm)
elif plan.customer.remote_server is not None:
remote_server = plan.customer.remote_server
billing_session = RemoteServerBillingSession(remote_server=remote_server)
try:
review_and_maybe_invoice_plan(plan, event_time)
except Exception as e:
failed_customer_ids.add(plan.customer.id)
if isinstance(e, BillingError):
billing_logger.exception(
"Invoicing failed: Customer.id: %s, CustomerPlan.id: %s, BillingError: %s",
plan.customer.id,
plan.id,
e.error_description,
)
elif isinstance(e, AssertionError): # nocoverage
billing_logger.exception(
"Invoicing failed due to AssertionError: Customer.id: %s, CustomerPlan.id: %s",
plan.customer.id,
plan.id,
stack_info=True,
)
else:
billing_logger.exception(e, stack_info=True) # nocoverage
assert plan.next_invoice_date is not None # for mypy
if (
plan.fixed_price is not None
and not plan.reminder_to_review_plan_email_sent
and plan.end_date is not None # for mypy
# The max gap between two months is 62 days. (1 Jul - 1 Sep)
and plan.end_date - plan.next_invoice_date <= timedelta(days=62)
):
context = {
"billing_entity": billing_session.billing_entity_display_name,
"end_date": plan.end_date.strftime("%Y-%m-%d"),
"support_url": billing_session.support_url(),
"notice_reason": "fixed_price_plan_ends_soon",
}
send_email(
"zerver/emails/internal_billing_notice",
to_emails=[BILLING_SUPPORT_EMAIL],
from_address=FromAddress.tokenized_no_reply_address(),
context=context,
)
plan.reminder_to_review_plan_email_sent = True
plan.save(update_fields=["reminder_to_review_plan_email_sent"])
if remote_server:
free_plan_with_no_next_plan = (
not plan.is_a_paid_plan() and plan.status == CustomerPlan.ACTIVE
)
free_trial_pay_by_invoice_plan = plan.is_free_trial() and not plan.charge_automatically
last_audit_log_update = remote_server.last_audit_log_update
if not free_plan_with_no_next_plan and (
last_audit_log_update is None or plan.next_invoice_date > last_audit_log_update
):
if (
last_audit_log_update is None
or plan.next_invoice_date - last_audit_log_update >= timedelta(days=1)
) and not plan.invoice_overdue_email_sent:
last_audit_log_update_string = "Never uploaded"
if last_audit_log_update is not None:
last_audit_log_update_string = last_audit_log_update.strftime("%Y-%m-%d")
context = {
"billing_entity": billing_session.billing_entity_display_name,
"support_url": billing_session.support_url(),
"last_audit_log_update": last_audit_log_update_string,
"notice_reason": "invoice_overdue",
}
send_email(
"zerver/emails/internal_billing_notice",
to_emails=[BILLING_SUPPORT_EMAIL],
from_address=FromAddress.tokenized_no_reply_address(),
context=context,
)
plan.invoice_overdue_email_sent = True
plan.save(update_fields=["invoice_overdue_email_sent"])
# We still process free trial plans so that we can directly downgrade them.
# Above emails can serve as a reminder to followup for additional feedback.
if not free_trial_pay_by_invoice_plan:
continue
while (
plan.next_invoice_date is not None # type: ignore[redundant-expr] # plan.next_invoice_date can be None after calling invoice_plan.
and plan.next_invoice_date <= event_time
):
billing_session.invoice_plan(plan, plan.next_invoice_date)
plan.refresh_from_db()
def is_realm_on_free_trial(realm: Realm) -> bool:
@@ -5685,8 +5563,7 @@ def downgrade_small_realms_behind_on_payments_as_needed() -> None:
billing_session.void_all_open_invoices()
context: dict[str, str | Realm] = {
"upgrade_url": f"{realm.url}{reverse('upgrade_page')}",
"realm_url": realm.url,
"string_id": realm.string_id,
"realm": realm,
}
send_email_to_users_with_billing_access_and_realm_owners(
"zerver/emails/realm_auto_downgraded",

View File

@@ -13,9 +13,14 @@ from corporate.lib.stripe import (
RemoteServerBillingSession,
get_configured_fixed_price_plan_offer,
)
from corporate.models.customers import Customer
from corporate.models.plans import CustomerPlan, get_current_plan_by_customer
from corporate.models.stripe_state import Event, Invoice, Session
from corporate.models import (
Customer,
CustomerPlan,
Event,
Invoice,
Session,
get_current_plan_by_customer,
)
from zerver.lib.send_email import FromAddress, send_email
from zerver.models.users import get_active_user_profile_by_id_in_realm

View File

@@ -17,13 +17,17 @@ from corporate.lib.stripe import (
get_push_status_for_remote_request,
start_of_next_billing_cycle,
)
from corporate.models.customers import Customer
from corporate.models.licenses import LicenseLedger
from corporate.models.plans import CustomerPlan, CustomerPlanOffer, get_current_plan_by_customer
from corporate.models.sponsorships import ZulipSponsorshipRequest
from corporate.models import (
Customer,
CustomerPlan,
CustomerPlanOffer,
LicenseLedger,
ZulipSponsorshipRequest,
get_current_plan_by_customer,
)
from zerver.lib.timestamp import timestamp_to_datetime
from zerver.models import Realm
from zerver.models.realm_audit_logs import AuditLogEventType, RealmAuditLog
from zerver.models.realm_audit_logs import AuditLogEventType
from zerver.models.realms import get_org_type_display_name
from zilencer.lib.remote_counts import MissingDataError
from zilencer.models import (
@@ -126,33 +130,12 @@ class CloudSupportData:
plan_data: PlanData
sponsorship_data: SponsorshipData
user_data: UserData
file_upload_usage: str
is_scrubbed: bool
def get_stripe_customer_url(stripe_id: str) -> str:
return f"https://dashboard.stripe.com/customers/{stripe_id}" # nocoverage
def get_formatted_realm_upload_space_used(realm: Realm) -> str: # nocoverage
realm_bytes_used = realm.currently_used_upload_space_bytes()
files_uploaded = realm_bytes_used > 0
realm_uploads = "No uploads"
if files_uploaded:
realm_uploads = str(round(realm_bytes_used / 1024 / 1024, 2))
quota = realm.upload_quota_bytes()
if quota is None:
if files_uploaded:
return f"{realm_uploads} MiB / No quota"
return f"{realm_uploads} / No quota"
if quota == 0:
return f"{realm_uploads} / 0.0 MiB"
quota_mb = round(quota / 1024 / 1024, 2)
return f"{realm_uploads} / {quota_mb} MiB"
def get_realm_user_data(realm: Realm) -> UserData:
non_guests = get_non_guest_user_count(realm)
guests = get_guest_user_count(realm)
@@ -498,8 +481,4 @@ def get_data_for_cloud_support_view(billing_session: BillingSession) -> CloudSup
plan_data=plan_data,
sponsorship_data=sponsorship_data,
user_data=user_data,
file_upload_usage=get_formatted_realm_upload_space_used(billing_session.realm),
is_scrubbed=RealmAuditLog.objects.filter(
realm=billing_session.realm, event_type=AuditLogEventType.REALM_SCRUBBED
).exists(),
)

View File

@@ -1,17 +0,0 @@
# Generated by Django 5.1.8 on 2025-05-27 14:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("corporate", "0045_zulipsponsorshiprequest_plan_to_use_zulip"),
]
operations = [
migrations.RenameField(
model_name="customerplan",
old_name="invoice_overdue_email_sent",
new_name="stale_audit_log_data_email_sent",
),
]

587
corporate/models.py Normal file
View File

@@ -0,0 +1,587 @@
from enum import Enum
from typing import Any, Union
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import CASCADE, SET_NULL, Q
from typing_extensions import override
from zerver.models import Realm, UserProfile
from zilencer.models import RemoteRealm, RemoteZulipServer
class Customer(models.Model):
"""
This model primarily serves to connect a Realm with
the corresponding Stripe customer object for payment purposes
and the active plan, if any.
"""
# The actual model object that this customer is associated
# with. Exactly one of the following will be non-null.
realm = models.OneToOneField(Realm, on_delete=CASCADE, null=True)
remote_realm = models.OneToOneField(RemoteRealm, on_delete=CASCADE, null=True)
remote_server = models.OneToOneField(RemoteZulipServer, on_delete=CASCADE, null=True)
stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)
sponsorship_pending = models.BooleanField(default=False)
# Discounted price for required_plan_tier in cents.
# We treat 0 as no discount. Not using `null` here keeps the
# checks simpler and avoids the cases where we forget to
# check for both `null` and 0.
monthly_discounted_price = models.IntegerField(default=0, null=False)
annual_discounted_price = models.IntegerField(default=0, null=False)
minimum_licenses = models.PositiveIntegerField(null=True)
# Used for limiting discounted price or a fixed_price
# to be used only for a particular CustomerPlan tier.
required_plan_tier = models.SmallIntegerField(null=True)
# Some non-profit organizations on manual license management pay
# only for their paid employees. We don't prevent these
# organizations from adding more users than the number of licenses
# they purchased.
exempt_from_license_number_check = models.BooleanField(default=False)
# In cents.
flat_discount = models.IntegerField(default=2000)
# Number of months left in the flat discount period.
flat_discounted_months = models.IntegerField(default=0)
class Meta:
# Enforce that at least one of these is set.
constraints = [
models.CheckConstraint(
condition=Q(realm__isnull=False)
| Q(remote_server__isnull=False)
| Q(remote_realm__isnull=False),
name="has_associated_model_object",
)
]
@override
def __str__(self) -> str:
if self.realm is not None:
return f"{self.realm!r} (with stripe_customer_id: {self.stripe_customer_id})"
elif self.remote_realm is not None:
return f"{self.remote_realm!r} (with stripe_customer_id: {self.stripe_customer_id})"
else:
return f"{self.remote_server!r} (with stripe_customer_id: {self.stripe_customer_id})"
def get_discounted_price_for_plan(self, plan_tier: int, schedule: int) -> int | None:
if plan_tier != self.required_plan_tier:
return None
if schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL:
return self.annual_discounted_price
assert schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY
return self.monthly_discounted_price
def get_customer_by_realm(realm: Realm) -> Customer | None:
return Customer.objects.filter(realm=realm).first()
def get_customer_by_remote_server(remote_server: RemoteZulipServer) -> Customer | None:
return Customer.objects.filter(remote_server=remote_server).first()
def get_customer_by_remote_realm(remote_realm: RemoteRealm) -> Customer | None: # nocoverage
return Customer.objects.filter(remote_realm=remote_realm).first()
class Event(models.Model):
stripe_event_id = models.CharField(max_length=255)
type = models.CharField(max_length=255)
RECEIVED = 1
EVENT_HANDLER_STARTED = 30
EVENT_HANDLER_FAILED = 40
EVENT_HANDLER_SUCCEEDED = 50
status = models.SmallIntegerField(default=RECEIVED)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(db_index=True)
content_object = GenericForeignKey("content_type", "object_id")
handler_error = models.JSONField(default=None, null=True)
def get_event_handler_details_as_dict(self) -> dict[str, Any]:
details_dict = {}
details_dict["status"] = {
Event.RECEIVED: "not_started",
Event.EVENT_HANDLER_STARTED: "started",
Event.EVENT_HANDLER_FAILED: "failed",
Event.EVENT_HANDLER_SUCCEEDED: "succeeded",
}[self.status]
if self.handler_error:
details_dict["error"] = self.handler_error
return details_dict
def get_last_associated_event_by_type(
content_object: Union["Invoice", "PaymentIntent", "Session"], event_type: str
) -> Event | None:
content_type = ContentType.objects.get_for_model(type(content_object))
return Event.objects.filter(
content_type=content_type, object_id=content_object.id, type=event_type
).last()
class Session(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_session_id = models.CharField(max_length=255, unique=True)
CARD_UPDATE_FROM_BILLING_PAGE = 40
CARD_UPDATE_FROM_UPGRADE_PAGE = 50
type = models.SmallIntegerField()
CREATED = 1
COMPLETED = 10
status = models.SmallIntegerField(default=CREATED)
# Did the user opt to manually manage licenses before clicking on update button?
is_manual_license_management_upgrade_session = models.BooleanField(default=False)
# CustomerPlan tier that the user is upgrading to.
tier = models.SmallIntegerField(null=True)
def get_status_as_string(self) -> str:
return {Session.CREATED: "created", Session.COMPLETED: "completed"}[self.status]
def get_type_as_string(self) -> str:
return {
Session.CARD_UPDATE_FROM_BILLING_PAGE: "card_update_from_billing_page",
Session.CARD_UPDATE_FROM_UPGRADE_PAGE: "card_update_from_upgrade_page",
}[self.type]
def to_dict(self) -> dict[str, Any]:
session_dict: dict[str, Any] = {}
session_dict["status"] = self.get_status_as_string()
session_dict["type"] = self.get_type_as_string()
session_dict["is_manual_license_management_upgrade_session"] = (
self.is_manual_license_management_upgrade_session
)
session_dict["tier"] = self.tier
event = self.get_last_associated_event()
if event is not None:
session_dict["event_handler"] = event.get_event_handler_details_as_dict()
return session_dict
def get_last_associated_event(self) -> Event | None:
if self.status == Session.CREATED:
return None
return get_last_associated_event_by_type(self, "checkout.session.completed")
class PaymentIntent(models.Model): # nocoverage
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
REQUIRES_PAYMENT_METHOD = 1
REQUIRES_CONFIRMATION = 20
REQUIRES_ACTION = 30
PROCESSING = 40
REQUIRES_CAPTURE = 50
CANCELLED = 60
SUCCEEDED = 70
status = models.SmallIntegerField()
last_payment_error = models.JSONField(default=None, null=True)
@classmethod
def get_status_integer_from_status_text(cls, status_text: str) -> int:
return getattr(cls, status_text.upper())
def get_status_as_string(self) -> str:
return {
PaymentIntent.REQUIRES_PAYMENT_METHOD: "requires_payment_method",
PaymentIntent.REQUIRES_CONFIRMATION: "requires_confirmation",
PaymentIntent.REQUIRES_ACTION: "requires_action",
PaymentIntent.PROCESSING: "processing",
PaymentIntent.REQUIRES_CAPTURE: "requires_capture",
PaymentIntent.CANCELLED: "cancelled",
PaymentIntent.SUCCEEDED: "succeeded",
}[self.status]
def get_last_associated_event(self) -> Event | None:
if self.status == PaymentIntent.SUCCEEDED:
event_type = "payment_intent.succeeded"
# TODO: Add test for this case. Not sure how to trigger naturally.
else: # nocoverage
return None # nocoverage
return get_last_associated_event_by_type(self, event_type)
def to_dict(self) -> dict[str, Any]:
payment_intent_dict: dict[str, Any] = {}
payment_intent_dict["status"] = self.get_status_as_string()
event = self.get_last_associated_event()
if event is not None:
payment_intent_dict["event_handler"] = event.get_event_handler_details_as_dict()
return payment_intent_dict
class Invoice(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_invoice_id = models.CharField(max_length=255, unique=True)
plan = models.ForeignKey("CustomerPlan", null=True, default=None, on_delete=SET_NULL)
is_created_for_free_trial_upgrade = models.BooleanField(default=False)
SENT = 1
PAID = 2
VOID = 3
status = models.SmallIntegerField()
def get_status_as_string(self) -> str:
return {
Invoice.SENT: "sent",
Invoice.PAID: "paid",
Invoice.VOID: "void",
}[self.status]
def get_last_associated_event(self) -> Event | None:
if self.status == Invoice.PAID:
event_type = "invoice.paid"
# TODO: Add test for this case. Not sure how to trigger naturally.
else: # nocoverage
return None # nocoverage
return get_last_associated_event_by_type(self, event_type)
def to_dict(self) -> dict[str, Any]:
stripe_invoice_dict: dict[str, Any] = {}
stripe_invoice_dict["status"] = self.get_status_as_string()
event = self.get_last_associated_event()
if event is not None:
stripe_invoice_dict["event_handler"] = event.get_event_handler_details_as_dict()
return stripe_invoice_dict
class AbstractCustomerPlan(models.Model):
# A customer can only have one ACTIVE / CONFIGURED plan,
# but old, inactive / processed plans are preserved to allow
# auditing - so there can be multiple CustomerPlan / CustomerPlanOffer
# objects pointing to one Customer.
customer = models.ForeignKey(Customer, on_delete=CASCADE)
fixed_price = models.IntegerField(null=True)
class Meta:
abstract = True
class CustomerPlanOffer(AbstractCustomerPlan):
"""
This is for storing offers configured via /support which
the customer is yet to buy or schedule a purchase.
Once customer buys or schedules a purchase, we create a
CustomerPlan record. The record in this table stays for
audit purpose with status=PROCESSED.
"""
TIER_CLOUD_STANDARD = 1
TIER_CLOUD_PLUS = 2
TIER_SELF_HOSTED_BASIC = 103
TIER_SELF_HOSTED_BUSINESS = 104
tier = models.SmallIntegerField()
# Whether the offer is:
# * only configured
# * processed by the customer to buy or schedule a purchase.
CONFIGURED = 1
PROCESSED = 2
status = models.SmallIntegerField()
# ID of invoice sent when chose to 'Pay by invoice'.
sent_invoice_id = models.CharField(max_length=255, null=True)
@override
def __str__(self) -> str:
return f"{self.name} (status: {self.get_plan_status_as_text()})"
def get_plan_status_as_text(self) -> str:
return {
self.CONFIGURED: "Configured",
self.PROCESSED: "Processed",
}[self.status]
@staticmethod
def name_from_tier(tier: int) -> str:
return {
CustomerPlanOffer.TIER_CLOUD_STANDARD: "Zulip Cloud Standard",
CustomerPlanOffer.TIER_CLOUD_PLUS: "Zulip Cloud Plus",
CustomerPlanOffer.TIER_SELF_HOSTED_BASIC: "Zulip Basic",
CustomerPlanOffer.TIER_SELF_HOSTED_BUSINESS: "Zulip Business",
}[tier]
@property
def name(self) -> str:
return self.name_from_tier(self.tier)
class CustomerPlan(AbstractCustomerPlan):
"""
This is for storing most of the fiddly details
of the customer's plan.
"""
automanage_licenses = models.BooleanField(default=False)
charge_automatically = models.BooleanField(default=False)
# Both of the price_per_license and fixed_price are in cents. Exactly
# one of them 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)
# Discount for current `billing_schedule`. For display purposes only.
# Explicitly set to be TextField to avoid being used in calculations.
# NOTE: This discount can be different for annual and monthly schedules.
discount = models.TextField(null=True)
# Initialized with the time of plan creation. Used for calculating
# start of next billing cycle, next invoice date etc. This value
# should never be modified. The only exception is when we change
# the status of the plan from free trial to active and reset the
# billing_cycle_anchor.
billing_cycle_anchor = models.DateTimeField()
BILLING_SCHEDULE_ANNUAL = 1
BILLING_SCHEDULE_MONTHLY = 2
BILLING_SCHEDULES = {
BILLING_SCHEDULE_ANNUAL: "Annual",
BILLING_SCHEDULE_MONTHLY: "Monthly",
}
billing_schedule = models.SmallIntegerField()
# The next date the billing system should go through ledger
# entries and create invoices for additional users or plan
# renewal. Since we use a daily cron job for invoicing, the
# invoice will be generated the first time the cron job runs after
# next_invoice_date.
next_invoice_date = models.DateTimeField(db_index=True, null=True)
# Flag to track if an email has been sent to Zulip team for
# invoice overdue by >= one day. Helps to send an email only once
# and not every time when cron run.
invoice_overdue_email_sent = models.BooleanField(default=False)
# Flag to track if an email has been sent to Zulip team to
# review the pricing, 60 days before the end date. Helps to send
# an email only once and not every time when cron run.
reminder_to_review_plan_email_sent = models.BooleanField(default=False)
# On next_invoice_date, we go through ledger entries that were
# created after invoiced_through and process them by generating
# invoices for any additional users and/or plan renewal. Once the
# invoice is generated, we update the value of invoiced_through
# and set it to the last ledger entry we processed.
invoiced_through = models.ForeignKey(
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
)
end_date = models.DateTimeField(null=True)
INVOICING_STATUS_DONE = 1
INVOICING_STATUS_STARTED = 2
INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT = 3
# This status field helps ensure any errors encountered during the
# invoicing process do not leave our invoicing system in a broken
# state.
invoicing_status = models.SmallIntegerField(default=INVOICING_STATUS_DONE)
TIER_CLOUD_STANDARD = 1
TIER_CLOUD_PLUS = 2
# Reserved tier IDs for future use
TIER_CLOUD_COMMUNITY = 3
TIER_CLOUD_ENTERPRISE = 4
TIER_SELF_HOSTED_BASE = 100
TIER_SELF_HOSTED_LEGACY = 101
TIER_SELF_HOSTED_COMMUNITY = 102
TIER_SELF_HOSTED_BASIC = 103
TIER_SELF_HOSTED_BUSINESS = 104
TIER_SELF_HOSTED_ENTERPRISE = 105
tier = models.SmallIntegerField()
PAID_PLAN_TIERS = [
TIER_CLOUD_STANDARD,
TIER_CLOUD_PLUS,
TIER_SELF_HOSTED_BASIC,
TIER_SELF_HOSTED_BUSINESS,
TIER_SELF_HOSTED_ENTERPRISE,
]
COMPLIMENTARY_PLAN_TIERS = [TIER_SELF_HOSTED_LEGACY]
ACTIVE = 1
DOWNGRADE_AT_END_OF_CYCLE = 2
FREE_TRIAL = 3
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
SWITCH_PLAN_TIER_NOW = 5
SWITCH_TO_MONTHLY_AT_END_OF_CYCLE = 6
DOWNGRADE_AT_END_OF_FREE_TRIAL = 7
SWITCH_PLAN_TIER_AT_PLAN_END = 8
# "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)
# Currently, all the fixed-price plans are configured for one year.
# In future, we might change this to a field.
FIXED_PRICE_PLAN_DURATION_MONTHS = 12
# TODO maybe override setattr to ensure billing_cycle_anchor, etc
# are immutable.
@override
def __str__(self) -> str:
return f"{self.name} (status: {self.get_plan_status_as_text()})"
@staticmethod
def name_from_tier(tier: int) -> str:
# NOTE: Check `statement_descriptor` values after updating this.
# Stripe has a 22 character limit on the statement descriptor length.
# https://stripe.com/docs/payments/account/statement-descriptors
return {
CustomerPlan.TIER_CLOUD_STANDARD: "Zulip Cloud Standard",
CustomerPlan.TIER_CLOUD_PLUS: "Zulip Cloud Plus",
CustomerPlan.TIER_CLOUD_ENTERPRISE: "Zulip Enterprise",
CustomerPlan.TIER_SELF_HOSTED_BASIC: "Zulip Basic",
CustomerPlan.TIER_SELF_HOSTED_BUSINESS: "Zulip Business",
CustomerPlan.TIER_SELF_HOSTED_COMMUNITY: "Community",
# Complimentary access plans should never be billed through Stripe,
# so the tier name can exceed the 22 character limit noted above.
CustomerPlan.TIER_SELF_HOSTED_LEGACY: "Zulip Basic (complimentary)",
}[tier]
@property
def name(self) -> str:
return self.name_from_tier(self.tier)
def get_plan_status_as_text(self) -> str:
return {
self.ACTIVE: "Active",
self.DOWNGRADE_AT_END_OF_CYCLE: "Downgrade end of cycle",
self.FREE_TRIAL: "Free trial",
self.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE: "Scheduled switch to annual",
self.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE: "Scheduled switch to monthly",
self.DOWNGRADE_AT_END_OF_FREE_TRIAL: "Downgrade end of free trial",
self.SWITCH_PLAN_TIER_AT_PLAN_END: "New plan scheduled",
self.ENDED: "Ended",
self.NEVER_STARTED: "Never started",
}[self.status]
def licenses(self) -> int:
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
assert ledger_entry is not None
return ledger_entry.licenses
def licenses_at_next_renewal(self) -> int | None:
if self.status in (
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
):
return None
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
assert ledger_entry is not None
return ledger_entry.licenses_at_next_renewal
def is_free_trial(self) -> bool:
return self.status == CustomerPlan.FREE_TRIAL
def is_complimentary_access_plan(self) -> bool:
return self.tier in self.COMPLIMENTARY_PLAN_TIERS
def is_a_paid_plan(self) -> bool:
return self.tier in self.PAID_PLAN_TIERS
def get_current_plan_by_customer(customer: Customer) -> CustomerPlan | None:
return CustomerPlan.objects.filter(
customer=customer, status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD
).first()
def get_current_plan_by_realm(realm: Realm) -> CustomerPlan | None:
customer = get_customer_by_realm(realm)
if customer is None:
return None
return get_current_plan_by_customer(customer)
class LicenseLedger(models.Model):
"""
This table's purpose is to store the current, and historical,
count of "seats" purchased by the organization.
Because we want to keep historical data, when the purchased
seat count changes, a new LicenseLedger object is created,
instead of updating the old one. This lets us preserve
the entire history of how the seat count changes, which is
important for analytics as well as auditing and debugging
in case of issues.
"""
plan = models.ForeignKey(CustomerPlan, on_delete=CASCADE)
# Also True for the initial upgrade.
is_renewal = models.BooleanField(default=False)
event_time = models.DateTimeField()
# The number of licenses ("seats") purchased by the organization at the time of ledger
# entry creation. Normally, to add a user the organization needs at least one spare license.
# Once a license is purchased, it is valid till the end of the billing period, irrespective
# of whether the license is used or not. So the value of licenses will never decrease for
# subsequent LicenseLedger entries in the same billing period.
licenses = models.IntegerField()
# The number of licenses the organization needs in the next billing cycle. The value of
# licenses_at_next_renewal can increase or decrease for subsequent LicenseLedger entries in
# the same billing period. For plans on automatic license management this value is usually
# equal to the number of activated users in the organization.
licenses_at_next_renewal = models.IntegerField(null=True)
@override
def __str__(self) -> str:
ledger_type = "renewal" if self.is_renewal else "update"
ledger_time = self.event_time.strftime("%Y-%m-%d %H:%M")
return f"License {ledger_type}, {self.licenses} purchased, {self.licenses_at_next_renewal} next cycle, {ledger_time} (id={self.id})"
class SponsoredPlanTypes(Enum):
# unspecified used for cloud sponsorship requests
UNSPECIFIED = ""
COMMUNITY = "Community"
BASIC = "Basic"
BUSINESS = "Business"
class ZulipSponsorshipRequest(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
requested_by = models.ForeignKey(UserProfile, on_delete=CASCADE, null=True, blank=True)
org_type = models.PositiveSmallIntegerField(
default=Realm.ORG_TYPES["unspecified"]["id"],
choices=[(t["id"], t["name"]) for t in Realm.ORG_TYPES.values()],
)
MAX_ORG_URL_LENGTH: int = 200
org_website = models.URLField(max_length=MAX_ORG_URL_LENGTH, blank=True, null=True)
org_description = models.TextField(default="")
expected_total_users = models.TextField(default="")
plan_to_use_zulip = models.TextField(default="")
paid_users_count = models.TextField(default="")
paid_users_description = models.TextField(default="")
requested_plan = models.CharField(
max_length=50,
choices=[(plan.value, plan.name) for plan in SponsoredPlanTypes],
default=SponsoredPlanTypes.UNSPECIFIED.value,
)

View File

@@ -1,11 +0,0 @@
from corporate.models.customers import Customer as Customer
from corporate.models.licenses import LicenseLedger as LicenseLedger
from corporate.models.plans import AbstractCustomerPlan as AbstractCustomerPlan
from corporate.models.plans import CustomerPlan as CustomerPlan
from corporate.models.plans import CustomerPlanOffer as CustomerPlanOffer
from corporate.models.sponsorships import SponsoredPlanTypes as SponsoredPlanTypes
from corporate.models.sponsorships import ZulipSponsorshipRequest as ZulipSponsorshipRequest
from corporate.models.stripe_state import Event as Event
from corporate.models.stripe_state import Invoice as Invoice
from corporate.models.stripe_state import PaymentIntent as PaymentIntent
from corporate.models.stripe_state import Session as Session

View File

@@ -1,89 +0,0 @@
from django.db import models
from django.db.models import CASCADE, Q
from typing_extensions import override
from zerver.models import Realm
from zilencer.models import RemoteRealm, RemoteZulipServer
class Customer(models.Model):
"""
This model primarily serves to connect a Realm with
the corresponding Stripe customer object for payment purposes
and the active plan, if any.
"""
# The actual model object that this customer is associated
# with. Exactly one of the following will be non-null.
realm = models.OneToOneField(Realm, on_delete=CASCADE, null=True)
remote_realm = models.OneToOneField(RemoteRealm, on_delete=CASCADE, null=True)
remote_server = models.OneToOneField(RemoteZulipServer, on_delete=CASCADE, null=True)
stripe_customer_id = models.CharField(max_length=255, null=True, unique=True)
sponsorship_pending = models.BooleanField(default=False)
# Discounted price for required_plan_tier in cents.
# We treat 0 as no discount. Not using `null` here keeps the
# checks simpler and avoids the cases where we forget to
# check for both `null` and 0.
monthly_discounted_price = models.IntegerField(default=0, null=False)
annual_discounted_price = models.IntegerField(default=0, null=False)
minimum_licenses = models.PositiveIntegerField(null=True)
# Used for limiting discounted price or a fixed_price
# to be used only for a particular CustomerPlan tier.
required_plan_tier = models.SmallIntegerField(null=True)
# Some non-profit organizations on manual license management pay
# only for their paid employees. We don't prevent these
# organizations from adding more users than the number of licenses
# they purchased.
exempt_from_license_number_check = models.BooleanField(default=False)
# In cents.
flat_discount = models.IntegerField(default=2000)
# Number of months left in the flat discount period.
flat_discounted_months = models.IntegerField(default=0)
class Meta:
# Enforce that at least one of these is set.
constraints = [
models.CheckConstraint(
condition=Q(realm__isnull=False)
| Q(remote_server__isnull=False)
| Q(remote_realm__isnull=False),
name="has_associated_model_object",
)
]
@override
def __str__(self) -> str:
if self.realm is not None:
return f"{self.realm!r} (with stripe_customer_id: {self.stripe_customer_id})"
elif self.remote_realm is not None:
return f"{self.remote_realm!r} (with stripe_customer_id: {self.stripe_customer_id})"
else:
return f"{self.remote_server!r} (with stripe_customer_id: {self.stripe_customer_id})"
def get_discounted_price_for_plan(self, plan_tier: int, schedule: int) -> int | None:
from corporate.models.plans import CustomerPlan
if plan_tier != self.required_plan_tier:
return None
if schedule == CustomerPlan.BILLING_SCHEDULE_ANNUAL:
return self.annual_discounted_price
assert schedule == CustomerPlan.BILLING_SCHEDULE_MONTHLY
return self.monthly_discounted_price
def get_customer_by_realm(realm: Realm) -> Customer | None:
return Customer.objects.filter(realm=realm).first()
def get_customer_by_remote_server(remote_server: RemoteZulipServer) -> Customer | None:
return Customer.objects.filter(remote_server=remote_server).first()
def get_customer_by_remote_realm(remote_realm: RemoteRealm) -> Customer | None: # nocoverage
return Customer.objects.filter(remote_realm=remote_realm).first()

View File

@@ -1,45 +0,0 @@
from django.db import models
from django.db.models import CASCADE
from typing_extensions import override
from corporate.models.plans import CustomerPlan
class LicenseLedger(models.Model):
"""
This table's purpose is to store the current, and historical,
count of "seats" purchased by the organization.
Because we want to keep historical data, when the purchased
seat count changes, a new LicenseLedger object is created,
instead of updating the old one. This lets us preserve
the entire history of how the seat count changes, which is
important for analytics as well as auditing and debugging
in case of issues.
"""
plan = models.ForeignKey(CustomerPlan, on_delete=CASCADE)
# Also True for the initial upgrade.
is_renewal = models.BooleanField(default=False)
event_time = models.DateTimeField()
# The number of licenses ("seats") purchased by the organization at the time of ledger
# entry creation. Normally, to add a user the organization needs at least one spare license.
# Once a license is purchased, it is valid till the end of the billing period, irrespective
# of whether the license is used or not. So the value of licenses will never decrease for
# subsequent LicenseLedger entries in the same billing period.
licenses = models.IntegerField()
# The number of licenses the organization needs in the next billing cycle. The value of
# licenses_at_next_renewal can increase or decrease for subsequent LicenseLedger entries in
# the same billing period. For plans on automatic license management this value is usually
# equal to the number of activated users in the organization.
licenses_at_next_renewal = models.IntegerField(null=True)
@override
def __str__(self) -> str:
ledger_type = "renewal" if self.is_renewal else "update"
ledger_time = self.event_time.strftime("%Y-%m-%d %H:%M")
return f"License {ledger_type}, {self.licenses} purchased, {self.licenses_at_next_renewal} next cycle, {ledger_time} (id={self.id})"

View File

@@ -1,265 +0,0 @@
from django.db import models
from django.db.models import CASCADE
from typing_extensions import override
from corporate.models.customers import Customer, get_customer_by_realm
from zerver.models import Realm
class AbstractCustomerPlan(models.Model):
# A customer can only have one ACTIVE / CONFIGURED plan,
# but old, inactive / processed plans are preserved to allow
# auditing - so there can be multiple CustomerPlan / CustomerPlanOffer
# objects pointing to one Customer.
customer = models.ForeignKey(Customer, on_delete=CASCADE)
fixed_price = models.IntegerField(null=True)
class Meta:
abstract = True
class CustomerPlanOffer(AbstractCustomerPlan):
"""
This is for storing offers configured via /support which
the customer is yet to buy or schedule a purchase.
Once customer buys or schedules a purchase, we create a
CustomerPlan record. The record in this table stays for
audit purpose with status=PROCESSED.
"""
TIER_CLOUD_STANDARD = 1
TIER_CLOUD_PLUS = 2
TIER_SELF_HOSTED_BASIC = 103
TIER_SELF_HOSTED_BUSINESS = 104
tier = models.SmallIntegerField()
# Whether the offer is:
# * only configured
# * processed by the customer to buy or schedule a purchase.
CONFIGURED = 1
PROCESSED = 2
status = models.SmallIntegerField()
# ID of invoice sent when chose to 'Pay by invoice'.
sent_invoice_id = models.CharField(max_length=255, null=True)
@override
def __str__(self) -> str:
return f"{self.name} (status: {self.get_plan_status_as_text()})"
def get_plan_status_as_text(self) -> str:
return {
self.CONFIGURED: "Configured",
self.PROCESSED: "Processed",
}[self.status]
@staticmethod
def name_from_tier(tier: int) -> str:
return {
CustomerPlanOffer.TIER_CLOUD_STANDARD: "Zulip Cloud Standard",
CustomerPlanOffer.TIER_CLOUD_PLUS: "Zulip Cloud Plus",
CustomerPlanOffer.TIER_SELF_HOSTED_BASIC: "Zulip Basic",
CustomerPlanOffer.TIER_SELF_HOSTED_BUSINESS: "Zulip Business",
}[tier]
@property
def name(self) -> str:
return self.name_from_tier(self.tier)
class CustomerPlan(AbstractCustomerPlan):
"""
This is for storing most of the fiddly details
of the customer's plan.
"""
automanage_licenses = models.BooleanField(default=False)
charge_automatically = models.BooleanField(default=False)
# Both of the price_per_license and fixed_price are in cents. Exactly
# one of them 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)
# Discount for current `billing_schedule`. For display purposes only.
# Explicitly set to be TextField to avoid being used in calculations.
# NOTE: This discount can be different for annual and monthly schedules.
discount = models.TextField(null=True)
# Initialized with the time of plan creation. Used for calculating
# start of next billing cycle, next invoice date etc. This value
# should never be modified. The only exception is when we change
# the status of the plan from free trial to active and reset the
# billing_cycle_anchor.
billing_cycle_anchor = models.DateTimeField()
BILLING_SCHEDULE_ANNUAL = 1
BILLING_SCHEDULE_MONTHLY = 2
BILLING_SCHEDULES = {
BILLING_SCHEDULE_ANNUAL: "Annual",
BILLING_SCHEDULE_MONTHLY: "Monthly",
}
billing_schedule = models.SmallIntegerField()
# The next date the billing system should go through ledger
# entries and create invoices for additional users or plan
# renewal. Since we use a daily cron job for invoicing, the
# invoice will be generated the first time the cron job runs after
# next_invoice_date.
next_invoice_date = models.DateTimeField(db_index=True, null=True)
# Flag to track if an email has been sent to Zulip team for delay
# of invoicing by >= one day. Helps to send an email only once
# and not every time when cron run.
stale_audit_log_data_email_sent = models.BooleanField(default=False)
# Flag to track if an email has been sent to Zulip team to
# review the pricing, 60 days before the end date. Helps to send
# an email only once and not every time when cron run.
reminder_to_review_plan_email_sent = models.BooleanField(default=False)
# On next_invoice_date, we call invoice_plan, which goes through
# ledger entries that were created after invoiced_through and
# process them. An invoice will be generated for any additional
# users and/or plan renewal (if it's the end of the billing cycle).
# Once all new ledger entries have been processed, invoiced_through
# will be have been set to the last ledger entry we checked.
invoiced_through = models.ForeignKey(
"LicenseLedger", null=True, on_delete=CASCADE, related_name="+"
)
end_date = models.DateTimeField(null=True)
INVOICING_STATUS_DONE = 1
INVOICING_STATUS_STARTED = 2
INVOICING_STATUS_INITIAL_INVOICE_TO_BE_SENT = 3
# This status field helps ensure any errors encountered during the
# invoicing process do not leave our invoicing system in a broken
# state.
invoicing_status = models.SmallIntegerField(default=INVOICING_STATUS_DONE)
TIER_CLOUD_STANDARD = 1
TIER_CLOUD_PLUS = 2
# Reserved tier IDs for future use
TIER_CLOUD_COMMUNITY = 3
TIER_CLOUD_ENTERPRISE = 4
TIER_SELF_HOSTED_BASE = 100
TIER_SELF_HOSTED_LEGACY = 101
TIER_SELF_HOSTED_COMMUNITY = 102
TIER_SELF_HOSTED_BASIC = 103
TIER_SELF_HOSTED_BUSINESS = 104
TIER_SELF_HOSTED_ENTERPRISE = 105
tier = models.SmallIntegerField()
PAID_PLAN_TIERS = [
TIER_CLOUD_STANDARD,
TIER_CLOUD_PLUS,
TIER_SELF_HOSTED_BASIC,
TIER_SELF_HOSTED_BUSINESS,
TIER_SELF_HOSTED_ENTERPRISE,
]
COMPLIMENTARY_PLAN_TIERS = [TIER_SELF_HOSTED_LEGACY]
ACTIVE = 1
DOWNGRADE_AT_END_OF_CYCLE = 2
FREE_TRIAL = 3
SWITCH_TO_ANNUAL_AT_END_OF_CYCLE = 4
SWITCH_PLAN_TIER_NOW = 5
SWITCH_TO_MONTHLY_AT_END_OF_CYCLE = 6
DOWNGRADE_AT_END_OF_FREE_TRIAL = 7
SWITCH_PLAN_TIER_AT_PLAN_END = 8
# "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)
# Currently, all the fixed-price plans are configured for one year.
# In future, we might change this to a field.
FIXED_PRICE_PLAN_DURATION_MONTHS = 12
# TODO maybe override setattr to ensure billing_cycle_anchor, etc
# are immutable.
@override
def __str__(self) -> str:
return f"{self.name} (status: {self.get_plan_status_as_text()})"
@staticmethod
def name_from_tier(tier: int) -> str:
# NOTE: Check `statement_descriptor` values after updating this.
# Stripe has a 22 character limit on the statement descriptor length.
# https://stripe.com/docs/payments/account/statement-descriptors
return {
CustomerPlan.TIER_CLOUD_STANDARD: "Zulip Cloud Standard",
CustomerPlan.TIER_CLOUD_PLUS: "Zulip Cloud Plus",
CustomerPlan.TIER_CLOUD_ENTERPRISE: "Zulip Enterprise",
CustomerPlan.TIER_SELF_HOSTED_BASIC: "Zulip Basic",
CustomerPlan.TIER_SELF_HOSTED_BUSINESS: "Zulip Business",
CustomerPlan.TIER_SELF_HOSTED_COMMUNITY: "Community",
# Complimentary access plans should never be billed through Stripe,
# so the tier name can exceed the 22 character limit noted above.
CustomerPlan.TIER_SELF_HOSTED_LEGACY: "Zulip Basic (complimentary)",
}[tier]
@property
def name(self) -> str:
return self.name_from_tier(self.tier)
def get_plan_status_as_text(self) -> str:
return {
self.ACTIVE: "Active",
self.DOWNGRADE_AT_END_OF_CYCLE: "Downgrade end of cycle",
self.FREE_TRIAL: "Free trial",
self.SWITCH_TO_ANNUAL_AT_END_OF_CYCLE: "Scheduled switch to annual",
self.SWITCH_TO_MONTHLY_AT_END_OF_CYCLE: "Scheduled switch to monthly",
self.DOWNGRADE_AT_END_OF_FREE_TRIAL: "Downgrade end of free trial",
self.SWITCH_PLAN_TIER_AT_PLAN_END: "New plan scheduled",
self.ENDED: "Ended",
self.NEVER_STARTED: "Never started",
}[self.status]
def licenses(self) -> int:
from corporate.models.licenses import LicenseLedger
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
assert ledger_entry is not None
return ledger_entry.licenses
def licenses_at_next_renewal(self) -> int | None:
from corporate.models.licenses import LicenseLedger
if self.status in (
CustomerPlan.DOWNGRADE_AT_END_OF_CYCLE,
CustomerPlan.DOWNGRADE_AT_END_OF_FREE_TRIAL,
):
return None
ledger_entry = LicenseLedger.objects.filter(plan=self).order_by("id").last()
assert ledger_entry is not None
return ledger_entry.licenses_at_next_renewal
def is_free_trial(self) -> bool:
return self.status == CustomerPlan.FREE_TRIAL
def is_complimentary_access_plan(self) -> bool:
return self.tier in self.COMPLIMENTARY_PLAN_TIERS
def is_a_paid_plan(self) -> bool:
return self.tier in self.PAID_PLAN_TIERS
def get_current_plan_by_customer(customer: Customer) -> CustomerPlan | None:
return CustomerPlan.objects.filter(
customer=customer, status__lt=CustomerPlan.LIVE_STATUS_THRESHOLD
).first()
def get_current_plan_by_realm(realm: Realm) -> CustomerPlan | None:
customer = get_customer_by_realm(realm)
if customer is None:
return None
return get_current_plan_by_customer(customer)

View File

@@ -1,40 +0,0 @@
from enum import Enum
from django.db import models
from django.db.models import CASCADE
from corporate.models.customers import Customer
from zerver.models import Realm, UserProfile
class SponsoredPlanTypes(Enum):
# unspecified used for cloud sponsorship requests
UNSPECIFIED = ""
COMMUNITY = "Community"
BASIC = "Basic"
BUSINESS = "Business"
class ZulipSponsorshipRequest(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
requested_by = models.ForeignKey(UserProfile, on_delete=CASCADE, null=True, blank=True)
org_type = models.PositiveSmallIntegerField(
default=Realm.ORG_TYPES["unspecified"]["id"],
choices=[(t["id"], t["name"]) for t in Realm.ORG_TYPES.values()],
)
MAX_ORG_URL_LENGTH: int = 200
org_website = models.URLField(max_length=MAX_ORG_URL_LENGTH, blank=True, null=True)
org_description = models.TextField(default="")
expected_total_users = models.TextField(default="")
plan_to_use_zulip = models.TextField(default="")
paid_users_count = models.TextField(default="")
paid_users_description = models.TextField(default="")
requested_plan = models.CharField(
max_length=50,
choices=[(plan.value, plan.name) for plan in SponsoredPlanTypes],
default=SponsoredPlanTypes.UNSPECIFIED.value,
)

View File

@@ -1,176 +0,0 @@
from typing import Any, Union
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import CASCADE, SET_NULL
from corporate.models.customers import Customer
class Event(models.Model):
stripe_event_id = models.CharField(max_length=255)
type = models.CharField(max_length=255)
RECEIVED = 1
EVENT_HANDLER_STARTED = 30
EVENT_HANDLER_FAILED = 40
EVENT_HANDLER_SUCCEEDED = 50
status = models.SmallIntegerField(default=RECEIVED)
content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(db_index=True)
content_object = GenericForeignKey("content_type", "object_id")
handler_error = models.JSONField(default=None, null=True)
def get_event_handler_details_as_dict(self) -> dict[str, Any]:
details_dict = {}
details_dict["status"] = {
Event.RECEIVED: "not_started",
Event.EVENT_HANDLER_STARTED: "started",
Event.EVENT_HANDLER_FAILED: "failed",
Event.EVENT_HANDLER_SUCCEEDED: "succeeded",
}[self.status]
if self.handler_error:
details_dict["error"] = self.handler_error
return details_dict
def get_last_associated_event_by_type(
content_object: Union["Invoice", "PaymentIntent", "Session"], event_type: str
) -> Event | None:
content_type = ContentType.objects.get_for_model(type(content_object))
return Event.objects.filter(
content_type=content_type, object_id=content_object.id, type=event_type
).last()
class Session(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_session_id = models.CharField(max_length=255, unique=True)
CARD_UPDATE_FROM_BILLING_PAGE = 40
CARD_UPDATE_FROM_UPGRADE_PAGE = 50
type = models.SmallIntegerField()
CREATED = 1
COMPLETED = 10
status = models.SmallIntegerField(default=CREATED)
# Did the user opt to manually manage licenses before clicking on update button?
is_manual_license_management_upgrade_session = models.BooleanField(default=False)
# CustomerPlan tier that the user is upgrading to.
tier = models.SmallIntegerField(null=True)
def get_status_as_string(self) -> str:
return {Session.CREATED: "created", Session.COMPLETED: "completed"}[self.status]
def get_type_as_string(self) -> str:
return {
Session.CARD_UPDATE_FROM_BILLING_PAGE: "card_update_from_billing_page",
Session.CARD_UPDATE_FROM_UPGRADE_PAGE: "card_update_from_upgrade_page",
}[self.type]
def to_dict(self) -> dict[str, Any]:
session_dict: dict[str, Any] = {}
session_dict["status"] = self.get_status_as_string()
session_dict["type"] = self.get_type_as_string()
session_dict["is_manual_license_management_upgrade_session"] = (
self.is_manual_license_management_upgrade_session
)
session_dict["tier"] = self.tier
event = self.get_last_associated_event()
if event is not None:
session_dict["event_handler"] = event.get_event_handler_details_as_dict()
return session_dict
def get_last_associated_event(self) -> Event | None:
if self.status == Session.CREATED:
return None
return get_last_associated_event_by_type(self, "checkout.session.completed")
class PaymentIntent(models.Model): # nocoverage
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_payment_intent_id = models.CharField(max_length=255, unique=True)
REQUIRES_PAYMENT_METHOD = 1
REQUIRES_CONFIRMATION = 20
REQUIRES_ACTION = 30
PROCESSING = 40
REQUIRES_CAPTURE = 50
CANCELLED = 60
SUCCEEDED = 70
status = models.SmallIntegerField()
last_payment_error = models.JSONField(default=None, null=True)
@classmethod
def get_status_integer_from_status_text(cls, status_text: str) -> int:
return getattr(cls, status_text.upper())
def get_status_as_string(self) -> str:
return {
PaymentIntent.REQUIRES_PAYMENT_METHOD: "requires_payment_method",
PaymentIntent.REQUIRES_CONFIRMATION: "requires_confirmation",
PaymentIntent.REQUIRES_ACTION: "requires_action",
PaymentIntent.PROCESSING: "processing",
PaymentIntent.REQUIRES_CAPTURE: "requires_capture",
PaymentIntent.CANCELLED: "cancelled",
PaymentIntent.SUCCEEDED: "succeeded",
}[self.status]
def get_last_associated_event(self) -> Event | None:
if self.status == PaymentIntent.SUCCEEDED:
event_type = "payment_intent.succeeded"
# TODO: Add test for this case. Not sure how to trigger naturally.
else: # nocoverage
return None # nocoverage
return get_last_associated_event_by_type(self, event_type)
def to_dict(self) -> dict[str, Any]:
payment_intent_dict: dict[str, Any] = {}
payment_intent_dict["status"] = self.get_status_as_string()
event = self.get_last_associated_event()
if event is not None:
payment_intent_dict["event_handler"] = event.get_event_handler_details_as_dict()
return payment_intent_dict
class Invoice(models.Model):
customer = models.ForeignKey(Customer, on_delete=CASCADE)
stripe_invoice_id = models.CharField(max_length=255, unique=True)
plan = models.ForeignKey("CustomerPlan", null=True, default=None, on_delete=SET_NULL)
is_created_for_free_trial_upgrade = models.BooleanField(default=False)
SENT = 1
PAID = 2
VOID = 3
status = models.SmallIntegerField()
def get_status_as_string(self) -> str:
return {
Invoice.SENT: "sent",
Invoice.PAID: "paid",
Invoice.VOID: "void",
}[self.status]
def get_last_associated_event(self) -> Event | None:
if self.status == Invoice.PAID:
event_type = "invoice.paid"
# TODO: Add test for this case. Not sure how to trigger naturally.
else: # nocoverage
return None # nocoverage
return get_last_associated_event_by_type(self, event_type)
def to_dict(self) -> dict[str, Any]:
stripe_invoice_dict: dict[str, Any] = {}
stripe_invoice_dict["status"] = self.get_status_as_string()
event = self.get_last_associated_event()
if event is not None:
stripe_invoice_dict["event_handler"] = event.get_event_handler_details_as_dict()
return stripe_invoice_dict

View File

@@ -19,8 +19,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"calculated_statement_descriptor": "ZULIP CLOUD STANDARD",
"captured": true,
@@ -36,15 +35,13 @@
"failure_message": null,
"fraud_details": {},
"id": "add_minimum_licenses--Charge.list.1.json",
"invoice": "in_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"advice_code": null,
"network_advice_code": null,
"network_decline_code": null,
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
@@ -86,12 +83,10 @@
"network_token": {
"used": false
},
"network_transaction_id": "100110997670110",
"overcapture": {
"maximum_amount_capturable": 100000,
"status": "unavailable"
},
"regulated_status": "unregulated",
"three_d_secure": null,
"wallet": null
},
@@ -102,6 +97,13 @@
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/NORMALIZED?s=ap",
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/add_minimum_licenses--Charge.list.1.json/refunds"
},
"review": null,
"shipping": null,
"source": null,

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -1,7 +1,7 @@
{
"data": [
{
"api_version": "2025-04-30.basil",
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
@@ -9,6 +9,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",

View File

@@ -1,31 +1,30 @@
{
"data": [
{
"api_version": "2025-04-30.basil",
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 100000,
"amount_overpaid": 0,
"amount_paid": 100000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -42,6 +41,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -60,6 +60,7 @@
"data": [
{
"amount": 100000,
"amount_excluding_tax": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -67,36 +68,46 @@
"discounts": [],
"id": "add_minimum_licenses--Event.list.2.json",
"invoice": "add_minimum_licenses--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "add_minimum_licenses--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 25,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
@@ -118,7 +129,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -128,6 +141,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -137,6 +151,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -148,53 +163,57 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"tax": null,
"test_clock": null,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 100000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}
},
"id": "add_minimum_licenses--Event.list.3.json",
"livemode": false,
"object": "event",
"pending_webhooks": 0,
"pending_webhooks": 2,
"request": {
"id": "add_minimum_licenses--Event.list.2.json",
"id": "add_minimum_licenses--Event.list.3.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.payment_succeeded"
},
{
"api_version": "2025-04-30.basil",
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 100000,
"amount_overpaid": 0,
"amount_paid": 100000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -211,6 +230,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -229,6 +249,7 @@
"data": [
{
"amount": 100000,
"amount_excluding_tax": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -236,36 +257,46 @@
"discounts": [],
"id": "add_minimum_licenses--Event.list.2.json",
"invoice": "add_minimum_licenses--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "add_minimum_licenses--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 25,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
@@ -287,7 +318,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -297,6 +330,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -306,6 +340,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -317,15 +352,20 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"tax": null,
"test_clock": null,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 100000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}
},
"id": "add_minimum_licenses--Event.list.3.json",
@@ -333,10 +373,211 @@
"object": "event",
"pending_webhooks": 0,
"request": {
"id": "add_minimum_licenses--Event.list.2.json",
"id": "add_minimum_licenses--Event.list.3.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.paid"
},
{
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 100000,
"amount_paid": 100000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"enabled": false,
"liability": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap",
"id": "add_minimum_licenses--Event.list.2.json",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [
{
"amount": 100000,
"amount_excluding_tax": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "add_minimum_licenses--Event.list.2.json",
"invoice": "add_minimum_licenses--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "add_minimum_licenses--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 25,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/add_minimum_licenses--Event.list.2.json/lines"
},
"livemode": false,
"metadata": {
"billing_schedule": "1",
"current_plan_id": "None",
"license_management": "automatic",
"licenses": "25",
"on_free_trial": "False",
"plan_tier": "1",
"user_id": "10"
},
"next_payment_attempt": null,
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
"pdf": {
"page_size": "letter"
},
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Cloud Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"tax": null,
"test_clock": null,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 100000,
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
},
"previous_attributes": {
"amount_paid": 0,
"amount_remaining": 100000,
"attempt_count": 0,
"attempted": false,
"charge": null,
"paid": false,
"status": "open",
"status_transitions": {
"paid_at": null
}
}
},
"id": "add_minimum_licenses--Event.list.3.json",
"livemode": false,
"object": "event",
"pending_webhooks": 0,
"request": {
"id": "add_minimum_licenses--Event.list.3.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.updated"
}
],
"has_more": false,

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 0,
"amount_overpaid": 0,
"amount_due": 100000,
"amount_paid": 0,
"amount_remaining": 0,
"amount_remaining": 100000,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": null,
@@ -51,10 +51,62 @@
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [],
"data": [
{
"amount": 100000,
"amount_excluding_tax": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "add_minimum_licenses--Event.list.2.json",
"invoice": "add_minimum_licenses--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "add_minimum_licenses--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 25,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
"object": "list",
"total_count": 0,
"total_count": 1,
"url": "/v1/invoices/add_minimum_licenses--Event.list.2.json/lines"
},
"livemode": false,
@@ -71,7 +123,9 @@
"number": null,
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -81,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -90,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -101,13 +157,18 @@
"paid_at": null,
"voided_at": null
},
"subtotal": 0,
"subtotal_excluding_tax": 0,
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"tax": null,
"test_clock": null,
"total": 0,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 0,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_excluding_tax": 100000,
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 100000,
"amount_overpaid": 0,
"amount_paid": 0,
"amount_remaining": 100000,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -54,6 +54,7 @@
"data": [
{
"amount": 100000,
"amount_excluding_tax": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -61,36 +62,46 @@
"discounts": [],
"id": "add_minimum_licenses--Event.list.2.json",
"invoice": "add_minimum_licenses--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "add_minimum_licenses--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 25,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
@@ -112,7 +123,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -122,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -131,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -142,13 +157,18 @@
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"tax": null,
"test_clock": null,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 100000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 100000,
"amount_overpaid": 0,
"amount_paid": 100000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -54,6 +54,7 @@
"data": [
{
"amount": 100000,
"amount_excluding_tax": 100000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -61,36 +62,46 @@
"discounts": [],
"id": "add_minimum_licenses--Event.list.2.json",
"invoice": "add_minimum_licenses--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "add_minimum_licenses--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 25,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
@@ -112,7 +123,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -122,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -131,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -142,13 +157,18 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 100000,
"subtotal_excluding_tax": 100000,
"tax": null,
"test_clock": null,
"total": 100000,
"total_discount_amounts": [],
"total_excluding_tax": 100000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -7,25 +7,41 @@
"discountable": false,
"discounts": [],
"id": "add_minimum_licenses--Event.list.2.json",
"invoice": "in_NORMALIZED",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"parent": null,
"period": {
"end": 1357095845,
"start": 1325473445
},
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "add_minimum_licenses--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"quantity": 25,
"subscription": null,
"tax_rates": [],
"test_clock": null
"test_clock": null,
"unit_amount": 4000,
"unit_amount_decimal": "4000"
}

View File

@@ -11,8 +11,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -35,7 +34,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -1,5 +1,4 @@
{
"adaptive_pricing": null,
"after_expiration": null,
"allow_promotion_codes": null,
"amount_subtotal": null,
@@ -7,16 +6,12 @@
"automatic_tax": {
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"billing_address_collection": "required",
"cancel_url": "http://zulip.testserver/upgrade/?manual_license_management=false&tier=1",
"client_reference_id": null,
"client_secret": null,
"collected_information": {
"shipping_details": null
},
"consent": null,
"consent_collection": null,
"created": 1000000000,
@@ -40,7 +35,6 @@
"tax_ids": null
},
"customer_email": null,
"discounts": null,
"expires_at": 1000000000,
"id": "add_minimum_licenses--checkout.Session.create.1.json",
"invoice": null,
@@ -66,22 +60,21 @@
"card"
],
"payment_status": "no_payment_required",
"permissions": null,
"phone_number_collection": {
"enabled": false
},
"recovered_from": null,
"saved_payment_method_options": null,
"setup_intent": "seti_NORMALIZED",
"shipping": null,
"shipping_address_collection": null,
"shipping_cost": null,
"shipping_options": [],
"shipping_rate": null,
"status": "open",
"submit_type": null,
"subscription": null,
"success_url": "http://zulip.testserver/billing/event_status/?stripe_session_id={CHECKOUT_SESSION_ID}",
"total_details": null,
"ui_mode": "hosted",
"url": "https://checkout.stripe.com/c/pay/cs_test_NORMALIZED",
"wallet_options": null
"url": "https://checkout.stripe.com/c/pay/cs_test_NORMALIZED"
}

View File

@@ -1,7 +1,6 @@
{
"data": [
{
"adaptive_pricing": null,
"after_expiration": null,
"allow_promotion_codes": null,
"amount_subtotal": null,
@@ -9,16 +8,12 @@
"automatic_tax": {
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"billing_address_collection": "required",
"cancel_url": "http://zulip.testserver/upgrade/?manual_license_management=false&tier=1",
"client_reference_id": null,
"client_secret": null,
"collected_information": {
"shipping_details": null
},
"consent": null,
"consent_collection": null,
"created": 1000000000,
@@ -42,7 +37,6 @@
"tax_ids": null
},
"customer_email": null,
"discounts": null,
"expires_at": 1000000000,
"id": "add_minimum_licenses--checkout.Session.create.1.json",
"invoice": null,
@@ -68,24 +62,23 @@
"card"
],
"payment_status": "no_payment_required",
"permissions": null,
"phone_number_collection": {
"enabled": false
},
"recovered_from": null,
"saved_payment_method_options": null,
"setup_intent": "seti_NORMALIZED",
"shipping": null,
"shipping_address_collection": null,
"shipping_cost": null,
"shipping_options": [],
"shipping_rate": null,
"status": "open",
"submit_type": null,
"subscription": null,
"success_url": "http://zulip.testserver/billing/event_status/?stripe_session_id={CHECKOUT_SESSION_ID}",
"total_details": null,
"ui_mode": "hosted",
"url": "https://checkout.stripe.com/c/pay/cs_test_NORMALIZED",
"wallet_options": null
"url": "https://checkout.stripe.com/c/pay/cs_test_NORMALIZED"
}
],
"has_more": false,

View File

@@ -19,8 +19,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"calculated_statement_descriptor": "ZULIP CLOUD STANDARD",
"captured": true,
@@ -36,15 +35,13 @@
"failure_message": null,
"fraud_details": {},
"id": "attach_discount_to_realm--Charge.list.1.json",
"invoice": "in_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"advice_code": null,
"network_advice_code": null,
"network_decline_code": null,
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
@@ -86,22 +83,26 @@
"network_token": {
"used": false
},
"network_transaction_id": "100110997670110",
"overcapture": {
"maximum_amount_capturable": 7200,
"status": "unavailable"
},
"regulated_status": "unregulated",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"radar_options": {},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/NORMALIZED?s=ap",
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/attach_discount_to_realm--Charge.list.1.json/refunds"
},
"review": null,
"shipping": null,
"source": null,

View File

@@ -19,8 +19,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"calculated_statement_descriptor": "ZULIP CLOUD STANDARD",
"captured": true,
@@ -36,15 +35,13 @@
"failure_message": null,
"fraud_details": {},
"id": "attach_discount_to_realm--Charge.list.2.json",
"invoice": "in_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"advice_code": null,
"network_advice_code": null,
"network_decline_code": null,
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
@@ -86,12 +83,10 @@
"network_token": {
"used": false
},
"network_transaction_id": "100110997670110",
"overcapture": {
"maximum_amount_capturable": 36000,
"status": "unavailable"
},
"regulated_status": "unregulated",
"three_d_secure": null,
"wallet": null
},
@@ -102,6 +97,13 @@
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/NORMALIZED?s=ap",
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/attach_discount_to_realm--Charge.list.2.json/refunds"
},
"review": null,
"shipping": null,
"source": null,
@@ -131,8 +133,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"calculated_statement_descriptor": "ZULIP CLOUD STANDARD",
"captured": true,
@@ -148,15 +149,13 @@
"failure_message": null,
"fraud_details": {},
"id": "attach_discount_to_realm--Charge.list.1.json",
"invoice": "in_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "charge",
"on_behalf_of": null,
"order": null,
"outcome": {
"advice_code": null,
"network_advice_code": null,
"network_decline_code": null,
"network_status": "approved_by_network",
"reason": null,
"risk_level": "normal",
@@ -198,22 +197,26 @@
"network_token": {
"used": false
},
"network_transaction_id": "100110997670110",
"overcapture": {
"maximum_amount_capturable": 7200,
"status": "unavailable"
},
"regulated_status": "unregulated",
"three_d_secure": null,
"wallet": null
},
"type": "card"
},
"radar_options": {},
"receipt_email": "hamlet@zulip.com",
"receipt_number": null,
"receipt_url": "https://pay.stripe.com/receipts/invoices/NORMALIZED?s=ap",
"refunded": false,
"refunds": {
"data": [],
"has_more": false,
"object": "list",
"total_count": 0,
"url": "/v1/charges/attach_discount_to_realm--Charge.list.1.json/refunds"
},
"review": null,
"shipping": null,
"source": null,

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_currency": "usd",
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_currency": "usd",
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_currency": "usd",
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_currency": "usd",
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_currency": "usd",
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -3,6 +3,7 @@
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_currency": "usd",
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",
@@ -25,8 +26,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -49,7 +49,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

View File

@@ -1,7 +1,7 @@
{
"data": [
{
"api_version": "2025-04-30.basil",
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
@@ -9,6 +9,7 @@
"balance": 0,
"created": 1000000000,
"currency": null,
"default_currency": null,
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",

View File

@@ -1,31 +1,30 @@
{
"data": [
{
"api_version": "2025-04-30.basil",
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 7200,
"amount_overpaid": 0,
"amount_paid": 7200,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -42,6 +41,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -60,6 +60,7 @@
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -67,36 +68,46 @@
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
@@ -118,7 +129,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -128,6 +141,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -137,6 +151,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -148,21 +163,26 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 7200,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}
},
"id": "attach_discount_to_realm--Event.list.3.json",
"livemode": false,
"object": "event",
"pending_webhooks": 0,
"pending_webhooks": 2,
"request": {
"id": "attach_discount_to_realm--Event.list.2.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
@@ -170,31 +190,30 @@
"type": "invoice.payment_succeeded"
},
{
"api_version": "2025-04-30.basil",
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 7200,
"amount_overpaid": 0,
"amount_paid": 7200,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -211,6 +230,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -229,6 +249,7 @@
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -236,36 +257,46 @@
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
@@ -287,7 +318,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -297,6 +330,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -306,6 +340,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -317,26 +352,232 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 7200,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}
},
"id": "attach_discount_to_realm--Event.list.3.json",
"livemode": false,
"object": "event",
"pending_webhooks": 0,
"pending_webhooks": 1,
"request": {
"id": "attach_discount_to_realm--Event.list.2.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.paid"
},
{
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 7200,
"amount_paid": 7200,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"enabled": false,
"liability": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap",
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/attach_discount_to_realm--Event.list.2.json/lines"
},
"livemode": false,
"metadata": {
"billing_schedule": "1",
"current_plan_id": "None",
"license_management": "automatic",
"licenses": "6",
"on_free_trial": "False",
"plan_tier": "1",
"user_id": "10"
},
"next_payment_attempt": null,
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
"pdf": {
"page_size": "letter"
},
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Cloud Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 7200,
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
},
"previous_attributes": {
"amount_paid": 0,
"amount_remaining": 7200,
"attempt_count": 0,
"attempted": false,
"charge": null,
"paid": false,
"status": "open",
"status_transitions": {
"paid_at": null
}
}
},
"id": "attach_discount_to_realm--Event.list.3.json",
"livemode": false,
"object": "event",
"pending_webhooks": 1,
"request": {
"id": "attach_discount_to_realm--Event.list.2.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.updated"
}
],
"has_more": false,

View File

@@ -1,7 +1,7 @@
{
"data": [
{
"api_version": "2025-04-30.basil",
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
@@ -9,6 +9,7 @@
"balance": 0,
"created": 1000000000,
"currency": "usd",
"default_currency": "usd",
"default_source": null,
"delinquent": false,
"description": "zulip (Zulip Dev)",

View File

@@ -1,31 +1,30 @@
{
"data": [
{
"api_version": "2025-04-30.basil",
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 36000,
"amount_overpaid": 0,
"amount_paid": 36000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -42,6 +41,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -60,43 +60,54 @@
"data": [
{
"amount": 36000,
"amount_excluding_tax": 36000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.6.json",
"id": "attach_discount_to_realm--Event.list.7.json",
"invoice": "attach_discount_to_realm--Event.list.6.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "6000"
}
],
"has_more": false,
@@ -118,7 +129,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -128,6 +141,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -137,6 +151,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -148,14 +163,19 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 36000,
"subtotal_excluding_tax": 36000,
"tax": null,
"test_clock": null,
"total": 36000,
"total_discount_amounts": [],
"total_excluding_tax": 36000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
}
},
@@ -168,6 +188,396 @@
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.payment_succeeded"
},
{
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 36000,
"amount_paid": 36000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"enabled": false,
"liability": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap",
"id": "attach_discount_to_realm--Event.list.6.json",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [
{
"amount": 36000,
"amount_excluding_tax": 36000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.7.json",
"invoice": "attach_discount_to_realm--Event.list.6.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "6000"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/attach_discount_to_realm--Event.list.6.json/lines"
},
"livemode": false,
"metadata": {
"billing_schedule": "1",
"current_plan_id": "None",
"license_management": "automatic",
"licenses": "6",
"on_free_trial": "False",
"plan_tier": "1",
"user_id": "10"
},
"next_payment_attempt": null,
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
"pdf": {
"page_size": "letter"
},
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Cloud Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 36000,
"subtotal_excluding_tax": 36000,
"tax": null,
"test_clock": null,
"total": 36000,
"total_discount_amounts": [],
"total_excluding_tax": 36000,
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
}
},
"id": "attach_discount_to_realm--Event.list.7.json",
"livemode": false,
"object": "event",
"pending_webhooks": 0,
"request": {
"id": "attach_discount_to_realm--Event.list.6.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.paid"
},
{
"api_version": "2020-08-27",
"created": 1000000000,
"data": {
"object": {
"account_country": "US",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 36000,
"amount_paid": 36000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"enabled": false,
"liability": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
"custom_fields": null,
"customer": "cus_NORMALIZED",
"customer_address": null,
"customer_email": "hamlet@zulip.com",
"customer_name": null,
"customer_phone": null,
"customer_shipping": null,
"customer_tax_exempt": "none",
"customer_tax_ids": [],
"default_payment_method": null,
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
"ending_balance": 0,
"footer": null,
"from_invoice": null,
"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap",
"id": "attach_discount_to_realm--Event.list.6.json",
"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap",
"issuer": {
"type": "self"
},
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [
{
"amount": 36000,
"amount_excluding_tax": 36000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.7.json",
"invoice": "attach_discount_to_realm--Event.list.6.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "6000"
}
],
"has_more": false,
"object": "list",
"total_count": 1,
"url": "/v1/invoices/attach_discount_to_realm--Event.list.6.json/lines"
},
"livemode": false,
"metadata": {
"billing_schedule": "1",
"current_plan_id": "None",
"license_management": "automatic",
"licenses": "6",
"on_free_trial": "False",
"plan_tier": "1",
"user_id": "10"
},
"next_payment_attempt": null,
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
"payment_method_types": null
},
"period_end": 1000000000,
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
"pdf": {
"page_size": "letter"
},
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
"statement_descriptor": "Zulip Cloud Standard",
"status": "paid",
"status_transitions": {
"finalized_at": 1000000000,
"marked_uncollectible_at": null,
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 36000,
"subtotal_excluding_tax": 36000,
"tax": null,
"test_clock": null,
"total": 36000,
"total_discount_amounts": [],
"total_excluding_tax": 36000,
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
},
"previous_attributes": {
"amount_paid": 0,
"amount_remaining": 36000,
"attempt_count": 0,
"attempted": false,
"charge": null,
"paid": false,
"status": "open",
"status_transitions": {
"paid_at": null
}
}
},
"id": "attach_discount_to_realm--Event.list.7.json",
"livemode": false,
"object": "event",
"pending_webhooks": 0,
"request": {
"id": "attach_discount_to_realm--Event.list.6.json",
"idempotency_key": "00000000-0000-0000-0000-000000000000"
},
"type": "invoice.updated"
}
],
"has_more": false,

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 0,
"amount_overpaid": 0,
"amount_due": 7200,
"amount_paid": 0,
"amount_remaining": 0,
"amount_remaining": 7200,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": null,
@@ -51,10 +51,62 @@
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [],
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
"object": "list",
"total_count": 0,
"total_count": 1,
"url": "/v1/invoices/attach_discount_to_realm--Event.list.2.json/lines"
},
"livemode": false,
@@ -71,7 +123,9 @@
"number": null,
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -81,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -90,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -101,13 +157,18 @@
"paid_at": null,
"voided_at": null
},
"subtotal": 0,
"subtotal_excluding_tax": 0,
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 0,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 0,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_excluding_tax": 7200,
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 0,
"amount_overpaid": 0,
"amount_due": 36000,
"amount_paid": 0,
"amount_remaining": 0,
"amount_remaining": 36000,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": null,
@@ -51,10 +51,62 @@
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [],
"data": [
{
"amount": 36000,
"amount_excluding_tax": 36000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.7.json",
"invoice": "attach_discount_to_realm--Event.list.6.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1357095845,
"start": 1325473445
},
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "6000"
}
],
"has_more": false,
"object": "list",
"total_count": 0,
"total_count": 1,
"url": "/v1/invoices/attach_discount_to_realm--Event.list.6.json/lines"
},
"livemode": false,
@@ -71,7 +123,9 @@
"number": null,
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -81,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -90,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -101,13 +157,18 @@
"paid_at": null,
"voided_at": null
},
"subtotal": 0,
"subtotal_excluding_tax": 0,
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 36000,
"subtotal_excluding_tax": 36000,
"tax": null,
"test_clock": null,
"total": 0,
"total": 36000,
"total_discount_amounts": [],
"total_excluding_tax": 0,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_excluding_tax": 36000,
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 0,
"amount_overpaid": 0,
"amount_due": 24000,
"amount_paid": 0,
"amount_remaining": 0,
"amount_remaining": 24000,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": 1000000000,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": null,
@@ -51,10 +51,62 @@
"last_finalization_error": null,
"latest_revision": null,
"lines": {
"data": [],
"data": [
{
"amount": 24000,
"amount_excluding_tax": 24000,
"currency": "usd",
"description": "Zulip Cloud Standard - renewal",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Invoice.create.3.json",
"invoice": "attach_discount_to_realm--Invoice.create.3.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"period": {
"end": 1388631845,
"start": 1357095845
},
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Invoice.create.3.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
"object": "list",
"total_count": 0,
"total_count": 1,
"url": "/v1/invoices/attach_discount_to_realm--Invoice.create.3.json/lines"
},
"livemode": false,
@@ -63,7 +115,9 @@
"number": null,
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": null,
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -73,6 +127,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -82,6 +137,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -93,13 +149,18 @@
"paid_at": null,
"voided_at": null
},
"subtotal": 0,
"subtotal_excluding_tax": 0,
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 24000,
"subtotal_excluding_tax": 24000,
"tax": null,
"test_clock": null,
"total": 0,
"total": 24000,
"total_discount_amounts": [],
"total_excluding_tax": 0,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_excluding_tax": 24000,
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 7200,
"amount_overpaid": 0,
"amount_paid": 0,
"amount_remaining": 7200,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -54,6 +54,7 @@
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -61,36 +62,46 @@
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
@@ -112,7 +123,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -122,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -131,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -142,13 +157,18 @@
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 7200,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 36000,
"amount_overpaid": 0,
"amount_paid": 0,
"amount_remaining": 36000,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -54,43 +54,54 @@
"data": [
{
"amount": 36000,
"amount_excluding_tax": 36000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.6.json",
"id": "attach_discount_to_realm--Event.list.7.json",
"invoice": "attach_discount_to_realm--Event.list.6.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "6000"
}
],
"has_more": false,
@@ -112,7 +123,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -122,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -131,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -142,13 +157,18 @@
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 36000,
"subtotal_excluding_tax": 36000,
"tax": null,
"test_clock": null,
"total": 36000,
"total_discount_amounts": [],
"total_excluding_tax": 36000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 24000,
"amount_overpaid": 0,
"amount_paid": 0,
"amount_remaining": 24000,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -54,43 +54,54 @@
"data": [
{
"amount": 24000,
"amount_excluding_tax": 24000,
"currency": "usd",
"description": "Zulip Cloud Standard - renewal",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Invoice.finalize_invoice.3.json",
"id": "attach_discount_to_realm--Invoice.create.3.json",
"invoice": "attach_discount_to_realm--Invoice.create.3.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1388631845,
"start": 1357095845
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Invoice.create.3.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
@@ -104,7 +115,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -114,6 +127,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -123,6 +137,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -134,13 +149,18 @@
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 24000,
"subtotal_excluding_tax": 24000,
"tax": null,
"test_clock": null,
"total": 24000,
"total_discount_amounts": [],
"total_excluding_tax": 24000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -2,26 +2,25 @@
"data": [
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 7200,
"amount_overpaid": 0,
"amount_paid": 7200,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -38,6 +37,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -56,6 +56,7 @@
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -63,36 +64,46 @@
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
@@ -114,7 +125,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -124,6 +137,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -133,6 +147,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -144,14 +159,19 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 7200,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
}
],

View File

@@ -2,26 +2,25 @@
"data": [
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 36000,
"amount_overpaid": 0,
"amount_paid": 36000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -38,6 +37,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -56,43 +56,54 @@
"data": [
{
"amount": 36000,
"amount_excluding_tax": 36000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.6.json",
"id": "attach_discount_to_realm--Event.list.7.json",
"invoice": "attach_discount_to_realm--Event.list.6.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "6000"
}
],
"has_more": false,
@@ -114,7 +125,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -124,6 +137,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -133,6 +147,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -144,38 +159,42 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 36000,
"subtotal_excluding_tax": 36000,
"tax": null,
"test_clock": null,
"total": 36000,
"total_discount_amounts": [],
"total_excluding_tax": 36000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
},
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 7200,
"amount_overpaid": 0,
"amount_paid": 7200,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -192,6 +211,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -210,6 +230,7 @@
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -217,36 +238,46 @@
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
@@ -268,7 +299,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -278,6 +311,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -287,6 +321,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -298,14 +333,19 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 7200,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
}
],

View File

@@ -2,26 +2,25 @@
"data": [
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 24000,
"amount_overpaid": 0,
"amount_paid": 0,
"amount_remaining": 24000,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 0,
"attempted": false,
"auto_advance": true,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": null,
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -38,6 +37,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -56,43 +56,54 @@
"data": [
{
"amount": 24000,
"amount_excluding_tax": 24000,
"currency": "usd",
"description": "Zulip Cloud Standard - renewal",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Invoice.finalize_invoice.3.json",
"id": "attach_discount_to_realm--Invoice.create.3.json",
"invoice": "attach_discount_to_realm--Invoice.create.3.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1388631845,
"start": 1357095845
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Invoice.create.3.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "4000"
}
],
"has_more": false,
@@ -106,7 +117,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": false,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -116,6 +129,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -125,6 +139,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -136,38 +151,42 @@
"paid_at": null,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 24000,
"subtotal_excluding_tax": 24000,
"tax": null,
"test_clock": null,
"total": 24000,
"total_discount_amounts": [],
"total_excluding_tax": 24000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
},
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 36000,
"amount_overpaid": 0,
"amount_paid": 36000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -184,6 +203,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -202,43 +222,54 @@
"data": [
{
"amount": 36000,
"amount_excluding_tax": 36000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.6.json",
"id": "attach_discount_to_realm--Event.list.7.json",
"invoice": "attach_discount_to_realm--Event.list.6.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "6000"
}
],
"has_more": false,
@@ -260,7 +291,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -270,6 +303,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -279,6 +313,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -290,38 +325,42 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 36000,
"subtotal_excluding_tax": 36000,
"tax": null,
"test_clock": null,
"total": 36000,
"total_discount_amounts": [],
"total_excluding_tax": 36000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
},
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 7200,
"amount_overpaid": 0,
"amount_paid": 7200,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -338,6 +377,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -356,6 +396,7 @@
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -363,36 +404,46 @@
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
@@ -414,7 +465,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -424,6 +477,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -433,6 +487,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -444,14 +499,19 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 7200,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
}
],

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 7200,
"amount_overpaid": 0,
"amount_paid": 7200,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -54,6 +54,7 @@
"data": [
{
"amount": 7200,
"amount_excluding_tax": 7200,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
@@ -61,36 +62,46 @@
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "attach_discount_to_realm--Event.list.2.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "1200"
}
],
"has_more": false,
@@ -112,7 +123,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -122,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -131,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -142,13 +157,18 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 7200,
"subtotal_excluding_tax": 7200,
"tax": null,
"test_clock": null,
"total": 7200,
"total_discount_amounts": [],
"total_excluding_tax": 7200,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"webhooks_delivered_at": 1000000000
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": null
}

View File

@@ -1,25 +1,24 @@
{
"account_country": "US",
"account_name": "NORMALIZED",
"account_name": "Kandra Labs, Inc.",
"account_tax_ids": null,
"amount_due": 36000,
"amount_overpaid": 0,
"amount_paid": 36000,
"amount_remaining": 0,
"amount_shipping": 0,
"application": null,
"application_fee_amount": null,
"attempt_count": 1,
"attempted": true,
"auto_advance": false,
"automatic_tax": {
"disabled_reason": null,
"enabled": false,
"liability": null,
"provider": null,
"status": null
},
"automatically_finalizes_at": null,
"billing_reason": "manual",
"charge": "ch_NORMALIZED",
"collection_method": "charge_automatically",
"created": 1000000000,
"currency": "usd",
@@ -36,6 +35,7 @@
"default_source": null,
"default_tax_rates": [],
"description": null,
"discount": null,
"discounts": [],
"due_date": null,
"effective_at": 1000000000,
@@ -54,43 +54,54 @@
"data": [
{
"amount": 36000,
"amount_excluding_tax": 36000,
"currency": "usd",
"description": "Zulip Cloud Standard",
"discount_amounts": [],
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.6.json",
"id": "attach_discount_to_realm--Event.list.7.json",
"invoice": "attach_discount_to_realm--Event.list.6.json",
"invoice_item": "ii_NORMALIZED",
"livemode": false,
"metadata": {},
"object": "line_item",
"parent": {
"invoice_item_details": {
"invoice_item": "ii_NORMALIZED",
"proration": false,
"proration_details": {
"credited_items": null
},
"subscription": null
},
"subscription_item_details": null,
"type": "invoice_item_details"
},
"period": {
"end": 1357095845,
"start": 1325473445
},
"pretax_credit_amounts": [],
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"proration_details": {
"credited_items": null
},
"quantity": 6,
"taxes": []
"subscription": null,
"tax_amounts": [],
"tax_rates": [],
"type": "invoiceitem",
"unit_amount_excluding_tax": "6000"
}
],
"has_more": false,
@@ -112,7 +123,9 @@
"number": "NORMALIZED",
"object": "invoice",
"on_behalf_of": null,
"parent": null,
"paid": true,
"paid_out_of_band": false,
"payment_intent": "pi_NORMALIZED",
"payment_settings": {
"default_mandate": null,
"payment_method_options": null,
@@ -122,6 +135,7 @@
"period_start": 1000000000,
"post_payment_credit_notes_amount": 0,
"pre_payment_credit_notes_amount": 0,
"quote": null,
"receipt_number": null,
"rendering": {
"amount_tax_display": null,
@@ -131,6 +145,7 @@
"template": null,
"template_version": null
},
"rendering_options": null,
"shipping_cost": null,
"shipping_details": null,
"starting_balance": 0,
@@ -142,13 +157,18 @@
"paid_at": 1000000000,
"voided_at": null
},
"subscription": null,
"subscription_details": {
"metadata": null
},
"subtotal": 36000,
"subtotal_excluding_tax": 36000,
"tax": null,
"test_clock": null,
"total": 36000,
"total_discount_amounts": [],
"total_excluding_tax": 36000,
"total_pretax_credit_amounts": [],
"total_taxes": [],
"total_tax_amounts": [],
"transfer_data": null,
"webhooks_delivered_at": 1000000000
}

View File

@@ -7,25 +7,41 @@
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.2.json",
"invoice": "in_NORMALIZED",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"parent": null,
"period": {
"end": 1357095845,
"start": 1325473445
},
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.2.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 1200,
"unit_amount_decimal": "1200"
},
"proration": false,
"quantity": 6,
"subscription": null,
"tax_rates": [],
"test_clock": null
"test_clock": null,
"unit_amount": 1200,
"unit_amount_decimal": "1200"
}

View File

@@ -7,25 +7,41 @@
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--Event.list.6.json",
"invoice": "in_NORMALIZED",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"parent": null,
"period": {
"end": 1357095845,
"start": 1325473445
},
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Event.list.6.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 6000,
"unit_amount_decimal": "6000"
},
"proration": false,
"quantity": 6,
"subscription": null,
"tax_rates": [],
"test_clock": null
"test_clock": null,
"unit_amount": 6000,
"unit_amount_decimal": "6000"
}

View File

@@ -7,25 +7,41 @@
"discountable": false,
"discounts": [],
"id": "attach_discount_to_realm--InvoiceItem.create.3.json",
"invoice": "in_NORMALIZED",
"invoice": null,
"livemode": false,
"metadata": {},
"object": "invoiceitem",
"parent": null,
"period": {
"end": 1388631845,
"start": 1357095845
},
"pricing": {
"price_details": {
"price": "price_NORMALIZED",
"product": "prod_NORMALIZED"
},
"type": "price_details",
"plan": null,
"price": {
"active": false,
"billing_scheme": "per_unit",
"created": 1000000000,
"currency": "usd",
"custom_unit_amount": null,
"id": "attach_discount_to_realm--Invoice.create.3.json",
"livemode": false,
"lookup_key": null,
"metadata": {},
"nickname": null,
"object": "price",
"product": "prod_NORMALIZED",
"recurring": null,
"tax_behavior": "unspecified",
"tiers_mode": null,
"transform_quantity": null,
"type": "one_time",
"unit_amount": 4000,
"unit_amount_decimal": "4000"
},
"proration": false,
"quantity": 6,
"subscription": null,
"tax_rates": [],
"test_clock": null
"test_clock": null,
"unit_amount": 4000,
"unit_amount_decimal": "4000"
}

View File

@@ -11,8 +11,7 @@
},
"email": null,
"name": "John Doe",
"phone": null,
"tax_id": null
"phone": null
},
"card": {
"brand": "visa",
@@ -35,7 +34,6 @@
],
"preferred": null
},
"regulated_status": "unregulated",
"three_d_secure_usage": {
"supported": true
},

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