Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
1060 lines
45 KiB
Python
1060 lines
45 KiB
Python
"""Stripe billing class and test fixture generation
|
|
|
|
Record-and-replay fixture harness: ``mock_stripe`` intercepts every
|
|
``stripe.*`` call and either reads a saved JSON fixture (default,
|
|
offline) or hits the real Stripe test network (when
|
|
``--generate-stripe-fixtures`` is passed).
|
|
|
|
Fixtures live in ``corporate/tests/stripe_fixtures/``, one file
|
|
per call, named ``<test>--<Class>.<method>.<call_count>.json``
|
|
(e.g. ``upgrade_by_card--Customer.create.1.json``).
|
|
|
|
Two special cases keep ``Event.list`` fixture generation stable,
|
|
even though Stripe's view of "recent events" changes every run:
|
|
|
|
- ``StripeTestCase.pin_event_cursor`` returns the id of Stripe's
|
|
most-recent event, used as the ``ending_before`` cursor for the
|
|
next ``send_stripe_webhook_events`` poll. In regen, it bypasses the
|
|
mock and calls Stripe directly (no fixture saved; the response is
|
|
randomized). In replay it returns a sentinel id, and the mocked
|
|
``Event.list`` ignores ``ending_before``.
|
|
- ``send_stripe_webhook_events`` runs a cursor-stable polling loop in
|
|
regen, accumulates the union of events seen across the run, keeps
|
|
only ``HANDLED_STRIPE_EVENT_TYPES`` (matching the webhook view's
|
|
dispatcher), and writes one canonical fixture per polling run.
|
|
Replay reads that fixture once and delivers every event in it.
|
|
|
|
After each test, ``normalize_fixture_data`` rewrites every saved
|
|
fixture in place to collapse per-run variance (ids, timestamps, uuids,
|
|
etc.) so regenerating fixtures produces zero diff. See
|
|
``FIXTURE_NORMALIZE_REAL_TIMESTAMP_RE`` for how Stripe-generated
|
|
timestamps are distinguished from test-supplied 2012 ones.
|
|
|
|
Prior-test events, that Stripe finalizes after our cursor pin, are
|
|
dropped during normalization: their ``data.object.customer`` matches
|
|
``cus_UNRECORDED`` (no recorded ``Customer.create``) while our test's
|
|
customer matches the fixture file name.
|
|
|
|
To regenerate fixtures for a single test::
|
|
|
|
./tools/test-backend --generate-stripe-fixtures \\
|
|
corporate.tests.test_stripe.StripeTest.test_foo
|
|
|
|
Requires ``stripe_secret_key`` for a Stripe sandbox account set in
|
|
``zproject/dev-secrets.conf``.
|
|
"""
|
|
|
|
import json
|
|
import operator
|
|
import os
|
|
import re
|
|
import sys
|
|
from collections.abc import Callable, Iterator, Mapping, Sequence
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timezone
|
|
from functools import wraps
|
|
from time import monotonic, sleep
|
|
from typing import TYPE_CHECKING, Any, Literal, Optional, TypeVar, cast
|
|
from unittest import mock
|
|
from unittest.mock import Mock, patch
|
|
|
|
import orjson
|
|
import responses
|
|
import stripe
|
|
import time_machine
|
|
from django.conf import settings
|
|
from django.utils.crypto import get_random_string
|
|
from typing_extensions import ParamSpec, override
|
|
|
|
from corporate.lib.stripe import (
|
|
STRIPE_API_VERSION,
|
|
RealmBillingSession,
|
|
RemoteRealmBillingSession,
|
|
RemoteServerBillingSession,
|
|
get_latest_seat_count,
|
|
is_free_trial_offer_enabled,
|
|
sign_string,
|
|
stripe_customer_has_credit_card_as_default_payment_method,
|
|
stripe_get_customer,
|
|
)
|
|
from corporate.models.customers import Customer
|
|
from corporate.models.licenses import LicenseLedger
|
|
from corporate.models.plans import CustomerPlan
|
|
from corporate.models.stripe_state import Invoice
|
|
from zerver.actions.users import do_deactivate_user
|
|
from zerver.lib.test_classes import ZulipTestCase
|
|
from zerver.lib.utils import assert_is_not_none
|
|
from zerver.models import UserProfile
|
|
from zerver.models.realms import get_realm
|
|
|
|
if TYPE_CHECKING:
|
|
from django.test.client import _MonkeyPatchedWSGIResponse as TestHttpResponse
|
|
|
|
CallableT = TypeVar("CallableT", bound=Callable[..., Any])
|
|
ParamT = ParamSpec("ParamT")
|
|
ReturnT = TypeVar("ReturnT")
|
|
|
|
STRIPE_FIXTURES_DIR = "corporate/tests/stripe_fixtures"
|
|
|
|
# Match Unix timestamps in the 1500000000..1999999999 range (approximately
|
|
# 2017-07-14 through 2033-05-18) for Stripe "real-world" timestamps in
|
|
# fixtures we generate for tests.
|
|
FIXTURE_NORMALIZE_REAL_TIMESTAMP_RE = r": (1[5-9][0-9]{8})(?![0-9-])"
|
|
|
|
|
|
def stripe_fixture_path(
|
|
decorated_function_name: str, mocked_function_name: str, call_count: int
|
|
) -> str:
|
|
# Make the eventual filename a bit shorter, and also we conventionally
|
|
# use test_* for the python test files
|
|
decorated_function_name = decorated_function_name.removeprefix("test_")
|
|
mocked_function_name = mocked_function_name.removeprefix("stripe.")
|
|
return (
|
|
f"{STRIPE_FIXTURES_DIR}/{decorated_function_name}--{mocked_function_name}.{call_count}.json"
|
|
)
|
|
|
|
|
|
def fixture_files_for_function(decorated_function: CallableT) -> list[str]: # nocoverage
|
|
decorated_function_name = decorated_function.__name__
|
|
decorated_function_name = decorated_function_name.removeprefix("test_")
|
|
|
|
def call_order_key(filename: str) -> tuple[str, int]:
|
|
# Filenames look like `upgrade_by_card--Customer.create.10.json`,
|
|
# e.g., `<test>--<class>.<method>.<call_count>.json`. Split the
|
|
# filename strings and sort such that a call_count of 2 comes
|
|
# before 10, matching regen order.
|
|
stem = filename[: -len(".json")]
|
|
prefix, _, call_count = stem.rpartition(".")
|
|
return (prefix, int(call_count))
|
|
|
|
return [
|
|
f"{STRIPE_FIXTURES_DIR}/{f}"
|
|
for f in sorted(
|
|
(
|
|
f
|
|
for f in os.listdir(STRIPE_FIXTURES_DIR)
|
|
if f.startswith(decorated_function_name + "--")
|
|
),
|
|
key=call_order_key,
|
|
)
|
|
]
|
|
|
|
|
|
def generate_and_save_stripe_fixture(
|
|
decorated_function_name: str, mocked_function_name: str, mocked_function: CallableT
|
|
) -> Callable[[Any, Any], Any]: # nocoverage
|
|
def _generate_and_save_stripe_fixture(*args: Any, **kwargs: Any) -> Any:
|
|
# Note that mock is not the same as mocked_function, even though their
|
|
# definitions look the same
|
|
mock = operator.attrgetter(mocked_function_name)(sys.modules[__name__])
|
|
fixture_path = stripe_fixture_path(
|
|
decorated_function_name, mocked_function_name, mock.call_count
|
|
)
|
|
try:
|
|
with _allow_stripe_api_passthru():
|
|
# Talk to Stripe
|
|
stripe_object = mocked_function(*args, **kwargs)
|
|
except stripe.StripeError as e:
|
|
with open(fixture_path, "w") as f:
|
|
assert e.headers is not None
|
|
error_dict = {**vars(e), "headers": dict(e.headers)}
|
|
# Add http_body to the error_dict, since it's not included in the vars(e) output.
|
|
# It should be same as e.json_body, but we include it since stripe expects it.
|
|
if e.http_body is None:
|
|
assert e.json_body is not None
|
|
# Convert e.json_body to be a JSON string, since that's what stripe expects.
|
|
error_dict["http_body"] = json.dumps(e.json_body)
|
|
# vars(e) includes a stripe.ErrorObject, which is a StripeObject subclass that
|
|
# json.dumps doesn't know how to serialize. Since read_stripe_fixture only
|
|
# consumes http_body, http_status and headers for StripeError test fixtures,
|
|
# coercing the rest to strings via `default=str` is safe.
|
|
f.write(
|
|
json.dumps(
|
|
error_dict,
|
|
indent=2,
|
|
separators=(",", ": "),
|
|
sort_keys=True,
|
|
default=str,
|
|
)
|
|
+ "\n"
|
|
)
|
|
raise
|
|
with open(fixture_path, "w") as f:
|
|
if stripe_object is not None:
|
|
f.write(str(stripe_object) + "\n")
|
|
else:
|
|
f.write("{}\n")
|
|
return stripe_object
|
|
|
|
return _generate_and_save_stripe_fixture
|
|
|
|
|
|
def read_stripe_fixture(
|
|
decorated_function_name: str, mocked_function_name: str
|
|
) -> Callable[[Any, Any], Any]:
|
|
def _read_stripe_fixture(*args: Any, **kwargs: Any) -> Any:
|
|
mock = operator.attrgetter(mocked_function_name)(sys.modules[__name__])
|
|
fixture_path = stripe_fixture_path(
|
|
decorated_function_name, mocked_function_name, mock.call_count
|
|
)
|
|
with open(fixture_path, "rb") as f:
|
|
fixture = orjson.loads(f.read())
|
|
# Check for StripeError fixtures
|
|
if "json_body" in fixture:
|
|
requester = stripe._api_requestor._APIRequestor()
|
|
# This function will raise the relevant StripeError according to the fixture
|
|
requester._interpret_response(
|
|
fixture["http_body"], fixture["http_status"], fixture["headers"], "V1"
|
|
)
|
|
return stripe.convert_to_stripe_object(fixture)
|
|
|
|
return _read_stripe_fixture
|
|
|
|
|
|
def delete_fixture_data(decorated_function: CallableT) -> None: # nocoverage
|
|
for fixture_file in fixture_files_for_function(decorated_function):
|
|
os.remove(fixture_file)
|
|
|
|
|
|
def normalize_fixture_data(
|
|
decorated_function: CallableT, zulip_realm_uuid: str
|
|
) -> None: # nocoverage
|
|
# stripe ids are all of the form cus_D7OT2jf5YAtZQ2
|
|
id_lengths = [
|
|
("test", 12),
|
|
("cus", 14),
|
|
("prod", 14),
|
|
("req", 14),
|
|
("si", 14),
|
|
("sli", 14),
|
|
("sub", 14),
|
|
("acct", 16),
|
|
("card", 24),
|
|
("ch", 24),
|
|
("ii", 24),
|
|
("il", 24),
|
|
("in", 24),
|
|
("pi", 24),
|
|
("price", 24),
|
|
("src", 24),
|
|
("src_client_secret", 24),
|
|
("tok", 24),
|
|
("txn", 24),
|
|
("invst", 26),
|
|
("rcpt", 31),
|
|
("seti", 24),
|
|
("pm", 24),
|
|
("setatt", 24),
|
|
("bpc", 24),
|
|
("bps", 24),
|
|
("file", 24),
|
|
]
|
|
|
|
# We'll replace "invoice_prefix": "A35BC4Q" with something like "invoice_prefix": "NORMA01"
|
|
# For patterns whose matches can be too generic like `[0-9]+`, include matching field in the translation
|
|
# to avoid it replacing other occurrences of the pattern. See `exp_month` for example.
|
|
pattern_translations = {
|
|
r'"exp_month": [0-9]+': '"exp_month": 1',
|
|
r'"exp_year": [0-9]+': '"exp_year": 9999',
|
|
r'"postal_code": "[0-9]+"': '"postal_code": "12345"',
|
|
r'"invoice_prefix": "[A-Za-z0-9]{7,8}"': '"invoice_prefix": "NORMALIZED"',
|
|
r'"fingerprint": "[A-Za-z0-9]{16}"': '"fingerprint": "NORMALIZED"',
|
|
r'"number": "[A-Za-z0-9]{7,8}-[A-Za-z0-9]{4}"': '"number": "NORMALIZED"',
|
|
r'"address": "[A-Za-z0-9]{9}-test_[A-Za-z0-9]{12}"': '"address": "000000000-test_NORMALIZED"',
|
|
r'"url": "https://billing.stripe.com/p/session/test_([\w]+)"': "NORMALIZED",
|
|
r'"url": "https://checkout.stripe.com/c/pay/cs_test_([\w#%]+)"': "NORMALIZED",
|
|
r'"receipt_url": "https://pay.stripe.com/receipts/invoices/([\w-]+)\?s=[\w]+"': "NORMALIZED",
|
|
r'"hosted_invoice_url": "https://invoice.stripe.com/i/acct_[\w]+/test_[\w,]+\?s=[\w]+"': '"hosted_invoice_url": "https://invoice.stripe.com/i/acct_NORMALIZED/test_NORMALIZED?s=ap"',
|
|
r'"invoice_pdf": "https://pay.stripe.com/invoice/acct_[\w]+/test_[\w,]+/pdf\?s=[\w]+"': '"invoice_pdf": "https://pay.stripe.com/invoice/acct_NORMALIZED/test_NORMALIZED/pdf?s=ap"',
|
|
r'"id": "([\w]+)"': "FILE_NAME", # Replace with file name later.
|
|
# Don't use (..) notation, since the matched strings may be small integers that will also match
|
|
# elsewhere in the file
|
|
r'"realm_id": "[0-9]+"': '"realm_id": "1"',
|
|
r'"account_name": "[^"]+"': '"account_name": "NORMALIZED"',
|
|
r'"display_name": "[^"]+"': '"display_name": "STRIPE SANDBOX"',
|
|
}
|
|
|
|
# Customer IDs whose ``Customer.create`` we never recorded get the
|
|
# ``cus_UNRECORDED`` suffix as the ``Event.list`` check below uses
|
|
# that exact suffix to filter prior-test customers' webhook events.
|
|
# Other ID placeholders use a generic ``<prefix>_NORMALIZED``, as
|
|
# there's no load-bearing meaning and just a "this ID was redacted"
|
|
# marker is sufficient. Customer IDs we did record are re-replaced
|
|
# with their fixture filename by the ``"id":`` pass below.
|
|
pattern_translations.update(
|
|
{
|
|
rf"{prefix}_[A-Za-z0-9]{{{length}}}": (
|
|
f"{prefix}_UNRECORDED" if prefix == "cus" else f"{prefix}_NORMALIZED"
|
|
)
|
|
for prefix, length in id_lengths
|
|
}
|
|
)
|
|
normalized_values: dict[str, dict[str, str]] = {pattern: {} for pattern in pattern_translations}
|
|
for fixture_file in fixture_files_for_function(decorated_function):
|
|
with open(fixture_file) as f:
|
|
file_content = f.read()
|
|
for pattern, translation in pattern_translations.items():
|
|
for match in re.findall(pattern, file_content):
|
|
if match not in normalized_values[pattern]:
|
|
if pattern.startswith('"id": "'):
|
|
# Set file name as ID.
|
|
normalized_values[pattern][match] = fixture_file.split("/")[-1]
|
|
else:
|
|
normalized_values[pattern][match] = translation
|
|
# Stripe IDs leak across files: ``cus_XYZ`` is an ``"id"`` value in
|
|
# ``Customer.create`` and a ``"customer"`` value in every event
|
|
# referencing it. Sweep every mapping this pattern has accumulated so
|
|
# far, including ones registered while normalizing an earlier fixture,
|
|
# not just the matches ``findall`` returned for this file.
|
|
for original, normalized in normalized_values[pattern].items():
|
|
file_content = file_content.replace(original, normalized)
|
|
file_content = re.sub(r'(?<="risk_score": )(\d+)', "0", file_content)
|
|
file_content = re.sub(r'(?<="times_redeemed": )(\d+)', "0", file_content)
|
|
file_content = re.sub(
|
|
r'"authorization_code": "[0-9]+"', '"authorization_code": "000000"', file_content
|
|
)
|
|
file_content = re.sub(
|
|
r'"network_transaction_id": "[0-9]+"',
|
|
'"network_transaction_id": "000000000000000"',
|
|
file_content,
|
|
)
|
|
# Idempotency keys appear as a lowercase JSON field ("idempotency_key") and
|
|
# as a response-header field ("Idempotency-Key"). Stripe assigns a fresh
|
|
# uuid for both on every call.
|
|
file_content = re.sub(
|
|
r'(?<=")(idempotency_key|Idempotency-Key)": "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"',
|
|
r'\1": "00000000-0000-0000-0000-000000000000"',
|
|
file_content,
|
|
)
|
|
# client_secret fields appear both at the JSON top-level of fixtures and
|
|
# inside doubly-escaped strings in error fixtures. Match both forms.
|
|
file_content = re.sub(
|
|
r'(\\?")client_secret(\\?": \\?")[^"\\]+(\\?")',
|
|
r"\1client_secret\2NORMALIZED\3",
|
|
file_content,
|
|
)
|
|
# Stripe's CSP / Reporting headers carry a per-response random
|
|
# ``?q=<token>`` that's unrelated to anything we test.
|
|
file_content = re.sub(r"\?q=[\w-]+", "?q=NORMALIZED", file_content)
|
|
# Stripe's ``Report-To`` response header carries an embedded
|
|
# JSON document as a string value. The value has no test
|
|
# relevance, so collapse it to a placeholder.
|
|
file_content = re.sub(r'(?<="Report-To": )".*"', '"NORMALIZED"', file_content)
|
|
# The ``webhooks_delivered_at`` field is null or a Unix timestamp,
|
|
# depending on when Stripe finishes delivering webhooks. We handle
|
|
# this race in fixture generation by normalizing on null.
|
|
file_content = re.sub(
|
|
r'"webhooks_delivered_at": [0-9]+', '"webhooks_delivered_at": null', file_content
|
|
)
|
|
# The ``pending_webhooks`` field ticks down as Stripe delivers
|
|
# webhooks. We handle this inconsistency in fixture generation by
|
|
# normalizing on 0.
|
|
file_content = re.sub(r'"pending_webhooks": [0-9]+', '"pending_webhooks": 0', file_content)
|
|
# Dates
|
|
file_content = re.sub(r'(?<="Date": )"(.* GMT)"', '"NORMALIZED DATETIME"', file_content)
|
|
file_content = re.sub(r"[0-3]\d [A-Z][a-z]{2} 20[1-2]\d", "NORMALIZED DATE", file_content)
|
|
# IP addresses
|
|
file_content = re.sub(r'"\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}"', '"0.0.0.0"', file_content)
|
|
# ``Realm.uuid`` is a per-provision uuid, and ends up in self-hosted
|
|
# billing URLs and customer metadata/descriptions. Re-provisioning the
|
|
# test database rotates that uuid, so we replace it with a placeholder.
|
|
file_content = file_content.replace(
|
|
zulip_realm_uuid, "00000000-0000-0000-0000-000000000000"
|
|
)
|
|
file_content = file_content.replace(zulip_realm_uuid[:12], "00000000-000")
|
|
# Unix timestamps
|
|
file_content = re.sub(FIXTURE_NORMALIZE_REAL_TIMESTAMP_RE, ": 1000000000", file_content)
|
|
|
|
# Stripe returns events with the same ``created`` second in
|
|
# non-deterministic order across runs. Sort the ``data`` array
|
|
# by normalized-event content so the fixture is byte-stable.
|
|
# Drop any event whose ``data.object.customer`` is
|
|
# ``cus_UNRECORDED`` (i.e., the placeholder for any customer
|
|
# we never recorded a ``Customer.create`` for), which for
|
|
# ``Event.list`` means a prior-test customer that Stripe
|
|
# finalized a webhook for after we anchored our cursor (and the
|
|
# count of such leaked events varies regen to regen). The
|
|
# current test's customer is re-replaced with the file-name
|
|
# test string, so the check unambiguously distinguishes the two.
|
|
if re.search(r"--Event\.list\.\d+\.json$", fixture_file):
|
|
fixture_data = json.loads(file_content)
|
|
fixture_data["data"] = sorted(
|
|
(
|
|
e
|
|
for e in fixture_data["data"]
|
|
if not (e.get("data", {}).get("object") or {})
|
|
.get("customer", "")
|
|
.endswith("_UNRECORDED")
|
|
),
|
|
key=lambda e: json.dumps(e, sort_keys=True),
|
|
)
|
|
file_content = json.dumps(fixture_data, indent=2, sort_keys=True) + "\n"
|
|
|
|
with open(fixture_file, "w") as f:
|
|
f.write(file_content)
|
|
|
|
|
|
MOCKED_STRIPE_FUNCTION_NAMES = [
|
|
f"stripe.{name}"
|
|
for name in [
|
|
"billing_portal.Configuration.create",
|
|
"billing_portal.Session.create",
|
|
"checkout.Session.create",
|
|
"checkout.Session.list",
|
|
"Charge.create",
|
|
"Charge.list",
|
|
"Coupon.create",
|
|
"Customer.create",
|
|
"Customer.create_balance_transaction",
|
|
"Customer.list_balance_transactions",
|
|
"Customer.retrieve",
|
|
"Customer.save",
|
|
"Customer.list",
|
|
"Customer.modify",
|
|
"Event.list",
|
|
"Invoice.create",
|
|
"Invoice.finalize_invoice",
|
|
"Invoice.list",
|
|
"Invoice.pay",
|
|
"Invoice.refresh",
|
|
"Invoice.retrieve",
|
|
"Invoice.void_invoice",
|
|
"InvoiceItem.create",
|
|
"InvoiceItem.list",
|
|
"PaymentMethod.attach",
|
|
"PaymentMethod.create",
|
|
"PaymentMethod.detach",
|
|
"PaymentMethod.list",
|
|
"Plan.create",
|
|
"Product.create",
|
|
"SetupIntent.create",
|
|
"SetupIntent.list",
|
|
"SetupIntent.retrieve",
|
|
"Subscription.create",
|
|
"Subscription.delete",
|
|
"Subscription.retrieve",
|
|
"Subscription.save",
|
|
"Token.create",
|
|
]
|
|
]
|
|
|
|
|
|
def mock_stripe(
|
|
generate: bool = settings.GENERATE_STRIPE_FIXTURES,
|
|
) -> Callable[[Callable[ParamT, ReturnT]], Callable[ParamT, ReturnT]]:
|
|
def _mock_stripe(decorated_function: Callable[ParamT, ReturnT]) -> Callable[ParamT, ReturnT]:
|
|
generate_fixture = generate
|
|
if generate_fixture: # nocoverage
|
|
assert stripe.api_key
|
|
for mocked_function_name in MOCKED_STRIPE_FUNCTION_NAMES:
|
|
mocked_function = operator.attrgetter(mocked_function_name)(sys.modules[__name__])
|
|
if generate_fixture:
|
|
side_effect = generate_and_save_stripe_fixture(
|
|
decorated_function.__name__, mocked_function_name, mocked_function
|
|
) # nocoverage
|
|
else:
|
|
side_effect = read_stripe_fixture(decorated_function.__name__, mocked_function_name)
|
|
decorated_function = patch(
|
|
mocked_function_name,
|
|
side_effect=side_effect,
|
|
autospec=mocked_function_name.endswith(".refresh"),
|
|
)(decorated_function)
|
|
|
|
@wraps(decorated_function)
|
|
def wrapped(*args: ParamT.args, **kwargs: ParamT.kwargs) -> ReturnT:
|
|
if generate_fixture: # nocoverage
|
|
delete_fixture_data(decorated_function)
|
|
# Captured before the test runs so that tests that hit a
|
|
# BillingError can still normalize test fixture data.
|
|
zulip_realm_uuid = str(get_realm("zulip").uuid)
|
|
val = decorated_function(*args, **kwargs)
|
|
normalize_fixture_data(decorated_function, zulip_realm_uuid)
|
|
return val
|
|
else:
|
|
return decorated_function(*args, **kwargs)
|
|
|
|
return wrapped
|
|
|
|
return _mock_stripe
|
|
|
|
|
|
# Stripe event types that ``corporate/views/webhook.py`` actually
|
|
# dispatches on. Everything else is a 200-no-op for the webhook view,
|
|
# and the per-event cardinality of those unhandled types
|
|
# (``invoice.updated`` in particular) drifts regen to regen as Stripe's
|
|
# internal invoice state machine churns, which means including them in
|
|
# fixtures would make ``Event.list`` non-byte-stable.
|
|
HANDLED_STRIPE_EVENT_TYPES: frozenset[str] = frozenset(
|
|
{
|
|
"checkout.session.completed",
|
|
"invoice.paid",
|
|
"invoice.voided",
|
|
}
|
|
)
|
|
|
|
|
|
# Captured at module load before ``mock_stripe`` installs any patch, so
|
|
# ``pin_event_cursor`` and the regen polling loop can call the real
|
|
# ``Event.list`` without advancing the per-call fixture count.
|
|
_REAL_STRIPE_EVENT_LIST = stripe.Event.list
|
|
|
|
|
|
@contextmanager
|
|
def _allow_stripe_api_passthru() -> Iterator[None]: # nocoverage
|
|
"""Zulip's test harness blocks outgoing HTTP by default; allow the
|
|
Stripe API through for the regen-time calls that bypass the mock."""
|
|
with responses.RequestsMock() as request_mock:
|
|
request_mock.add_passthru("https://api.stripe.com")
|
|
yield
|
|
|
|
|
|
class StripeTestCase(ZulipTestCase):
|
|
@override
|
|
def setUp(self) -> None:
|
|
super().setUp()
|
|
# Per-test counter so each ``send_stripe_webhook_events`` run
|
|
# writes its canonical fixture at a predictable ``Event.list``
|
|
# call count (run 1 at .1, run 2 at .2, etc.).
|
|
self._stripe_polling_runs = 0
|
|
realm = get_realm("zulip")
|
|
|
|
# Explicitly limit our active users to 6 regular users,
|
|
# to make seat_count less prone to changes in our test data.
|
|
# We also keep a guest user and a bot to make the data
|
|
# slightly realistic.
|
|
active_emails = [
|
|
self.example_email("AARON"),
|
|
self.example_email("cordelia"),
|
|
self.example_email("hamlet"),
|
|
self.example_email("iago"),
|
|
self.example_email("othello"),
|
|
self.example_email("desdemona"),
|
|
self.example_email("polonius"), # guest
|
|
self.example_email("default_bot"), # bot
|
|
]
|
|
|
|
# Deactivate all users in our realm that aren't in our whitelist.
|
|
for user_profile in UserProfile.objects.filter(realm_id=realm.id).exclude(
|
|
delivery_email__in=active_emails
|
|
):
|
|
do_deactivate_user(user_profile, acting_user=None)
|
|
|
|
# sanity check our 8 expected users are active
|
|
self.assertEqual(
|
|
UserProfile.objects.filter(realm=realm, is_active=True).count(),
|
|
8,
|
|
)
|
|
|
|
# Make sure we have active users outside our realm (to make
|
|
# sure relevant queries restrict on realm).
|
|
self.assertEqual(
|
|
UserProfile.objects.exclude(realm=realm).filter(is_active=True).count(),
|
|
10,
|
|
)
|
|
|
|
# Our seat count excludes our guest user and bot, and
|
|
# we want this to be predictable for certain tests with
|
|
# arithmetic calculations.
|
|
self.assertEqual(get_latest_seat_count(realm), 6)
|
|
self.seat_count = 6
|
|
self.signed_seat_count, self.salt = sign_string(str(self.seat_count))
|
|
|
|
# Test dates are deliberately set in 2012 so their Unix timestamps
|
|
# stay below 1.5e9; see FIXTURE_NORMALIZE_REAL_TIMESTAMP_RE.
|
|
self.now = datetime(2012, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
|
self.next_month = datetime(2012, 2, 2, 3, 4, 5, tzinfo=timezone.utc)
|
|
self.next_year = datetime(2013, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
|
|
|
|
# Add hamlet in `can_manage_billing_group` for testing.
|
|
hamlet = self.example_user("hamlet")
|
|
iago = self.example_user("iago")
|
|
self.set_user_role(hamlet, UserProfile.ROLE_REALM_OWNER)
|
|
self.set_user_role(iago, UserProfile.ROLE_REALM_OWNER)
|
|
|
|
self.billing_session: (
|
|
RealmBillingSession | RemoteRealmBillingSession | RemoteServerBillingSession
|
|
) = RealmBillingSession(user=hamlet, realm=realm)
|
|
|
|
def get_signed_seat_count_from_response(self, response: "TestHttpResponse") -> str | None:
|
|
match = re.search(r"name=\"signed_seat_count\" value=\"(.+)\"", response.content.decode())
|
|
return match.group(1) if match else None
|
|
|
|
def get_salt_from_response(self, response: "TestHttpResponse") -> str | None:
|
|
match = re.search(r"name=\"salt\" value=\"(\w+)\"", response.content.decode())
|
|
return match.group(1) if match else None
|
|
|
|
def get_test_card_token(
|
|
self,
|
|
attaches_to_customer: bool,
|
|
charge_succeeds: bool | None = None,
|
|
card_provider: str | None = None,
|
|
) -> str:
|
|
if attaches_to_customer:
|
|
assert charge_succeeds is not None
|
|
if charge_succeeds:
|
|
if card_provider == "visa":
|
|
return "tok_visa"
|
|
if card_provider == "mastercard":
|
|
return "tok_mastercard"
|
|
raise AssertionError("Unreachable code path")
|
|
else:
|
|
return "tok_chargeCustomerFail"
|
|
else:
|
|
return "tok_visa_chargeDeclined"
|
|
|
|
def assert_details_of_valid_session_from_event_status_endpoint(
|
|
self, stripe_session_id: str, expected_details: dict[str, Any]
|
|
) -> None:
|
|
json_response = self.client_billing_get(
|
|
"/billing/event/status",
|
|
{
|
|
"stripe_session_id": stripe_session_id,
|
|
},
|
|
)
|
|
response_dict = self.assert_json_success(json_response)
|
|
self.assertEqual(response_dict["session"], expected_details)
|
|
|
|
def assert_details_of_valid_invoice_payment_from_event_status_endpoint(
|
|
self,
|
|
stripe_invoice_id: str,
|
|
expected_details: dict[str, Any],
|
|
) -> None:
|
|
json_response = self.client_billing_get(
|
|
"/billing/event/status",
|
|
{
|
|
"stripe_invoice_id": stripe_invoice_id,
|
|
},
|
|
)
|
|
response_dict = self.assert_json_success(json_response)
|
|
self.assertEqual(response_dict["stripe_invoice"], expected_details)
|
|
|
|
def trigger_stripe_checkout_session_completed_webhook(
|
|
self,
|
|
token: str,
|
|
) -> None:
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
customer_stripe_id = customer.stripe_customer_id
|
|
assert customer_stripe_id is not None
|
|
[checkout_setup_intent] = iter(
|
|
stripe.SetupIntent.list(customer=customer_stripe_id, limit=1)
|
|
)
|
|
|
|
# Create a PaymentMethod using the token
|
|
payment_method = stripe.PaymentMethod.create(
|
|
type="card",
|
|
card={
|
|
"token": token,
|
|
},
|
|
billing_details={
|
|
"name": "John Doe",
|
|
"address": {
|
|
"line1": "123 Main St",
|
|
"city": "San Francisco",
|
|
"state": "CA",
|
|
"postal_code": "94105",
|
|
"country": "US",
|
|
},
|
|
},
|
|
)
|
|
assert isinstance(checkout_setup_intent.customer, str)
|
|
assert checkout_setup_intent.metadata is not None
|
|
assert checkout_setup_intent.usage in {"off_session", "on_session"}
|
|
usage = cast(
|
|
Literal["off_session", "on_session"], checkout_setup_intent.usage
|
|
) # https://github.com/python/mypy/issues/12535
|
|
stripe_setup_intent = stripe.SetupIntent.create(
|
|
payment_method=payment_method.id,
|
|
confirm=True,
|
|
payment_method_types=checkout_setup_intent.payment_method_types,
|
|
customer=checkout_setup_intent.customer,
|
|
metadata=checkout_setup_intent.metadata,
|
|
usage=usage,
|
|
)
|
|
[stripe_session] = iter(stripe.checkout.Session.list(customer=customer_stripe_id, limit=1))
|
|
stripe_session_dict = stripe_session.to_dict(for_json=True)
|
|
stripe_session_dict["setup_intent"] = stripe_setup_intent.id
|
|
|
|
event_payload = {
|
|
"id": f"evt_{get_random_string(24)}",
|
|
"object": "event",
|
|
"data": {"object": stripe_session_dict},
|
|
"type": "checkout.session.completed",
|
|
"api_version": STRIPE_API_VERSION,
|
|
}
|
|
|
|
self._post_webhook_event(event_payload, event_label="checkout.session.completed")
|
|
|
|
def send_stripe_webhook_event(self, event: stripe.Event) -> None:
|
|
self._post_webhook_event(event.to_dict(for_json=True), event_label=event.type)
|
|
|
|
def _post_webhook_event(self, payload: dict[str, Any], event_label: str) -> None:
|
|
response = self.client_post("/stripe/webhook/", payload, content_type="application/json")
|
|
assert response.status_code == 200, (
|
|
f"/stripe/webhook/ returned {response.status_code} for {event_label} "
|
|
f"event {payload.get('id')!r}: {response.content.decode(errors='replace')!r}"
|
|
)
|
|
|
|
def pin_event_cursor(self) -> str:
|
|
"""Return the id of Stripe's most-recent event, anchoring the
|
|
next ``send_stripe_webhook_events`` poll.
|
|
|
|
In regen this hits the real ``Event.list``; in replay the
|
|
returned id is inert because the mocked ``Event.list`` ignores
|
|
``ending_before``."""
|
|
if not settings.GENERATE_STRIPE_FIXTURES:
|
|
return "evt_normalized_cursor_lookup"
|
|
with _allow_stripe_api_passthru(): # nocoverage
|
|
[event] = _REAL_STRIPE_EVENT_LIST(limit=1)
|
|
return event.id
|
|
|
|
def send_stripe_webhook_events(self, cursor: str, must_have_event: str | None = None) -> None:
|
|
# Stripe's ``Event.list`` is eventually consistent, but a freshly-created
|
|
# event may not appear under an ``ending_before=cursor`` query right away.
|
|
# Pin the cursor and dedupe locally so late arrivals still surface on a
|
|
# subsequent poll. Regen and replay of fixtures need different termination
|
|
# strategies.
|
|
assert must_have_event is None or must_have_event in HANDLED_STRIPE_EVENT_TYPES, (
|
|
f"Test waits on {must_have_event}, which is not in HANDLED_STRIPE_EVENT_TYPES"
|
|
)
|
|
if settings.GENERATE_STRIPE_FIXTURES:
|
|
self._poll_stripe_events_for_regen(cursor, must_have_event) # nocoverage
|
|
else:
|
|
self._replay_stripe_events_from_fixtures(cursor, must_have_event)
|
|
|
|
def _poll_stripe_events_for_regen(
|
|
self, cursor: str, must_have_event: str | None
|
|
) -> None: # nocoverage
|
|
# Bypass the ``Event.list`` mock during polling, because we don't want a
|
|
# fixture per HTTP call (the grace-period loop produces many nearly
|
|
# identical ones that just churn between regens). Instead, we accumulate
|
|
# the union of events seen across the run and write one canonical fixture
|
|
# at the end. Stripe's ``ending_before=cursor`` already excludes events
|
|
# older than the pin, and ``normalize_fixture_data`` drops prior-test
|
|
# events that leak through.
|
|
deadline_seconds = 60.0
|
|
grace_period_seconds = 3.0
|
|
poll_interval_seconds = 0.2
|
|
|
|
found_must_have_event = must_have_event is None
|
|
hard_deadline = monotonic() + deadline_seconds
|
|
last_progress = monotonic()
|
|
seen_event_ids: set[str] = set()
|
|
events_in_order: list[stripe.Event] = []
|
|
while True:
|
|
if monotonic() >= hard_deadline:
|
|
if not found_must_have_event:
|
|
raise AssertionError(
|
|
f"Did not find expected event {must_have_event} within {deadline_seconds}s"
|
|
)
|
|
break
|
|
if found_must_have_event and monotonic() - last_progress >= grace_period_seconds:
|
|
break
|
|
|
|
with _allow_stripe_api_passthru():
|
|
events_old_to_new = [
|
|
e
|
|
for e in reversed(_REAL_STRIPE_EVENT_LIST(ending_before=cursor, limit=100))
|
|
if e.type in HANDLED_STRIPE_EVENT_TYPES
|
|
]
|
|
new_events = [e for e in events_old_to_new if e.id not in seen_event_ids]
|
|
|
|
if not new_events:
|
|
sleep(poll_interval_seconds)
|
|
continue
|
|
|
|
last_progress = monotonic()
|
|
for event in new_events:
|
|
seen_event_ids.add(event.id)
|
|
events_in_order.append(event)
|
|
if event.type == must_have_event:
|
|
found_must_have_event = True
|
|
self.send_stripe_webhook_event(event)
|
|
|
|
self._write_polling_run_fixtures(events_in_order)
|
|
|
|
def _write_polling_run_fixtures(self, events: list[stripe.Event]) -> None: # nocoverage
|
|
"""Persist the union of events for replay. See
|
|
``_poll_stripe_events_for_regen`` for why we bypass the per-call
|
|
mock."""
|
|
self._stripe_polling_runs += 1
|
|
path = stripe_fixture_path(
|
|
self._testMethodName, "stripe.Event.list", self._stripe_polling_runs
|
|
)
|
|
with open(path, "w") as f:
|
|
f.write(
|
|
json.dumps(
|
|
{
|
|
"data": [json.loads(str(e)) for e in events],
|
|
"has_more": False,
|
|
"object": "list",
|
|
"url": "/v1/events",
|
|
},
|
|
indent=2,
|
|
sort_keys=True,
|
|
)
|
|
+ "\n"
|
|
)
|
|
|
|
def _replay_stripe_events_from_fixtures(self, cursor: str, must_have_event: str | None) -> None:
|
|
# One ``Event.list`` call returns the next recorded canonical fixture,
|
|
# and we deliver every event in it. The mock ignores ``ending_before``
|
|
# and ``limit``. There is no unstable-event filter here, as the fixture
|
|
# was already filtered during regen. Don't reverse because the fixture's
|
|
# ``data`` array is in dispatch order (i.e., the order that
|
|
# ``_poll_stripe_events_for_regen`` accumulated events), not Stripe's
|
|
# newest-first API convention.
|
|
events = list(stripe.Event.list(ending_before=cursor, limit=100))
|
|
for event in events:
|
|
self.send_stripe_webhook_event(event)
|
|
if must_have_event is not None:
|
|
assert any(e.type == must_have_event for e in events), (
|
|
f"Replay fixture missing expected event {must_have_event}"
|
|
)
|
|
|
|
def add_card_to_customer_for_upgrade(self, charge_succeeds: bool = True) -> None:
|
|
start_session_json_response = self.client_billing_post(
|
|
"/upgrade/session/start_card_update_session",
|
|
{
|
|
"tier": 1,
|
|
},
|
|
)
|
|
response_dict = self.assert_json_success(start_session_json_response)
|
|
stripe_session_id = response_dict["stripe_session_id"]
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
stripe_session_id,
|
|
{
|
|
"type": "card_update_from_upgrade_page",
|
|
"status": "created",
|
|
"is_manual_license_management_upgrade_session": False,
|
|
"tier": 1,
|
|
},
|
|
)
|
|
self.trigger_stripe_checkout_session_completed_webhook(
|
|
self.get_test_card_token(
|
|
attaches_to_customer=True,
|
|
charge_succeeds=charge_succeeds,
|
|
card_provider="visa",
|
|
)
|
|
)
|
|
self.assert_details_of_valid_session_from_event_status_endpoint(
|
|
stripe_session_id,
|
|
{
|
|
"type": "card_update_from_upgrade_page",
|
|
"status": "completed",
|
|
"is_manual_license_management_upgrade_session": False,
|
|
"tier": 1,
|
|
"event_handler": {"status": "succeeded"},
|
|
},
|
|
)
|
|
|
|
def upgrade(
|
|
self,
|
|
invoice: bool = False,
|
|
talk_to_stripe: bool = True,
|
|
upgrade_page_response: Optional["TestHttpResponse"] = None,
|
|
del_args: Sequence[str] = [],
|
|
dont_confirm_payment: bool = False,
|
|
**kwargs: Any,
|
|
) -> "TestHttpResponse":
|
|
if upgrade_page_response is None:
|
|
tier = kwargs.get("tier")
|
|
upgrade_url = f"{self.billing_session.billing_base_url}/upgrade/"
|
|
if tier:
|
|
upgrade_url += f"?tier={tier}"
|
|
if self.billing_session.billing_base_url:
|
|
upgrade_page_response = self.client_get(upgrade_url, {}, subdomain="selfhosting")
|
|
else:
|
|
upgrade_page_response = self.client_get(upgrade_url, {})
|
|
params: dict[str, Any] = {
|
|
"schedule": "annual",
|
|
"signed_seat_count": self.get_signed_seat_count_from_response(upgrade_page_response),
|
|
"salt": self.get_salt_from_response(upgrade_page_response),
|
|
}
|
|
if invoice: # send_invoice
|
|
params.update(
|
|
billing_modality="send_invoice",
|
|
licenses=kwargs.get("licenses", 123),
|
|
)
|
|
else: # charge_automatically
|
|
params.update(
|
|
billing_modality="charge_automatically",
|
|
license_management="automatic",
|
|
)
|
|
|
|
remote_server_plan_start_date = kwargs.get("remote_server_plan_start_date")
|
|
if remote_server_plan_start_date:
|
|
params.update(
|
|
remote_server_plan_start_date=remote_server_plan_start_date,
|
|
)
|
|
|
|
params.update(kwargs)
|
|
for key in del_args:
|
|
params.pop(key, None)
|
|
|
|
if talk_to_stripe:
|
|
# Anchor a cursor here so we can replay all subsequent events
|
|
# (from the upgrade flow and the invoice paid webhook).
|
|
cursor = self.pin_event_cursor()
|
|
|
|
existing_customer = self.billing_session.customer_plan_exists()
|
|
upgrade_json_response = self.client_billing_post("/billing/upgrade", params)
|
|
|
|
if upgrade_json_response.status_code != 200 or dont_confirm_payment:
|
|
# Return early if the upgrade request failed.
|
|
return upgrade_json_response
|
|
|
|
is_self_hosted_billing = not isinstance(self.billing_session, RealmBillingSession)
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
if not talk_to_stripe or (
|
|
is_free_trial_offer_enabled(is_self_hosted_billing)
|
|
and
|
|
# Free trial is not applicable for existing customers.
|
|
not existing_customer
|
|
):
|
|
# Upgrade already happened for free trial, invoice realms or schedule
|
|
# upgrade for customers on complimentary access plan.
|
|
return upgrade_json_response
|
|
|
|
last_sent_invoice = Invoice.objects.last()
|
|
assert last_sent_invoice is not None
|
|
|
|
response_dict = self.assert_json_success(upgrade_json_response)
|
|
self.assertEqual(
|
|
response_dict["stripe_invoice_id"],
|
|
last_sent_invoice.stripe_invoice_id,
|
|
)
|
|
|
|
# Verify that the Invoice was sent.
|
|
# Invoice is only marked as paid in our db after we receive `invoice.paid` event.
|
|
self.assert_details_of_valid_invoice_payment_from_event_status_endpoint(
|
|
last_sent_invoice.stripe_invoice_id,
|
|
{"status": "sent"},
|
|
)
|
|
|
|
if invoice:
|
|
assert talk_to_stripe is True
|
|
# Mark the invoice as paid via stripe with the `invoice.paid` event.
|
|
stripe.Invoice.pay(last_sent_invoice.stripe_invoice_id, paid_out_of_band=True)
|
|
|
|
self.send_stripe_webhook_events(
|
|
cursor,
|
|
must_have_event="invoice.paid" if invoice else None,
|
|
)
|
|
return upgrade_json_response
|
|
|
|
def add_card_and_upgrade(
|
|
self, user: UserProfile | None = None, **kwargs: Any
|
|
) -> stripe.Customer:
|
|
# Add card
|
|
with time_machine.travel(self.now, tick=False):
|
|
self.add_card_to_customer_for_upgrade()
|
|
|
|
# Check that we correctly created a Customer object in Stripe
|
|
if user is not None:
|
|
stripe_customer = stripe_get_customer(
|
|
assert_is_not_none(Customer.objects.get(realm=user.realm).stripe_customer_id)
|
|
)
|
|
else:
|
|
customer = self.billing_session.get_customer()
|
|
assert customer is not None
|
|
stripe_customer = stripe_get_customer(assert_is_not_none(customer.stripe_customer_id))
|
|
self.assertTrue(stripe_customer_has_credit_card_as_default_payment_method(stripe_customer))
|
|
|
|
with time_machine.travel(self.now, tick=False):
|
|
response = self.upgrade(**kwargs)
|
|
self.assert_json_success(response)
|
|
|
|
return stripe_customer
|
|
|
|
# Upgrade without talking to Stripe
|
|
def local_upgrade(
|
|
self,
|
|
licenses: int,
|
|
automanage_licenses: bool,
|
|
billing_schedule: int,
|
|
charge_automatically: bool,
|
|
free_trial: bool,
|
|
stripe_invoice_paid: bool = False,
|
|
) -> None:
|
|
class StripeMock(Mock):
|
|
def __init__(self, depth: int = 1) -> None:
|
|
super().__init__(spec=stripe.Card)
|
|
self.id = "cus_123"
|
|
self.created = "1000"
|
|
self.last4 = "4242"
|
|
|
|
def upgrade_func(
|
|
licenses: int,
|
|
automanage_licenses: bool,
|
|
billing_schedule: int,
|
|
charge_automatically: bool,
|
|
free_trial: bool,
|
|
stripe_invoice_paid: bool,
|
|
*mock_args: Any,
|
|
) -> Any:
|
|
hamlet = self.example_user("hamlet")
|
|
billing_session = RealmBillingSession(hamlet)
|
|
return billing_session.process_initial_upgrade(
|
|
plan_tier=CustomerPlan.TIER_CLOUD_STANDARD,
|
|
licenses=licenses,
|
|
automanage_licenses=automanage_licenses,
|
|
billing_schedule=billing_schedule,
|
|
charge_automatically=charge_automatically,
|
|
free_trial=free_trial,
|
|
stripe_invoice_paid=stripe_invoice_paid,
|
|
)
|
|
|
|
for mocked_function_name in MOCKED_STRIPE_FUNCTION_NAMES:
|
|
upgrade_func = patch(mocked_function_name, return_value=StripeMock())(upgrade_func)
|
|
upgrade_func(
|
|
licenses,
|
|
automanage_licenses,
|
|
billing_schedule,
|
|
charge_automatically,
|
|
free_trial,
|
|
stripe_invoice_paid,
|
|
)
|
|
|
|
def setup_mocked_stripe(self, callback: Callable[..., Any], *args: Any, **kwargs: Any) -> Mock:
|
|
with patch.multiple("stripe", Invoice=mock.DEFAULT, InvoiceItem=mock.DEFAULT) as mocked:
|
|
mocked["Invoice"].create.return_value = mock.Mock()
|
|
mocked["Invoice"].finalize_invoice.return_value = mock.Mock()
|
|
mocked["InvoiceItem"].create.return_value = mock.Mock()
|
|
callback(*args, **kwargs)
|
|
return mocked
|
|
|
|
def client_billing_get(self, url_suffix: str, info: Mapping[str, Any] = {}) -> Any:
|
|
url = f"/json{self.billing_session.billing_base_url}" + url_suffix
|
|
if self.billing_session.billing_base_url:
|
|
response = self.client_get(url, info, subdomain="selfhosting")
|
|
else:
|
|
response = self.client_get(url, info)
|
|
return response
|
|
|
|
def client_billing_post(self, url_suffix: str, info: Mapping[str, Any] = {}) -> Any:
|
|
url = f"/json{self.billing_session.billing_base_url}" + url_suffix
|
|
if self.billing_session.billing_base_url:
|
|
response = self.client_post(url, info, subdomain="selfhosting")
|
|
else:
|
|
response = self.client_post(url, info)
|
|
return response
|
|
|
|
def client_billing_patch(self, url_suffix: str, info: Mapping[str, Any] = {}) -> Any:
|
|
url = f"/json{self.billing_session.billing_base_url}" + url_suffix
|
|
if self.billing_session.billing_base_url:
|
|
response = self.client_patch(url, info, subdomain="selfhosting")
|
|
else:
|
|
response = self.client_patch(url, info)
|
|
return response
|
|
|
|
def check_last_ledger_entry_license_counts(
|
|
self, plan: CustomerPlan, licenses: int, licenses_at_next_renewal: int
|
|
) -> LicenseLedger:
|
|
ledger_entry = LicenseLedger.objects.filter(plan=plan).order_by("-id").first()
|
|
assert ledger_entry is not None
|
|
self.assertEqual(ledger_entry.licenses, licenses)
|
|
self.assertEqual(ledger_entry.licenses_at_next_renewal, licenses_at_next_renewal)
|
|
return ledger_entry
|