diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f0bd7b582fe1a..15bc91f737ea7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -58,7 +58,7 @@ jobs: fetch-depth: 0 - name: Set up Node.js - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: 14 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65e475589414d..c6a3ff8901374 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -50,6 +50,7 @@ jobs: NOSE_DIVIDED_WE_RUN: ${{ matrix.NOSE_DIVIDED_WE_RUN }} JS_SETUP: yes KAFKA_HOSTNAME: kafka + STRIPE_PRIVATE_KEY: ${{ secrets.STRIPE_PRIVATE_KEY }} run: scripts/docker test --noinput --stop -v --divided-we-run=${{ matrix.NOSE_DIVIDED_WE_RUN }} --divide-depth=1 --with-timing --with-flaky --threshold=10 --max-test-time=29 - name: "Codecov upload" env: diff --git a/DEV_SETUP.md b/DEV_SETUP.md index d3443abd73f96..4967007911038 100644 --- a/DEV_SETUP.md +++ b/DEV_SETUP.md @@ -532,10 +532,12 @@ $ ./manage.py create_kafka_topics $ env CCHQ_IS_FRESH_INSTALL=1 ./manage.py migrate --noinput ``` -If you are using a partitioned database, populate the additional databases too: +If you are using a partitioned database, populate the additional +databases too, and configure PL/Proxy: ```sh $ env CCHQ_IS_FRESH_INSTALL=1 ./manage.py migrate_multi --noinput +$ ./manage.py configure_pl_proxy_cluster --create_only ``` You should run `./manage.py migrate` frequently, but only use the environment diff --git a/Dockerfile b/Dockerfile index 1adc0e31b6f90..92c14bfac2a29 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,7 +54,7 @@ RUN git config --global url."https://".insteadOf git:// \ # this keeps the image size down, make sure to set in mocha-headless-chrome options # executablePath: 'google-chrome-unstable' -ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true +ENV PUPPETEER_SKIP_DOWNLOAD true RUN npm -g install \ yarn \ diff --git a/Gruntfile.js b/Gruntfile.js index 4bdc471d32c34..064448591b1bd 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -73,7 +73,7 @@ module.exports = function (grunt) { }; // For running in docker/travis - if (process.env.PUPPETEER_SKIP_CHROMIUM_DOWNLOAD) { + if (process.env.PUPPETEER_SKIP_DOWNLOAD) { runnerOptions.executablePath = 'google-chrome-unstable'; } diff --git a/corehq/apps/accounting/forms.py b/corehq/apps/accounting/forms.py index 84a2063cd26f4..84c7a50cfb4c5 100644 --- a/corehq/apps/accounting/forms.py +++ b/corehq/apps/accounting/forms.py @@ -625,10 +625,10 @@ def __init__(self, subscription, account_id, web_user, *args, **kwargs): is_most_recent_version = subscription.plan_version.plan.get_version() == subscription.plan_version most_recent_version_text = ("is most recent version" if is_most_recent_version else "not most recent version") - self.fields['most_recent_version'].initial = most_recent_version_text + self.fields['most_recent_version'].initial = is_most_recent_version most_recent_version_field = hqcrispy.B3TextField( 'most_recent_version', - self.fields['most_recent_version'].initial + most_recent_version_text ) self.fields['domain'].choices = [ (subscription.subscriber.domain, subscription.subscriber.domain) diff --git a/corehq/apps/accounting/interface.py b/corehq/apps/accounting/interface.py index 11428d13fd406..dbecd76698850 100644 --- a/corehq/apps/accounting/interface.py +++ b/corehq/apps/accounting/interface.py @@ -945,7 +945,7 @@ class CustomerInvoiceInterface(InvoiceInterfaceBase): 'corehq.apps.accounting.interface.IsHiddenFilter', ] - account = None + subscription = None @property def headers(self): diff --git a/corehq/apps/accounting/invoicing.py b/corehq/apps/accounting/invoicing.py index 1addb5c206dc7..b07e1e843f7c6 100644 --- a/corehq/apps/accounting/invoicing.py +++ b/corehq/apps/accounting/invoicing.py @@ -849,6 +849,7 @@ def _end_date_count_sms(self): @property @memoized def unit_cost(self): + """Return aggregate cost of all the excess SMS""" total_excess = Decimal('0.0') if self.is_within_monthly_limit: return total_excess diff --git a/corehq/apps/accounting/models.py b/corehq/apps/accounting/models.py index 380cee2ebf0aa..c09e5bde8fe1f 100644 --- a/corehq/apps/accounting/models.py +++ b/corehq/apps/accounting/models.py @@ -19,6 +19,7 @@ import stripe from django_prbac.models import Role from memoized import memoized +from corehq.apps.accounting.utils.stripe import charge_through_stripe from corehq.apps.domain.shortcuts import publish_domain_saved from dimagi.ext.couchdbkit import ( @@ -562,6 +563,8 @@ def _send_autopay_card_removed_email(self, new_user, domain): email, render_to_string('accounting/email/autopay_card_removed.html', context), text_content=strip_tags(render_to_string('accounting/email/autopay_card_removed.html', context)), + domain=domain, + use_domain_gateway=True, ) def _send_autopay_card_added_email(self, domain): @@ -593,6 +596,8 @@ def _send_autopay_card_added_email(self, domain): email, render_to_string('accounting/email/invoice_autopay_setup.html', context), text_content=strip_tags(render_to_string('accounting/email/invoice_autopay_setup.html', context)), + domain=domain, + use_domain_gateway=True, ) @staticmethod @@ -3770,18 +3775,33 @@ def _remove_card_from_all_accounts(self, card): account.remove_autopay_user() def create_card(self, stripe_token, billing_account, domain, autopay=False): + """ + Creates and associates a new card with the Stripe customer. + + This method uses a Stripe token (usually generated on the client side) + to securely create a new card and associate it with the customer + represented by this instance. Additionally, if the 'autopay' flag is + set to True, it sets the card to be used for automatic payments for + a specific billing account and domain. + + Parameters: + - stripe_token (str): The token representing the card details, typically + generated using Stripe.js on the client side. + - billing_account (BillingAccount): The account for which the card might + be set for automatic payments. + - domain (str): The domain associated with the billing account. + - autopay (bool, optional): Flag indicating if the card should be set for + automatic payments. Default is False. + + Returns: + - card (stripe.Card): The newly created Stripe card object. + """ customer = self.customer card = customer.cards.create(card=stripe_token) - self.set_default_card(card) if autopay: self.set_autopay(card, billing_account, domain) return card - def set_default_card(self, card): - self.customer.default_card = card - self.customer.save() - return card - def set_autopay(self, card, billing_account, domain): """ Sets the auto_pay status on the card for a billing account @@ -3838,11 +3858,10 @@ def _auto_pay_card_metadata_key(billing_account): def create_charge(self, card, amount_in_dollars, description, idempotency_key=None): """ Charges a stripe card and returns a transaction id """ - amount_in_cents = int((amount_in_dollars * Decimal('100')).quantize(Decimal(10))) - transaction_record = stripe.Charge.create( + transaction_record = charge_through_stripe( card=card, customer=self.customer, - amount=amount_in_cents, + amount_in_dollars=amount_in_dollars, currency=settings.DEFAULT_CURRENCY, description=description, idempotency_key=idempotency_key diff --git a/corehq/apps/accounting/payment_handlers.py b/corehq/apps/accounting/payment_handlers.py index ebcc4c38099bd..4e5d15b6b72c0 100644 --- a/corehq/apps/accounting/payment_handlers.py +++ b/corehq/apps/accounting/payment_handlers.py @@ -21,6 +21,7 @@ log_accounting_error, log_accounting_info, ) +from corehq.apps.accounting.utils.stripe import charge_through_stripe from corehq.const import USER_DATE_FORMAT stripe.api_key = settings.STRIPE_PRIVATE_KEY @@ -60,11 +61,6 @@ def update_credits(self, payment_record): """ raise NotImplementedError("you must implement update_credits") - @staticmethod - def get_amount_in_cents(amount): - amt_cents = amount * Decimal('100') - return int(amt_cents.quantize(Decimal(10))) - def update_payment_information(self, account): account.last_payment_method = LastPayment.CC_ONE_TIME account.pre_or_post_pay = PreOrPostPay.POSTPAY @@ -182,10 +178,10 @@ def cost_item_name(self): return _("Invoice #%s") % self.invoice.id def create_charge(self, amount, card=None, customer=None): - return stripe.Charge.create( + return charge_through_stripe( card=card, customer=customer, - amount=self.get_amount_in_cents(amount), + amount_in_dollars=amount, currency=settings.DEFAULT_CURRENCY, description="Payment for Invoice %s" % self.invoice.invoice_number, ) @@ -244,10 +240,10 @@ def cost_item_name(self): return _('Bulk Payment for project space %s' % self.domain) def create_charge(self, amount, card=None, customer=None): - return stripe.Charge.create( + return charge_through_stripe( card=card, customer=customer, - amount=self.get_amount_in_cents(amount), + amount_in_dollars=amount, currency=settings.DEFAULT_CURRENCY, description=self.cost_item_name, ) @@ -345,10 +341,10 @@ def get_charge_amount(self, request): return Decimal(request.POST['amount']) def create_charge(self, amount, card=None, customer=None): - return stripe.Charge.create( + return charge_through_stripe( card=card, customer=customer, - amount=self.get_amount_in_cents(amount), + amount_in_dollars=amount, currency=settings.DEFAULT_CURRENCY, description="Payment for %s" % self.cost_item_name, ) diff --git a/corehq/apps/accounting/tests/generator.py b/corehq/apps/accounting/tests/generator.py index 6ec71c72cccc3..23d695880e164 100644 --- a/corehq/apps/accounting/tests/generator.py +++ b/corehq/apps/accounting/tests/generator.py @@ -114,7 +114,8 @@ def subscribable_plan_version(edition=SoftwarePlanEdition.STANDARD): @unit_testing_only def generate_domain_subscription(account, domain, date_start, date_end, - plan_version=None, service_type=SubscriptionType.NOT_SET, is_active=False): + plan_version=None, service_type=SubscriptionType.NOT_SET, + is_active=False, do_not_invoice=False): subscriber, _ = Subscriber.objects.get_or_create(domain=domain.name) subscription = Subscription( account=account, @@ -124,6 +125,7 @@ def generate_domain_subscription(account, domain, date_start, date_end, date_end=date_end, service_type=service_type, is_active=is_active, + do_not_invoice=do_not_invoice ) subscription.save() return subscription diff --git a/corehq/apps/accounting/tests/test_autopay_with_api.py b/corehq/apps/accounting/tests/test_autopay_with_api.py new file mode 100644 index 0000000000000..acf09d892edfd --- /dev/null +++ b/corehq/apps/accounting/tests/test_autopay_with_api.py @@ -0,0 +1,148 @@ +from django.core import mail + +from django_prbac.models import Role +import stripe + +from dimagi.utils.dates import add_months_to_date + +from corehq.apps.accounting import tasks, utils +from corehq.apps.accounting.models import ( + Invoice, + PaymentRecord, + SoftwarePlan, + SoftwarePlanVersion, + SoftwareProductRate, + StripePaymentMethod, +) +from corehq.apps.accounting.payment_handlers import ( + AutoPayInvoicePaymentHandler, +) +from corehq.apps.accounting.tests import generator +from corehq.apps.accounting.tests.test_invoicing import BaseInvoiceTestCase +from django.db import transaction +from unittest import SkipTest +from django.conf import settings + + +class TestBillingAutoPay(BaseInvoiceTestCase): + + @classmethod + def setUpClass(cls): + super(TestBillingAutoPay, cls).setUpClass() + # Dependabot-created PRs do not have access to secrets. + # We skip test so the tests do not fail when dependabot creates new PR for dependency upgrades. + # Or for developers running tests locally if they do not have stripe API key in their localsettings. + if not settings.STRIPE_PRIVATE_KEY: + raise SkipTest("Stripe API Key not set") + cls._generate_autopayable_entities() + cls._generate_non_autopayable_entities() + cls._generate_invoices() + + @classmethod + def _generate_autopayable_entities(cls): + """ + Create account, domain and subscription linked to the autopay user that have autopay enabled + """ + cls.autopay_account = cls.account + cls.autopay_account.created_by_domain = cls.domain + cls.autopay_account.save() + web_user = generator.arbitrary_user(domain_name=cls.domain.name, is_active=True, is_webuser=True) + cls.autopay_user_email = web_user.email + cls.stripe_customer = stripe.Customer.create(email=cls.autopay_user_email) + cls.addClassCleanup(cls.stripe_customer.delete) + cls.payment_method = StripePaymentMethod(web_user=cls.autopay_user_email, + customer_id=cls.stripe_customer.id) + # cls.payment_method.save() + cls.card = cls.payment_method.create_card('tok_visa', cls.autopay_account, None) + cls.payment_method.set_autopay(cls.card, cls.autopay_account, cls.domain) + cls.payment_method.save() + cls.autopay_account.update_autopay_user(cls.autopay_user_email, cls.domain) + + @classmethod + def _generate_non_autopayable_entities(cls): + """ + Create account, domain, and subscription linked to the autopay user, but that don't have autopay enabled + """ + cls.non_autopay_account = generator.billing_account( + web_user_creator=generator.create_arbitrary_web_user_name(is_dimagi=True), + web_user_contact=cls.autopay_user_email + ) + cls.non_autopay_domain = generator.arbitrary_domain() + cls.addClassCleanup(cls.non_autopay_domain.delete) + # Non-autopay subscription has same parameters as the autopayable subscription + cheap_plan = SoftwarePlan.objects.create(name='cheap') + cheap_product_rate = SoftwareProductRate.objects.create(monthly_fee=100, name=cheap_plan.name) + cheap_plan_version = SoftwarePlanVersion.objects.create( + plan=cheap_plan, + product_rate=cheap_product_rate, + role=Role.objects.first(), + ) + cls.non_autopay_subscription = generator.generate_domain_subscription( + cls.non_autopay_account, + cls.non_autopay_domain, + plan_version=cheap_plan_version, + date_start=cls.subscription.date_start, + date_end=add_months_to_date(cls.subscription.date_start, cls.subscription_length), + ) + + @classmethod + def _generate_invoices(cls): + """ + Create invoices for both autopayable and non-autopayable subscriptions + """ + # invoice date is 2 months before the end of the subscription (this is arbitrary) + invoice_date = utils.months_from_date(cls.subscription.date_start, cls.subscription_length - 2) + tasks.calculate_users_in_all_domains(invoice_date) + tasks.generate_invoices_based_on_date(invoice_date) + + def test_get_autopayable_invoices(self): + """ + Invoice.autopayable_invoices() should return invoices that can be automatically paid + """ + autopayable_invoice = Invoice.objects.filter(subscription=self.subscription) + date_due = autopayable_invoice.first().date_due + + autopayable_invoices = Invoice.autopayable_invoices(date_due) + + self.assertCountEqual(autopayable_invoices, autopayable_invoice) + + def test_get_autopayable_invoices_returns_nothing(self): + """ + Invoice.autopayable_invoices() should not return invoices if the customer does not have an autopay method + """ + not_autopayable_invoice = Invoice.objects.filter(subscription=self.non_autopay_subscription) + date_due = not_autopayable_invoice.first().date_due + autopayable_invoices = Invoice.autopayable_invoices(date_due) + self.assertCountEqual(autopayable_invoices, []) + + def test_pay_autopayable_invoices(self): + original_outbox_length = len(mail.outbox) + + autopayable_invoice = Invoice.objects.filter(subscription=self.subscription) + date_due = autopayable_invoice.first().date_due + + AutoPayInvoicePaymentHandler().pay_autopayable_invoices(date_due) + self.assertAlmostEqual(autopayable_invoice.first().get_total(), 0) + self.assertEqual(len(PaymentRecord.objects.all()), 1) + self.assertEqual(len(mail.outbox), original_outbox_length + 1) + + def test_double_charge_is_prevented_and_only_one_payment_record_created(self): + self.original_outbox_length = len(mail.outbox) + invoice = Invoice.objects.get(subscription=self.subscription) + original_amount = invoice.balance + self._run_autopay() + # Add balance to the same invoice so it gets paid again + invoice = Invoice.objects.get(subscription=self.subscription) + invoice.balance = original_amount + invoice.save() + # Run autopay again to test no double charge + with transaction.atomic(), self.assertLogs(level='ERROR') as log_cm: + self._run_autopay() + self.assertIn("[BILLING] [Autopay] Attempt to double charge invoice", "\n".join(log_cm.output)) + self.assertEqual(len(PaymentRecord.objects.all()), 1) + self.assertEqual(len(mail.outbox), self.original_outbox_length + 1) + + def _run_autopay(self): + autopayable_invoice = Invoice.objects.filter(subscription=self.subscription) + date_due = autopayable_invoice.first().date_due + AutoPayInvoicePaymentHandler().pay_autopayable_invoices(date_due) diff --git a/corehq/apps/accounting/tests/test_customer_invoicing.py b/corehq/apps/accounting/tests/test_customer_invoicing.py index d0a50adb73ff2..7ce1bbab09c26 100644 --- a/corehq/apps/accounting/tests/test_customer_invoicing.py +++ b/corehq/apps/accounting/tests/test_customer_invoicing.py @@ -37,6 +37,7 @@ arbitrary_sms_billables_for_domain, ) from corehq.util.dates import get_previous_month_date_range +from corehq.apps.domain.shortcuts import create_domain class BaseCustomerInvoiceCase(BaseAccountingTest): @@ -47,153 +48,126 @@ class BaseCustomerInvoiceCase(BaseAccountingTest): def setUpClass(cls): super(BaseCustomerInvoiceCase, cls).setUpClass() + # In the test, we want to setup the senario mimic how Ops will setup enterprise account + # Only one subscription should have do_not_invoice=False, this subscription will be the main subscription + # All other subscriptions should have do_not_invoice=True and the same plan as the main subscription has + if cls.is_using_test_plans: generator.bootstrap_test_software_plan_versions() + cls.addClassCleanup(utils.clear_plan_version_cache) cls.billing_contact = generator.create_arbitrary_web_user_name() cls.dimagi_user = generator.create_arbitrary_web_user_name(is_dimagi=True) cls.account = generator.billing_account( cls.dimagi_user, cls.billing_contact) - cls.domain = generator.arbitrary_domain() cls.account.is_customer_billing_account = True cls.account.save() cls.advanced_plan = DefaultProductPlan.get_default_plan_version(edition=SoftwarePlanEdition.ADVANCED) cls.advanced_plan.plan.is_customer_software_plan = True - cls.subscription_length = 15 # months - subscription_start_date = date(2016, 2, 23) - subscription_end_date = add_months_to_date(subscription_start_date, cls.subscription_length) - cls.subscription = generator.generate_domain_subscription( - cls.account, - cls.domain, - date_start=subscription_start_date, - date_end=subscription_end_date, - ) + # This will be the domain with the main subscription + cls.main_domain = cls._create_domain("main domain") + cls.main_subscription_length = 15 # months + main_subscription_start_date = date(2016, 2, 23) + main_subscription_end_date = add_months_to_date(main_subscription_start_date, cls.main_subscription_length) - advanced_subscription_end_date = add_months_to_date(subscription_end_date, 2) - cls.domain2 = generator.arbitrary_domain() - cls.sub2 = generator.generate_domain_subscription( + cls.main_subscription = generator.generate_domain_subscription( cls.account, - cls.domain2, - date_start=subscription_start_date, - date_end=advanced_subscription_end_date, - plan_version=cls.advanced_plan + cls.main_domain, + date_start=main_subscription_start_date, + date_end=main_subscription_end_date, + plan_version=cls.advanced_plan, ) - cls.domain3 = generator.arbitrary_domain() - cls.sub3 = generator.generate_domain_subscription( + cls.non_main_subscription_length = 10 # months + non_main_subscription_end_date = add_months_to_date(main_subscription_start_date, + cls.non_main_subscription_length) + cls.non_main_domain1 = cls._create_domain("non main domain 1") + cls.non_main_sub1 = generator.generate_domain_subscription( cls.account, - cls.domain3, - date_start=subscription_start_date, - date_end=advanced_subscription_end_date, - plan_version=cls.advanced_plan + cls.non_main_domain1, + date_start=main_subscription_start_date, + date_end=non_main_subscription_end_date, + plan_version=cls.advanced_plan, + do_not_invoice=True ) - # This subscription should not be included in any customer invoices in these tests - cls.domain_community = generator.arbitrary_domain() - cls.sub3 = generator.generate_domain_subscription( + cls.non_main_domain2 = cls._create_domain("non main domain 2") + cls.non_main_sub2 = generator.generate_domain_subscription( cls.account, - cls.domain3, - date_start=subscription_start_date, - date_end=advanced_subscription_end_date, - plan_version=DefaultProductPlan.get_default_plan_version(edition=SoftwarePlanEdition.COMMUNITY) + cls.non_main_domain2, + date_start=main_subscription_start_date, + date_end=non_main_subscription_end_date, + plan_version=cls.advanced_plan, + do_not_invoice=True ) - def tearDown(self): - for user in self.domain.all_users(): - user.delete(self.domain.name, deleted_by=None) - - for user in self.domain2.all_users(): - user.delete(self.domain2.name, deleted_by=None) - - for user in self.domain3.all_users(): - user.delete(self.domain3.name, deleted_by=None) - - for user in self.domain_community.all_users(): - user.delete(self.domain_community.name, deleted_by=None) + def cleanUpUser(self): + for user in self.main_domain.all_users(): + user.delete(self.main_domain.name, deleted_by=None) - if self.is_using_test_plans: - utils.clear_plan_version_cache() + for user in self.non_main_domain1.all_users(): + user.delete(self.non_main_domain1.name, deleted_by=None) - super(BaseAccountingTest, self).tearDown() + for user in self.non_main_domain2.all_users(): + user.delete(self.non_main_domain2.name, deleted_by=None) @classmethod - def tearDownClass(cls): - cls.domain.delete() - cls.domain2.delete() - cls.domain3.delete() - cls.domain_community.delete() - - super(BaseCustomerInvoiceCase, cls).tearDownClass() + def _create_domain(cls, name): + domain_obj = create_domain(name) + cls.addClassCleanup(domain_obj.delete) + return domain_obj class TestCustomerInvoice(BaseCustomerInvoiceCase): def test_multiple_subscription_invoice(self): - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(3, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(3, self.non_main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertGreater(invoice.balance, Decimal('0.0000')) - self.assertEqual(invoice.account, self.account) - - num_product_line_items = invoice.lineitem_set.get_products().count() - self.assertEqual(num_product_line_items, 2) - - num_feature_line_items = invoice.lineitem_set.get_features().count() - self.assertEqual(num_feature_line_items, self.subscription.plan_version.feature_rates.count() + - self.sub2.plan_version.feature_rates.count()) - - def test_only_invoice_active_subscriptions(self): - """ - Test that only active subscriptions are invoiced. - Two subscriptions of the same plan only create one product line item and one set of feature line items - """ - invoice_date = utils.months_from_date(self.sub2.date_end, 1) - calculate_users_in_all_domains(invoice_date) - tasks.generate_invoices_based_on_date(invoice_date) - - self.assertEqual(CustomerInvoice.objects.count(), 1) - invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('851.6200')) + self.assertEqual(invoice.balance, Decimal('1200.0000')) self.assertEqual(invoice.account, self.account) num_product_line_items = invoice.lineitem_set.get_products().count() self.assertEqual(num_product_line_items, 1) num_feature_line_items = invoice.lineitem_set.get_features().count() - self.assertEqual(num_feature_line_items, self.sub2.plan_version.feature_rates.count()) + self.assertEqual(num_feature_line_items, self.main_subscription.plan_version.feature_rates.count()) def test_no_invoice_before_start(self): """ Test that an invoice is not created if its subscriptions didn't start in the previous month. """ - calculate_users_in_all_domains(self.subscription.date_start) - tasks.generate_invoices_based_on_date(self.subscription.date_start) + calculate_users_in_all_domains(self.main_subscription.date_start) + tasks.generate_invoices_based_on_date(self.main_subscription.date_start) self.assertEqual(CustomerInvoice.objects.count(), 0) def test_no_invoice_after_end(self): """ No invoices should be generated for the months after the end date of the subscriptions. """ - invoice_date = utils.months_from_date(self.sub2.date_end, 2) + invoice_date = utils.months_from_date(self.main_subscription.date_end, 2) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 0) def test_deleted_domain_in_multiple_subscription_invoice(self): - invoice_date = utils.months_from_date(self.subscription.date_start, 2) + """ + Test the customer invoice can still be created after one of the domains was deleted + """ + invoice_date = utils.months_from_date(self.main_subscription.date_start, 2) domain_to_be_deleted = generator.arbitrary_domain() generator.generate_domain_subscription( self.account, domain_to_be_deleted, - date_start=self.sub2.date_start, - date_end=self.sub2.date_end, + date_start=self.main_subscription.date_start, + date_end=self.main_subscription.date_end, plan_version=self.advanced_plan ) domain_to_be_deleted.delete(leave_tombstone=True) @@ -205,7 +179,7 @@ def test_deleted_domain_in_multiple_subscription_invoice(self): invoice = CustomerInvoice.objects.first() num_product_line_items = invoice.lineitem_set.get_products().count() - self.assertEqual(num_product_line_items, 2) + self.assertEqual(num_product_line_items, 1) class TestProductLineItem(BaseCustomerInvoiceCase): @@ -216,29 +190,28 @@ class TestProductLineItem(BaseCustomerInvoiceCase): def setUp(self): super(TestProductLineItem, self).setUp() - self.product_rate = self.subscription.plan_version.product_rate + self.product_rate = self.main_subscription.plan_version.product_rate def test_product_line_items(self): - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(2, self.main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - product_line_items = invoice.lineitem_set.get_products() - self.assertEqual(product_line_items.count(), 2) - product_descriptions = [line_item.base_description for line_item in product_line_items] - self.assertItemsEqual(product_descriptions, ['One month of CommCare Advanced Edition Software Plan.', - 'One month of CommCare Standard Edition Software Plan.']) - product_costs = [line_item.base_cost for line_item in product_line_items] - self.assertItemsEqual(product_costs, [self.product_rate.monthly_fee, - self.advanced_plan.product_rate.monthly_fee]) + product_line_item_count = invoice.lineitem_set.get_products().count() + self.assertEqual(product_line_item_count, 1) + product_line_item = invoice.lineitem_set.get_products().first() + product_description = product_line_item.base_description + self.assertEqual(product_description, 'One month of CommCare Advanced Edition Software Plan.') + product_cost = product_line_item.base_cost + self.assertEqual(product_cost, self.product_rate.monthly_fee) def test_product_line_items_in_quarterly_invoice(self): self.account.invoicing_plan = InvoicingPlan.QUARTERLY self.account.save() - invoice_date = utils.months_from_date(self.subscription.date_start, 14) + invoice_date = utils.months_from_date(self.main_subscription.date_start, 14) for months_before_invoice_date in range(3): user_date = date(invoice_date.year, invoice_date.month, 1) user_date -= relativedelta.relativedelta(months=months_before_invoice_date) @@ -247,19 +220,18 @@ def test_product_line_items_in_quarterly_invoice(self): self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('4500.0000')) + self.assertEqual(invoice.balance, Decimal('3600.0000')) self.assertEqual(invoice.account, self.account) - # There should be two product line items, with 3 months billed for each - num_product_line_items = invoice.lineitem_set.get_products().count() - self.assertEqual(num_product_line_items, 2) - for product_line_item in invoice.lineitem_set.get_products().all(): - self.assertEqual(product_line_item.quantity, 3) + num_product_line_item = invoice.lineitem_set.get_products().count() + self.assertEqual(num_product_line_item, 1) + product_line_item = invoice.lineitem_set.get_products().first() + self.assertEqual(product_line_item.quantity, 3) def test_product_line_items_in_yearly_invoice(self): self.account.invoicing_plan = InvoicingPlan.YEARLY self.account.save() - invoice_date = utils.months_from_date(self.subscription.date_start, 14) + invoice_date = utils.months_from_date(self.main_subscription.date_start, 14) for months_before_invoice_date in range(12): user_date = date(invoice_date.year, invoice_date.month, 1) user_date -= relativedelta.relativedelta(months=months_before_invoice_date) @@ -268,75 +240,43 @@ def test_product_line_items_in_yearly_invoice(self): self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('18000.0000')) + self.assertEqual(invoice.balance, Decimal('14400.0000')) self.assertEqual(invoice.account, self.account) - # There should be two product line items, with 3 months billed for each - num_product_line_items = invoice.lineitem_set.get_products().count() - self.assertEqual(num_product_line_items, 2) - for product_line_item in invoice.lineitem_set.get_products().all(): - self.assertEqual(product_line_item.quantity, 12) - - def test_subscriptions_marked_do_not_invoice_not_included(self): - self.subscription.do_not_invoice = True - - invoice_date = utils.months_from_date(self.sub2.date_end, 1) - calculate_users_in_all_domains(invoice_date) - tasks.generate_invoices_based_on_date(invoice_date) - - self.assertEqual(CustomerInvoice.objects.count(), 1) - invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('851.6200')) - self.assertEqual(invoice.account, self.account) - - product_line_items = invoice.lineitem_set.get_products() - self.assertEqual(product_line_items.count(), 1) - self.assertEqual( - product_line_items.first().base_description, - None - ) - self.assertEqual( - product_line_items.first().unit_description, - '22 days of CommCare Advanced Edition Software Plan. (Jul 1 - Jul 22)' - ) - - num_feature_line_items = invoice.lineitem_set.get_features().count() - self.assertEqual(num_feature_line_items, self.sub2.plan_version.feature_rates.count()) + num_product_line_items = invoice.lineitem_set.get_products() + self.assertEqual(num_product_line_items.count(), 1) + product_line_item = invoice.lineitem_set.get_products().first() + self.assertEqual(product_line_item.quantity, 12) def test_account_level_product_credits(self): CreditLine.add_credit( - amount=self.subscription.plan_version.product_rate.monthly_fee / 2, + amount=self.main_subscription.plan_version.product_rate.monthly_fee / 2, account=self.account, is_product=True ) - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(2, self.main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1350.0000')) + self.assertEqual(invoice.balance, Decimal('600.0000')) def test_subscription_level_product_credits(self): CreditLine.add_credit( - self.subscription.plan_version.product_rate.monthly_fee / 2, - is_product=True, - subscription=self.subscription - ) - CreditLine.add_credit( - self.sub2.plan_version.product_rate.monthly_fee / 4, + self.main_subscription.plan_version.product_rate.monthly_fee / 2, is_product=True, - subscription=self.sub2, + subscription=self.main_subscription ) - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(2, self.main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1050.0000')) + self.assertEqual(invoice.balance, Decimal('600.0000')) class TestUserLineItem(BaseCustomerInvoiceCase): @@ -345,42 +285,45 @@ class TestUserLineItem(BaseCustomerInvoiceCase): def setUp(self): super(TestUserLineItem, self).setUp() - self.user_rate = self.subscription.plan_version.feature_rates \ + self.user_rate = self.main_subscription.plan_version.feature_rates \ .filter(feature__feature_type=FeatureType.USER).get() - self.advanced_rate = self.advanced_plan.feature_rates.filter(feature__feature_type=FeatureType.USER).get() - self.invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) + self.invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(2, self.non_main_subscription_length)) def test_under_limit(self): - num_users = random.randint(0, self.user_rate.monthly_limit) - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) + num_users_main_domain = random.randint(0, self.user_rate.monthly_limit / 2) + generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) - num_users_advanced = random.randint(0, self.advanced_rate.monthly_limit) - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) + num_users_non_main_domain1 = random.randint(0, self.user_rate.monthly_limit / 2) + generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) + + self.addCleanup(self.cleanUpUser) calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1500.0000')) + self.assertEqual(invoice.balance, Decimal('1200.0000')) user_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.USER) - self.assertEqual(user_line_items.count(), 2) - for user_line_item in user_line_items: - self.assertEqual(user_line_item.quantity, 0) - self.assertEqual(user_line_item.subtotal, Decimal('0.0000')) - self.assertEqual(user_line_item.total, Decimal('0.0000')) - self.assertIsNone(user_line_item.base_description) - self.assertEqual(user_line_item.base_cost, Decimal('0.0000')) - self.assertIsNone(user_line_item.unit_description) - self.assertEqual(user_line_item.unit_cost, Decimal('1.0000')) + self.assertEqual(user_line_items.count(), 1) + user_line_item = invoice.lineitem_set.get_feature_by_type(FeatureType.USER).first() + self.assertEqual(user_line_item.quantity, 0) + self.assertEqual(user_line_item.subtotal, Decimal('0.0000')) + self.assertEqual(user_line_item.total, Decimal('0.0000')) + self.assertIsNone(user_line_item.base_description) + self.assertEqual(user_line_item.base_cost, Decimal('0.0000')) + self.assertIsNone(user_line_item.unit_description) + self.assertEqual(user_line_item.unit_cost, Decimal('1.0000')) def test_over_limit(self): - num_users = self.user_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) + num_users_main_domain = self.user_rate.monthly_limit + 1 + generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) + + num_users_non_main_domain1 = self.user_rate.monthly_limit + 1 + generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) - num_users_advanced = self.advanced_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) + self.addCleanup(self.cleanUpUser) calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) @@ -388,100 +331,107 @@ def test_over_limit(self): invoice = CustomerInvoice.objects.first() user_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.USER) - self.assertEqual(user_line_items.count(), 2) - for user_line_item in user_line_items: - self.assertIsNone(user_line_item.base_description) - self.assertEqual(user_line_item.base_cost, Decimal('0.0000')) - num_to_charge = num_users - self.user_rate.monthly_limit - self.assertEqual(num_to_charge, user_line_item.quantity) - if self.user_rate.feature.name == user_line_item.feature_rate.feature.name: - self.assertEqual(user_line_item.unit_cost, self.user_rate.per_excess_fee) - self.assertEqual(user_line_item.total, self.user_rate.per_excess_fee * num_to_charge) - self.assertEqual(user_line_item.subtotal, self.user_rate.per_excess_fee * num_to_charge) - elif user_line_item.feature_rate.feature.name == self.advanced_rate.feature.name: - self.assertEqual(user_line_item.unit_cost, self.advanced_rate.per_excess_fee) - self.assertEqual(user_line_item.total, self.advanced_rate.per_excess_fee * num_to_charge) - self.assertEqual(user_line_item.subtotal, self.advanced_rate.per_excess_fee * num_to_charge) - - def test_account_level_user_credits(self): + self.assertEqual(user_line_items.count(), 1) + user_line_item = invoice.lineitem_set.get_feature_by_type(FeatureType.USER).first() + self.assertIsNone(user_line_item.base_description) + self.assertEqual(user_line_item.base_cost, Decimal('0.0000')) + num_to_charge = num_users_main_domain + num_users_non_main_domain1 - self.user_rate.monthly_limit + self.assertEqual(num_to_charge, user_line_item.quantity) + self.assertEqual(user_line_item.unit_cost, self.user_rate.per_excess_fee) + self.assertEqual(user_line_item.total, self.user_rate.per_excess_fee * num_to_charge) + self.assertEqual(user_line_item.subtotal, self.user_rate.per_excess_fee * num_to_charge) + + def test_balance_reflects_credit_deduction_for_account_level_user_credits(self): # Add User usage - num_users = self.user_rate.monthly_limit + 10 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - num_users_advanced = self.advanced_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) + num_users_main_domain = self.user_rate.monthly_limit + 10 + generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) + num_users_non_main_domain1 = 1 + generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) + + self.addCleanup(self.cleanUpUser) # Cover the cost of 1 User CreditLine.add_credit( - amount=Decimal(2.0000), + amount=Decimal(1.0000), feature_type=FeatureType.USER, account=self.account, ) calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) + + num_to_charge = num_users_main_domain + num_users_non_main_domain1 - self.user_rate.monthly_limit + self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal(1509.0000)) - - def test_subscription_level_user_credits(self): + self.assertEqual(invoice.balance, Decimal(1200.0000) + num_to_charge - 1) + + # Comment out until subscription level bug is fixed + # def test_balance_reflects_credit_deduction_for_multiple_subscription_level_user_credit(self): + # # Add User usage + # num_users_main_domain = self.user_rate.monthly_limit + 10 + # generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) + # num_users_non_main_domain1 = 10 + # generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) + + # self.addCleanup(self.cleanUpUser) + + # # Cover the cost of 2 User for main subscription + # CreditLine.add_credit( + # amount=Decimal(2.0000), + # feature_type=FeatureType.USER, + # subscription=self.main_subscription + # ) + + # # Cover the cost of 5 User for non main subscription 1 + # CreditLine.add_credit( + # amount=Decimal(5.0000), + # feature_type=FeatureType.USER, + # subscription=self.non_main_sub1 + # ) + + # num_to_charge = num_users_main_domain + num_users_non_main_domain1 - self.user_rate.monthly_limit + + # calculate_users_in_all_domains(self.invoice_date) + # tasks.generate_invoices_based_on_date(self.invoice_date) + # self.assertEqual(CustomerInvoice.objects.count(), 1) + # invoice = CustomerInvoice.objects.first() + # self.assertEqual(invoice.balance, Decimal(1200.0000) + num_to_charge - 7) + + def test_balance_reflects_credit_deduction_for_single_subscription_level_user_credit(self): # Add User usage - num_users = self.user_rate.monthly_limit + 10 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - num_users_advanced = self.advanced_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) + num_users_main_domain = self.user_rate.monthly_limit + 10 + generator.arbitrary_commcare_users_for_domain(self.main_domain.name, num_users_main_domain) + num_users_non_main_domain1 = 1 + generator.arbitrary_commcare_users_for_domain(self.non_main_domain1.name, num_users_non_main_domain1) + + self.addCleanup(self.cleanUpUser) - # Cover the cost of 1 User on the Standard subscription + # Cover the cost of 2 User CreditLine.add_credit( amount=Decimal(2.0000), feature_type=FeatureType.USER, - subscription=self.subscription - ) - # Cover the cost of 5 Users on the Advanced subscription - CreditLine.add_credit( - amount=Decimal(10.0000), - feature_type=FeatureType.USER, - subscription=self.sub2 + subscription=self.main_subscription ) + num_to_charge = num_users_main_domain + num_users_non_main_domain1 - self.user_rate.monthly_limit + calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal(1500.0000)) - - def test_one_subscription_level_user_credit(self): - # Add User usage - num_users = self.user_rate.monthly_limit + 10 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - num_users_advanced = self.advanced_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) - - # Cover the cost of 2 Users on the Advanced subscription - CreditLine.add_credit( - amount=Decimal(4.0000), - feature_type=FeatureType.USER, - subscription=self.sub2 - ) - - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(2, self.subscription_length)) - calculate_users_in_all_domains(invoice_date) - tasks.generate_invoices_based_on_date(invoice_date) - self.assertEqual(CustomerInvoice.objects.count(), 1) - invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal(1507.0000)) + self.assertEqual(invoice.balance, Decimal(1200.0000) + num_to_charge - 2) class TestSmsLineItem(BaseCustomerInvoiceCase): def setUp(self): super(TestSmsLineItem, self).setUp() - self.sms_rate = self.subscription.plan_version.feature_rates.filter( + self.sms_rate = self.main_subscription.plan_version.feature_rates.filter( feature__feature_type=FeatureType.SMS ).get() - self.advanced_rate = self.advanced_plan.feature_rates.filter(feature__feature_type=FeatureType.SMS).get() self.invoice_date = utils.months_from_date( - self.subscription.date_start, random.randint(2, self.subscription_length) + self.main_subscription.date_start, random.randint(2, self.non_main_subscription_length) ) self.sms_date = utils.months_from_date(self.invoice_date, -1) @@ -490,17 +440,17 @@ def tearDown(self): super(TestSmsLineItem, self).tearDown() def test_under_limit(self): - num_sms = self.sms_rate.monthly_limit // 2 + num_sms_main_domain = self.sms_rate.monthly_limit // 2 arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, num_sms, direction=INCOMING + self.main_domain, self.sms_date, num_sms_main_domain, direction=INCOMING ) - num_sms_advanced = self.advanced_rate.monthly_limit // 2 + num_sms_non_main_domain1 = self.sms_rate.monthly_limit // 2 arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms_advanced, direction=INCOMING + self.non_main_domain1, self.sms_date, num_sms_non_main_domain1, direction=INCOMING ) sms_line_items = self._create_sms_line_items() - self.assertEqual(sms_line_items.count(), 2) + self.assertEqual(sms_line_items.count(), 1) for sms_line_item in sms_line_items: self.assertIsNone(sms_line_item.base_description) self.assertEqual(sms_line_item.base_cost, Decimal('0.0000')) @@ -511,98 +461,96 @@ def test_under_limit(self): self.assertEqual(sms_line_item.total, Decimal('0.0000')) def test_over_limit(self): - num_sms = random.randint(self.sms_rate.monthly_limit + 1, self.sms_rate.monthly_limit + 2) - billables = arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, num_sms + num_sms_main_domain = random.randint(self.sms_rate.monthly_limit + 1, self.sms_rate.monthly_limit + 2) + main_domain_billables = arbitrary_sms_billables_for_domain( + self.main_domain, self.sms_date, num_sms_main_domain ) - num_sms_advanced = random.randint(self.advanced_rate.monthly_limit + 1, - self.advanced_rate.monthly_limit + 2) - advanced_billables = arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms_advanced + num_sms_non_main_domain1 = random.randint(self.sms_rate.monthly_limit + 1, + self.sms_rate.monthly_limit + 2) + non_main_domain1_billables = arbitrary_sms_billables_for_domain( + self.non_main_domain1, self.sms_date, num_sms_non_main_domain1 ) sms_line_items = self._create_sms_line_items() - self.assertEqual(sms_line_items.count(), 2) + self.assertEqual(sms_line_items.count(), 1) for sms_line_item in sms_line_items: self.assertIsNone(sms_line_item.base_description) self.assertEqual(sms_line_item.base_cost, Decimal('0.0000')) self.assertEqual(sms_line_item.quantity, 1) - if self.advanced_rate.feature == sms_line_item.feature_rate.feature: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in advanced_billables[self.advanced_rate.monthly_limit:] - ) - else: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in billables[self.sms_rate.monthly_limit:] - ) + sms_cost = sum( + billable.gateway_charge + billable.usage_charge + for billable in ( + non_main_domain1_billables[self.sms_rate.monthly_limit:] + + main_domain_billables + )) self.assertEqual(sms_line_item.unit_cost, sms_cost) self.assertEqual(sms_line_item.total, sms_cost) - def test_subscription_level_sms_credits(self): + # Comment out until subscription level credit bug is fixed + # def test_balance_reflects_credit_deduction_for_multiple_subscription_level_sms_credits(self): + # # Add SMS usage + # arbitrary_sms_billables_for_domain( + # self.main_domain, self.sms_date, self.sms_rate.monthly_limit + 1 + # ) + # arbitrary_sms_billables_for_domain( + # self.non_main_domain1, self.sms_date, num_sms=10 + # ) + + # # Cover the cost of 1 SMS for main subscription + # CreditLine.add_credit( + # amount=Decimal(0.7500), + # feature_type=FeatureType.SMS, + # subscription=self.main_subscription + # ) + + # # Cover the cost of 1 SMS for non main subscription 1 + # CreditLine.add_credit( + # amount=Decimal(0.7500), + # feature_type=FeatureType.SMS, + # subscription=self.non_main_sub1 + # ) + + # calculate_users_in_all_domains(self.invoice_date) + # tasks.generate_invoices_based_on_date(self.invoice_date) + # self.assertEqual(CustomerInvoice.objects.count(), 1) + # invoice = CustomerInvoice.objects.first() + # self.assertEqual(invoice.balance, Decimal('1206.7500')) + + def test_balance_reflects_credit_deduction_for_single_subscription_level_sms_credits(self): # Add SMS usage arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, self.sms_rate.monthly_limit + 1 + self.main_domain, self.sms_date, self.sms_rate.monthly_limit + 1 ) arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms=self.advanced_rate.monthly_limit + 10 + self.non_main_domain1, self.sms_date, num_sms=10 ) - # Cover the cost of 1 SMS on the Standard subscription - CreditLine.add_credit( - amount=Decimal(0.7500), - feature_type=FeatureType.SMS, - subscription=self.subscription - ) - # Cover the cost of 10 SMS on the Advanced subscription - CreditLine.add_credit( - amount=Decimal(7.5000), - feature_type=FeatureType.SMS, - subscription=self.sub2, - ) - - calculate_users_in_all_domains(self.invoice_date) - tasks.generate_invoices_based_on_date(self.invoice_date) - self.assertEqual(CustomerInvoice.objects.count(), 1) - invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1500.0000')) - - def test_one_subscription_level_sms_credit(self): - # Add SMS usage - arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, self.sms_rate.monthly_limit + 1 - ) - arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms=self.advanced_rate.monthly_limit + 10 - ) - - # Cover the cost of 1 SMS on the Standard subscription + # Cover the cost of 1 SMS CreditLine.add_credit( amount=Decimal(0.7500), feature_type=FeatureType.SMS, - subscription=self.subscription + subscription=self.main_subscription ) calculate_users_in_all_domains(self.invoice_date) tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1507.5000')) + self.assertEqual(invoice.balance, Decimal('1207.5000')) - def test_account_level_sms_credits(self): + def test_balance_reflects_credit_deduction_for_account_level_sms_credits(self): # Add SMS usage arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, self.sms_rate.monthly_limit + 1 + self.main_domain, self.sms_date, self.sms_rate.monthly_limit + 1 ) arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms=self.advanced_rate.monthly_limit + 10 + self.non_main_domain1, self.sms_date, num_sms=10 ) # Cover the cost of 1 SMS CreditLine.add_credit( - amount=Decimal(0.5000), + amount=Decimal(0.7500), feature_type=FeatureType.SMS, account=self.account, ) @@ -611,7 +559,7 @@ def test_account_level_sms_credits(self): tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() - self.assertEqual(invoice.balance, Decimal('1507.7500')) + self.assertEqual(invoice.balance, Decimal('1207.5000')) def _create_sms_line_items(self): calculate_users_in_all_domains(self.invoice_date) @@ -635,104 +583,80 @@ class TestQuarterlyInvoicing(BaseCustomerInvoiceCase): def setUp(self): super(TestQuarterlyInvoicing, self).setUp() - self.user_rate = self.subscription.plan_version.feature_rates \ + self.user_rate = self.main_subscription.plan_version.feature_rates \ .filter(feature__feature_type=FeatureType.USER).get() - self.advanced_rate = self.advanced_plan.feature_rates.filter(feature__feature_type=FeatureType.USER).get() self.initialize_domain_user_history_objects() - self.sms_rate = self.subscription.plan_version.feature_rates.filter( - feature__feature_type=FeatureType.SMS - ).get() - self.advanced_sms_rate = self.advanced_plan.feature_rates.filter( + self.sms_rate = self.main_subscription.plan_version.feature_rates.filter( feature__feature_type=FeatureType.SMS ).get() self.invoice_date = utils.months_from_date( - self.subscription.date_start, random.randint(2, self.subscription_length) + self.main_subscription.date_start, random.randint(3, self.non_main_subscription_length) ) self.sms_date = utils.months_from_date(self.invoice_date, -1) def initialize_domain_user_history_objects(self): record_dates = [] - month_end = self.subscription.date_end - while month_end > self.subscription.date_start: + month_end = self.main_subscription.date_end + while month_end > self.main_subscription.date_start: record_dates.append(month_end) _, month_end = get_previous_month_date_range(month_end) - num_users = self.user_rate.monthly_limit + 1 + self.num_users = self.user_rate.monthly_limit + 1 for record_date in record_dates: DomainUserHistory.objects.create( - domain=self.domain, - num_users=num_users, + domain=self.main_domain, + num_users=self.num_users, record_date=record_date ) - num_users = self.advanced_rate.monthly_limit + 2 for record_date in record_dates: DomainUserHistory.objects.create( - domain=self.domain2, - num_users=num_users, + domain=self.non_main_domain1, + num_users=self.num_users, record_date=record_date ) for record_date in record_dates: DomainUserHistory.objects.create( - domain=self.domain3, + domain=self.non_main_domain2, num_users=0, record_date=record_date ) def test_user_over_limit_in_quarterly_invoice(self): - num_users = self.user_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - - num_users_advanced = self.advanced_rate.monthly_limit + 2 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) - self.account.invoicing_plan = InvoicingPlan.QUARTERLY self.account.save() - invoice_date = utils.months_from_date(self.subscription.date_start, 14) - tasks.generate_invoices_based_on_date(invoice_date) + tasks.generate_invoices_based_on_date(self.invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() user_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.USER) - self.assertEqual(user_line_items.count(), 2) + num_excess_users_quarterly = (self.num_users * 2 - self.user_rate.monthly_limit) * 3 + self.assertEqual(user_line_items.count(), 1) for user_line_item in user_line_items: - if self.user_rate.feature.name == user_line_item.feature_rate.feature.name: - self.assertEqual(user_line_item.quantity, 3) - elif user_line_item.feature_rate.feature.name == self.advanced_rate.feature.name: - self.assertEqual(user_line_item.quantity, 6) + self.assertEqual(user_line_item.quantity, num_excess_users_quarterly) def test_user_over_limit_in_yearly_invoice(self): - num_users = self.user_rate.monthly_limit + 1 - generator.arbitrary_commcare_users_for_domain(self.domain.name, num_users) - - num_users_advanced = self.advanced_rate.monthly_limit + 2 - generator.arbitrary_commcare_users_for_domain(self.domain2.name, num_users_advanced) - self.account.invoicing_plan = InvoicingPlan.YEARLY self.account.save() - invoice_date = utils.months_from_date(self.subscription.date_start, 14) + invoice_date = utils.months_from_date(self.main_subscription.date_start, 14) tasks.generate_invoices_based_on_date(invoice_date) self.assertEqual(CustomerInvoice.objects.count(), 1) invoice = CustomerInvoice.objects.first() user_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.USER) - self.assertEqual(user_line_items.count(), 2) + num_excess_users_quarterly = (self.num_users * 2 - self.user_rate.monthly_limit) * 12 + self.assertEqual(user_line_items.count(), 1) for user_line_item in user_line_items: - if self.user_rate.feature.name == user_line_item.feature_rate.feature.name: - self.assertEqual(user_line_item.quantity, 12) - elif user_line_item.feature_rate.feature.name == self.advanced_rate.feature.name: - self.assertEqual(user_line_item.quantity, 24) + self.assertEqual(user_line_item.quantity, num_excess_users_quarterly) def test_sms_over_limit_in_quarterly_invoice(self): num_sms = random.randint(self.sms_rate.monthly_limit + 1, self.sms_rate.monthly_limit + 2) - billables = arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, num_sms + billables_main_domain = arbitrary_sms_billables_for_domain( + self.main_domain, self.sms_date, num_sms ) - num_sms_advanced = random.randint(self.advanced_sms_rate.monthly_limit + 1, - self.advanced_sms_rate.monthly_limit + 2) - advanced_billables = arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms_advanced + billables_non_main_domain1 = arbitrary_sms_billables_for_domain( + self.non_main_domain1, self.sms_date, num_sms ) tasks.generate_invoices_based_on_date(self.invoice_date) @@ -740,34 +664,28 @@ def test_sms_over_limit_in_quarterly_invoice(self): invoice = CustomerInvoice.objects.first() sms_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.SMS) - self.assertEqual(sms_line_items.count(), 2) + self.assertEqual(sms_line_items.count(), 1) for sms_line_item in sms_line_items: self.assertIsNone(sms_line_item.base_description) self.assertEqual(sms_line_item.base_cost, Decimal('0.0000')) self.assertEqual(sms_line_item.quantity, 1) - if self.advanced_sms_rate.feature == sms_line_item.feature_rate.feature: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in advanced_billables[self.advanced_sms_rate.monthly_limit:] - ) - else: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in billables[self.sms_rate.monthly_limit:] - ) + sms_cost = sum( + billable.gateway_charge + billable.usage_charge + for billable in ( + billables_non_main_domain1[self.sms_rate.monthly_limit:] + + billables_main_domain + )) self.assertEqual(sms_line_item.unit_cost, sms_cost) self.assertEqual(sms_line_item.total, sms_cost) def test_sms_over_limit_in_yearly_invoice(self): num_sms = random.randint(self.sms_rate.monthly_limit + 1, self.sms_rate.monthly_limit + 2) - billables = arbitrary_sms_billables_for_domain( - self.domain, self.sms_date, num_sms + billables_main_domain = arbitrary_sms_billables_for_domain( + self.main_domain, self.sms_date, num_sms ) - num_sms_advanced = random.randint(self.advanced_sms_rate.monthly_limit + 1, - self.advanced_sms_rate.monthly_limit + 2) - advanced_billables = arbitrary_sms_billables_for_domain( - self.domain2, self.sms_date, num_sms_advanced + billables_non_main_domain1 = arbitrary_sms_billables_for_domain( + self.non_main_domain1, self.sms_date, num_sms ) tasks.generate_invoices_based_on_date(self.invoice_date) @@ -775,22 +693,18 @@ def test_sms_over_limit_in_yearly_invoice(self): invoice = CustomerInvoice.objects.first() sms_line_items = invoice.lineitem_set.get_feature_by_type(FeatureType.SMS) - self.assertEqual(sms_line_items.count(), 2) + self.assertEqual(sms_line_items.count(), 1) for sms_line_item in sms_line_items: self.assertIsNone(sms_line_item.base_description) self.assertEqual(sms_line_item.base_cost, Decimal('0.0000')) self.assertEqual(sms_line_item.quantity, 1) - if self.advanced_sms_rate.feature == sms_line_item.feature_rate.feature: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in advanced_billables[self.advanced_sms_rate.monthly_limit:] - ) - else: - sms_cost = sum( - billable.gateway_charge + billable.usage_charge - for billable in billables[self.sms_rate.monthly_limit:] - ) + sms_cost = sum( + billable.gateway_charge + billable.usage_charge + for billable in ( + billables_non_main_domain1[self.sms_rate.monthly_limit:] + + billables_main_domain + )) self.assertEqual(sms_line_item.unit_cost, sms_cost) self.assertEqual(sms_line_item.total, sms_cost) diff --git a/corehq/apps/accounting/tests/test_race_condition_is_prevented.py b/corehq/apps/accounting/tests/test_race_condition_is_prevented.py index c2beceb2c1c48..b3a62b041c9b6 100644 --- a/corehq/apps/accounting/tests/test_race_condition_is_prevented.py +++ b/corehq/apps/accounting/tests/test_race_condition_is_prevented.py @@ -38,8 +38,8 @@ def test_unique_constraint_prevents_duplicate_invoice(self): class UniqueConstraintCustomerInvoiceTest(BaseCustomerInvoiceCase): def test_unique_constraint_prevents_duplicate_customer_invoice(self): - invoice_date = utils.months_from_date(self.subscription.date_start, - random.randint(3, self.subscription_length)) + invoice_date = utils.months_from_date(self.main_subscription.date_start, + random.randint(3, self.main_subscription_length)) calculate_users_in_all_domains(invoice_date) tasks.generate_invoices_based_on_date(invoice_date) with self.assertLogs(level='ERROR') as log_cm: diff --git a/corehq/apps/accounting/tests/test_stripe_payment_method_with_api.py b/corehq/apps/accounting/tests/test_stripe_payment_method_with_api.py new file mode 100644 index 0000000000000..fe1bc0daf4ca8 --- /dev/null +++ b/corehq/apps/accounting/tests/test_stripe_payment_method_with_api.py @@ -0,0 +1,220 @@ +from decimal import Decimal +from django.conf import settings +import stripe +from corehq.apps.accounting.tests import generator +from corehq.apps.accounting.tests.base_tests import BaseAccountingTest +from corehq.apps.accounting.models import StripePaymentMethod +from unittest import SkipTest +from unittest.mock import patch + + +class TestStripePaymentMethod(BaseAccountingTest): + + def setUp(self): + super(TestStripePaymentMethod, self).setUp() + # Dependabot-created PRs do not have access to secrets. + # We skip test so the tests do not fail when dependabot creates new PR for dependency upgrades. + # Or for developers running tests locally if they do not have stripe API key in their localsettings. + if not settings.STRIPE_PRIVATE_KEY: + raise SkipTest("Stripe API Key not set") + stripe.api_key = settings.STRIPE_PRIVATE_KEY + + self.web_user_email = "test_web_user@gmail.com" + self.dimagi_user_email = "test_dimagi_user@dimagi.com" + self.billing_account = generator.billing_account(self.dimagi_user_email, self.web_user_email) + + self.stripe_customer = stripe.Customer.create(email=self.web_user_email) + self.addCleanup(self.stripe_customer.delete) + self.payment_method = StripePaymentMethod(web_user=self.web_user_email, + customer_id=self.stripe_customer.id) + self.payment_method.save() + # Stripe suggest using test tokens, see https://stripe.com/docs/testing. + self.card = self.payment_method.create_card('tok_visa', self.billing_account, None) + + self.currency = generator.init_default_currency() + + def test_setup_autopay_for_first_time(self): + self.assertEqual(self.billing_account.auto_pay_user, None) + self.assertFalse(self.billing_account.auto_pay_enabled) + + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True'}) + self.assertEqual(self.billing_account.auto_pay_user, self.web_user_email) + self.assertTrue(self.billing_account.auto_pay_enabled) + + def test_replace_card_for_autopay(self): + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True'}) + self.assertEqual(self.billing_account.auto_pay_user, self.web_user_email) + self.assertTrue(self.billing_account.auto_pay_enabled) + + # Replace autopay card + other_web_user = "another_web_user@gmail.com" + other_stripe_customer = stripe.Customer.create(email=other_web_user) + self.addCleanup(other_stripe_customer.delete) + other_payment_method = StripePaymentMethod(web_user=other_web_user, customer_id=other_stripe_customer.id) + other_payment_method.save() + other_stripe_card = other_payment_method.create_card('tok_mastercard', self.billing_account, None, True) + + self.assertEqual(self.billing_account.auto_pay_user, other_web_user) + self.assertTrue(other_stripe_card.metadata["auto_pay_{}".format(self.billing_account.id)]) + # The old autopay card should be removed from this billing account + card = self.payment_method.all_cards[0] + self.assertFalse(card.metadata["auto_pay_{}".format(self.billing_account.id)] == 'True') + + def test_same_card_used_by_multiple_billing_accounts(self): + billing_account_2 = generator.billing_account(self.dimagi_user_email, self.web_user_email) + + # Use the card for first billing account + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True'}) + self.assertEqual(self.billing_account.auto_pay_user, self.web_user_email) + self.assertTrue(self.billing_account.auto_pay_enabled) + + # Use the same card for the second billing account + self.payment_method.set_autopay(self.card, billing_account_2, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True', + "auto_pay_{}".format(billing_account_2.id): 'True'}) + + def test_unset_autopay(self): + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'True'}) + + self.payment_method.unset_autopay(self.card, self.billing_account) + + self.assertEqual(self.card.metadata, {"auto_pay_{}".format(self.billing_account.id): 'False'}) + self.assertIsNone(self.billing_account.auto_pay_user) + self.assertFalse(self.billing_account.auto_pay_enabled) + + def test_get_stripe_customer_if_existed(self): + customer = self.payment_method._get_or_create_stripe_customer() + self.assertEqual(customer.id, self.stripe_customer.id) + + def test_create_stripe_customer_if_not_existed(self): + web_user_email = generator.create_arbitrary_web_user_name() + payment_method = StripePaymentMethod(web_user=web_user_email) + customer = payment_method._get_or_create_stripe_customer() + self.assertEqual(customer.email, web_user_email) + self.addCleanup(customer.delete) + + def test_all_cards_raise_authentication_error_when_stripe_key_is_wrong(self): + stripe.api_key = "12345678" + with self.assertRaises(stripe.error.AuthenticationError): + self.payment_method.all_cards + + def test_all_cards_return_the_correct_collection_of_cards_for_a_customer(self): + # Get the payment methods that is associated with the customer + payment_methods = stripe.PaymentMethod.list( + customer=self.stripe_customer.id, + type="card", + ) + cards = self.payment_method.all_cards + actual_card_ids = [card.fingerprint for card in cards] + expected_card_ids = [payment_method.card.fingerprint for payment_method in payment_methods] + self.assertCountEqual(actual_card_ids, expected_card_ids) + + def test_all_cards_return_empty_array_for_customer_have_no_cards(self): + payment_method = StripePaymentMethod(web_user="no_card@gmail.com") + payment_method.save() + cards = payment_method.all_cards + self.assertEqual(len(cards), 0) + + def test_all_cards_return_empty_array_if_no_stripe_key(self): + stripe.api_key = None + with patch.object(settings, "STRIPE_PRIVATE_KEY", None): + self.assertEqual(len(self.payment_method.all_cards), 0) + + def test_all_cards_serialized_return_the_correct_property_of_a_card(self): + cards = self.payment_method.all_cards_serialized(self.billing_account) + card = cards[0] + self.assertEqual(card["brand"], "Visa") + self.assertEqual(card["last4"], "4242") + # stripe might change exp date for the test card, so we don't assert absolute value + self.assertIn("exp_month", card) + self.assertIn("exp_year", card) + self.assertIn("token", card) + self.assertFalse(card["is_autopay"]) + + def test_get_card_return_the_correct_card_object(self): + # TODO: Should I rename get_card parameter to card_id? + card = self.payment_method.get_card(self.card.id) + self.assertEqual(card.id, self.card.id) + + def test_get_autopay_card_when_no_autopay_card(self): + self.assertEqual(self.billing_account.auto_pay_user, None) + self.assertFalse(self.billing_account.auto_pay_enabled) + result = self.payment_method.get_autopay_card(self.billing_account) + self.assertIsNone(result) + + def test_get_autopay_card_when_only_one_autopay(self): + self.payment_method.set_autopay(self.card, self.billing_account, None) + result = self.payment_method.get_autopay_card(self.billing_account) + self.assertEqual(result.id, self.card.id) + + def test_get_autopay_card_when_one_of_many_card_is_autopay(self): + card2 = self.payment_method.create_card('tok_discover', self.billing_account, None, True) + card3 = self.payment_method.create_card('tok_amex', self.billing_account, None, True) + self.addCleanup(card2.delete) + self.addCleanup(card3.delete) + + result = self.payment_method.get_autopay_card(self.billing_account) + self.assertIsNotNone(result.id, card2.id) + + result = self.payment_method.get_autopay_card(self.billing_account) + self.assertIsNotNone(result.id, card3.id) + + def test_remove_card_successful(self): + self.assertEqual(len(self.payment_method.customer.cards), 1) + self.payment_method.set_autopay(self.card, self.billing_account, None) + self.assertEqual(self.card.id, self.payment_method.get_autopay_card(self.billing_account).id) + + self.payment_method.remove_card(self.card.id) + self.assertEqual(len(self.payment_method.customer.cards), 0) + self.billing_account.refresh_from_db() + self.assertIsNone(self.billing_account.auto_pay_user) + self.assertFalse(self.billing_account.auto_pay_enabled) + + def test_remove_card_non_existent(self): + with self.assertRaises(stripe.error.InvalidRequestError): + self.payment_method.remove_card("non_existent_card_id") + + def test_create_card_creates_card(self): + created_card = self.payment_method.create_card('tok_discover', self.billing_account, None) + self.addCleanup(created_card.delete) + self.assertIsNotNone(created_card) + self.assertEqual(created_card.brand, 'Discover') + + def test_create_charge_success(self): + description = "Test charge" + amount_in_dollars = Decimal('100') + # Perform the charge + transaction_id = self.payment_method.create_charge( + card=self.card.id, + amount_in_dollars=amount_in_dollars, + description=description + ) + # Verify the charge was successful by retrieving the charge from Stripe + charge = stripe.Charge.retrieve(transaction_id) + self.assertIsNotNone(charge) + self.assertEqual(charge.amount, int(amount_in_dollars * Decimal('100'))) + self.assertEqual(charge.currency, self.currency.code.lower()) + self.assertEqual(charge.description, description) + + def test_create_charge_with_idempotency_key(self): + description = "Test charge" + amount_in_dollars = Decimal('100') + # Idempotency key ensures that the charge can be retried without fear of double charging + idempotency_key = 'test_idempotency_key_12345' + transaction_id_first_attempt = self.payment_method.create_charge( + card=self.card.id, + amount_in_dollars=amount_in_dollars, + description=description, + idempotency_key=idempotency_key + ) + transaction_id_second_attempt = self.payment_method.create_charge( + card=self.card.id, + amount_in_dollars=amount_in_dollars, + description=description, + idempotency_key=idempotency_key + ) + self.assertEqual(transaction_id_first_attempt, transaction_id_second_attempt) diff --git a/corehq/apps/accounting/tests/test_stripe_utils_with_api.py b/corehq/apps/accounting/tests/test_stripe_utils_with_api.py new file mode 100644 index 0000000000000..6bbbc7e3fb042 --- /dev/null +++ b/corehq/apps/accounting/tests/test_stripe_utils_with_api.py @@ -0,0 +1,59 @@ +from decimal import Decimal +from django.test import TestCase +import stripe +from corehq.apps.accounting.models import StripePaymentMethod +from corehq.apps.accounting.tests import generator +from corehq.apps.accounting.utils.stripe import get_customer_cards, charge_through_stripe +from unittest import SkipTest +from django.conf import settings + + +class StripeUtilsTests(TestCase): + + def setUp(self): + super().setUp() + # Dependabot-created PRs do not have access to secrets. + # We skip test so the tests do not fail when dependabot creates new PR for dependency upgrades. + # Or for developers running tests locally if they do not have stripe API key in their localsettings. + if not settings.STRIPE_PRIVATE_KEY: + raise SkipTest("Stripe API Key not set") + self.web_user_email = "test@example.com" + + # Set up Stripe customer with a card + self.stripe_customer = stripe.Customer.create(email=self.web_user_email) + self.addCleanup(self.stripe_customer.delete) + + # Create a corresponding local StripePaymentMethod instance + self.dimagi_user_email = "test_dimagi_user@dimagi.com" + self.billing_account = generator.billing_account(self.dimagi_user_email, self.web_user_email) + self.payment_method = StripePaymentMethod.objects.create( + web_user=self.web_user_email, + customer_id=self.stripe_customer.id + ) + self.payment_method.save() + self.card = self.payment_method.create_card('tok_visa', self.billing_account, None) + self.payment_method.create_card('tok_discover', self.billing_account, None) + + def test_get_customer_cards(self): + cards = get_customer_cards(self.web_user_email) + self.assertIsNotNone(cards) + self.assertEqual(len(cards['data']), 2) + self.assertEqual(cards['data'][0]['last4'], '4242') + self.assertEqual(cards['data'][0]['brand'], 'Visa') + self.assertEqual(cards['data'][0].id, self.card.id) + + def test_charge_through_stripe_successful(self): + amount_in_dollars = Decimal('10.00') + currency = 'usd' + description = 'Test charge' + charge = charge_through_stripe( + card=self.card.id, + customer=self.stripe_customer.id, + amount_in_dollars=amount_in_dollars, + currency=currency, + description=description + ) + self.assertIsNotNone(charge) + self.assertEqual(charge.amount, amount_in_dollars * 100) # Stripe uses cents + self.assertEqual(charge.currency, currency) + self.assertEqual(charge.description, description) diff --git a/corehq/apps/accounting/utils/__init__.py b/corehq/apps/accounting/utils/__init__.py index 6d4786c07ffd7..91e4d64a8afa5 100644 --- a/corehq/apps/accounting/utils/__init__.py +++ b/corehq/apps/accounting/utils/__init__.py @@ -218,28 +218,6 @@ def fmt_dollar_amount(decimal_value): return _("USD %s") % quantize_accounting_decimal(decimal_value) -def get_customer_cards(username, domain): - from corehq.apps.accounting.models import ( - StripePaymentMethod, PaymentMethodType, - ) - import stripe - try: - payment_method = StripePaymentMethod.objects.get( - web_user=username, - method_type=PaymentMethodType.STRIPE - ) - stripe_customer = payment_method.customer - return dict(stripe_customer.cards) - except StripePaymentMethod.DoesNotExist: - pass - except stripe.error.AuthenticationError: - if not settings.STRIPE_PRIVATE_KEY: - log_accounting_info("Private key is not defined in settings") - else: - raise - return None - - def is_accounting_admin(user): accounting_privilege = Role.get_privilege(privileges.ACCOUNTING_ADMIN) if accounting_privilege is None: diff --git a/corehq/apps/accounting/utils/stripe.py b/corehq/apps/accounting/utils/stripe.py new file mode 100644 index 0000000000000..43d20fbc46ed2 --- /dev/null +++ b/corehq/apps/accounting/utils/stripe.py @@ -0,0 +1,51 @@ +from decimal import ROUND_DOWN, Decimal +from django.conf import settings +from corehq.apps.accounting.utils import log_accounting_info +import stripe + + +def get_customer_cards(username): + from corehq.apps.accounting.models import StripePaymentMethod, PaymentMethodType + + try: + payment_method = StripePaymentMethod.objects.get( + web_user=username, + method_type=PaymentMethodType.STRIPE + ) + stripe_customer = payment_method.customer + return dict(stripe_customer.cards) + except StripePaymentMethod.DoesNotExist: + pass + except stripe.error.AuthenticationError: + if not settings.STRIPE_PRIVATE_KEY: + log_accounting_info("Private key is not defined in settings") + else: + raise + return None + + +def charge_through_stripe(card, customer, amount_in_dollars, currency, description, idempotency_key=None): + """ + Creates a charge on a customer's card using Stripe's payment processing service. + + This function is a simple wrapper around the Stripe API's `Charge.create` method. + + Parameters: + - card (str): The card token or ID representing the payment source to be charged. + - customer (str): The ID of the stripe customer to whom the card belongs. + - amount_in_dollars (Decimal): The amount to charge, represented as a Decimal in dollars. + - currency (str): The three-letter ISO currency code representing the currency of the charge. + - description (str): An arbitrary string attached to the charge, for describing the transaction. + - idempotency_key (str, optional): A unique key that ensures idempotence of the charge. + + """ + amount_in_cents = int((amount_in_dollars * Decimal('100')).quantize(Decimal('1'), rounding=ROUND_DOWN)) + + return stripe.Charge.create( + card=card, + customer=customer, + amount=amount_in_cents, + currency=currency, + description=description, + idempotency_key=idempotency_key, + ) diff --git a/corehq/apps/analytics/static/analytix/js/cta_forms.js b/corehq/apps/analytics/static/analytix/js/cta_forms.js index eab98d3cd5983..3042b20dd9fcc 100644 --- a/corehq/apps/analytics/static/analytix/js/cta_forms.js +++ b/corehq/apps/analytics/static/analytix/js/cta_forms.js @@ -4,7 +4,7 @@ hqDefine('analytix/js/cta_forms', [ 'underscore', 'hqwebapp/js/initial_page_data', 'hqwebapp/js/assert_properties', - 'hqwebapp/js/validators.ko', // needed for validation of startDate and endDate + 'hqwebapp/js/bootstrap3/validators.ko', // needed for validation of startDate and endDate 'intl-tel-input/build/js/intlTelInput.min', ], function ( $, diff --git a/corehq/apps/analytics/tasks.py b/corehq/apps/analytics/tasks.py index ad82b7ca4cc4a..c8242ec070bea 100644 --- a/corehq/apps/analytics/tasks.py +++ b/corehq/apps/analytics/tasks.py @@ -63,7 +63,7 @@ from corehq.util.decorators import analytics_task from corehq.util.metrics import metrics_counter, metrics_gauge from corehq.util.metrics.const import MPM_LIVESUM, MPM_MAX -from corehq.util.soft_assert import soft_assert +from dimagi.utils.logging import notify_error logger = logging.getLogger('analytics') logger.setLevel('DEBUG') @@ -374,7 +374,7 @@ def send_hubspot_form(form_id, request, user=None, extra_fields=None): @analytics_task() def send_hubspot_form_task(form_id, web_user_id, hubspot_cookie, meta, - extra_fields=None): + extra_fields=None): web_user = WebUser.get_by_user_id(web_user_id) _send_form_to_hubspot(form_id, web_user, hubspot_cookie, meta, extra_fields=extra_fields) @@ -442,7 +442,8 @@ def _no_nonascii_unicode(value): res = km.record( email, event, - {_no_nonascii_unicode(k): _no_nonascii_unicode(v) for k, v in properties.items()} if properties else {}, + {_no_nonascii_unicode(k): _no_nonascii_unicode(v) for k, v in properties.items()} + if properties else {}, timestamp ) log_response("KM", {'email': email, 'event': event, 'properties': properties, 'timestamp': timestamp}, res) @@ -678,11 +679,8 @@ def submit_data_to_hub_and_kiss(submit_json): try: dispatcher(submit_json) except requests.exceptions.HTTPError as e: - soft_assert(to=settings.SAAS_OPS_EMAIL, send_to_ops=False)( - False, - 'Error submitting periodic analytics data to Hubspot or Kissmetrics', - {'response': e.response.content.decode('utf-8')} - ) + notify_error("Error submitting periodic analytics data to Hubspot or Kissmetrics", + details=e.response.content.decode('utf-8')) except Exception as e: notify_exception(None, "{msg}: {exc}".format(msg=error_message, exc=e)) @@ -777,10 +775,10 @@ def _is_paying_subscription(subscription, plan_version): ProBonoStatus.YES, ProBonoStatus.DISCOUNTED, ] - return (plan_version.plan.visibility != SoftwarePlanVisibility.TRIAL and - subscription.service_type not in NON_PAYING_SERVICE_TYPES and - subscription.pro_bono_status not in NON_PAYING_PRO_BONO_STATUSES and - plan_version.plan.edition != SoftwarePlanEdition.COMMUNITY) + return (plan_version.plan.visibility != SoftwarePlanVisibility.TRIAL + and subscription.service_type not in NON_PAYING_SERVICE_TYPES + and subscription.pro_bono_status not in NON_PAYING_PRO_BONO_STATUSES + and plan_version.plan.edition != SoftwarePlanEdition.COMMUNITY) # Note: using "yes" and "no" instead of True and False because spec calls # for using these values. (True is just converted to "True" in KISSmetrics) diff --git a/corehq/apps/analytics/templates/analytics/initial/appcues.html b/corehq/apps/analytics/templates/analytics/initial/appcues.html index de1595b80851f..7445aff084a89 100644 --- a/corehq/apps/analytics/templates/analytics/initial/appcues.html +++ b/corehq/apps/analytics/templates/analytics/initial/appcues.html @@ -6,10 +6,11 @@ {% elif is_saas_environment %} {% comment %} HACK: project/domain_links is a hacky way for us to ensure appcues is being shown on the ERM/MRM page. + /cloudcare/apps/v2/ is a way for us to ensure appcues is being shown on Web Apps. If we remove these checks so all users see appcues, we risk going over our appcues user cap This is intended to be a short-term fix as we search for tour alternatives. {% endcomment %} - {% if not request.user.is_authenticated or request.couch_user.days_since_created < 31 or '/project/domain_links/' in request.path %} + {% if not request.user.is_authenticated or request.couch_user.days_since_created < 31 or '/project/domain_links/' in request.path or '/cloudcare/apps/v2/' in request.path%} {% initial_analytics_data 'appcues.apiId' ANALYTICS_IDS.APPCUES_ID %} {% endif %} {% endif %} diff --git a/corehq/apps/api/decorators.py b/corehq/apps/api/decorators.py index aa8a656ee57d0..f4e4a47b5ef42 100644 --- a/corehq/apps/api/decorators.py +++ b/corehq/apps/api/decorators.py @@ -55,7 +55,7 @@ def wrapped_view(request, *args, **kwargs): throttle = get_hq_throttle() should_be_throttled = throttle.should_be_throttled(identifier) if should_be_throttled: - return HttpResponse(status=429) + return HttpResponse(status=429, headers={'Retry-After': throttle.retry_after(identifier)}) throttle.accessed(identifier, url=request.get_full_path(), request_method=request.method.lower()) return view(request, *args, **kwargs) return wrapped_view diff --git a/corehq/apps/api/resources/meta.py b/corehq/apps/api/resources/meta.py index 700ef3a826cb0..ff0c8a9b3b445 100644 --- a/corehq/apps/api/resources/meta.py +++ b/corehq/apps/api/resources/meta.py @@ -44,6 +44,8 @@ def should_be_throttled(self, identifier, **kwargs): return not api_rate_limiter.allow_usage(identifier.domain) + def retry_after(self, identifier): + return api_rate_limiter.get_retry_after(scope=identifier.domain) def accessed(self, identifier, **kwargs): """ diff --git a/corehq/apps/api/resources/v0_1.py b/corehq/apps/api/resources/v0_1.py index 444583ee445a6..e70805a1fea58 100644 --- a/corehq/apps/api/resources/v0_1.py +++ b/corehq/apps/api/resources/v0_1.py @@ -104,7 +104,7 @@ def dehydrate(self, bundle): return super(UserResource, self).dehydrate(bundle) def dehydrate_user_data(self, bundle): - user_data = bundle.obj.metadata + user_data = bundle.obj.get_user_data(bundle.obj.domain).to_dict() if self.determine_format(bundle.request) == 'application/xml': # attribute names can't start with digits in xml user_data = {k: v for k, v in user_data.items() if not k[0].isdigit()} diff --git a/corehq/apps/api/resources/v0_5.py b/corehq/apps/api/resources/v0_5.py index addda5bfa8e53..ddd7132d38c1e 100644 --- a/corehq/apps/api/resources/v0_5.py +++ b/corehq/apps/api/resources/v0_5.py @@ -141,7 +141,8 @@ MOCK_BULK_USER_ES = None -EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT = 200 +EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT = 1000 +EXPORT_DATASOURCE_MAX_PAGINATION_LIMIT = 10000 def user_es_call(domain, q, fields, size, start_at): @@ -1358,8 +1359,8 @@ def get_datasource_data(request, config_id, domain): datasource_adapter = get_indicator_adapter(config, load_source='export_data_source') request_params = get_request_params(request).params request_params["limit"] = request.GET.dict().get("limit", EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT) - if int(request_params["limit"]) > EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT: - request_params["limit"] = EXPORT_DATASOURCE_DEFAULT_PAGINATION_LIMIT + if int(request_params["limit"]) > EXPORT_DATASOURCE_MAX_PAGINATION_LIMIT: + request_params["limit"] = EXPORT_DATASOURCE_MAX_PAGINATION_LIMIT query = cursor_based_query_for_datasource(request_params, datasource_adapter) data = response_for_cursor_based_pagination(request, query, request_params, datasource_adapter) return JsonResponse(data) diff --git a/corehq/apps/api/tests/test_user_updates.py b/corehq/apps/api/tests/test_user_updates.py index 5ed862605a3b1..955618af93e03 100644 --- a/corehq/apps/api/tests/test_user_updates.py +++ b/corehq/apps/api/tests/test_user_updates.py @@ -106,16 +106,20 @@ def test_update_phone_numbers_updates_default(self): self.assertEqual(self.user.default_phone_number, '50253311399') def test_update_user_data_succeeds(self): - self.user.update_metadata({'custom_data': "initial custom data"}) + self.user.get_user_data(self.domain)['custom_data'] = "initial custom data" update(self.user, 'user_data', {'custom_data': 'updated custom data'}) - self.assertEqual(self.user.metadata["custom_data"], "updated custom data") + self.assertEqual(self.user.get_user_data(self.domain)["custom_data"], "updated custom data") def test_update_user_data_raises_exception_if_profile_conflict(self): profile_id = self._setup_profile() with self.assertRaises(UpdateUserException) as cm: update(self.user, 'user_data', {PROFILE_SLUG: profile_id, 'conflicting_field': 'no'}) + self.assertEqual(cm.exception.message, "'conflicting_field' cannot be set directly") - self.assertEqual(cm.exception.message, 'metadata properties conflict with profile: conflicting_field') + def test_profile_not_found(self): + with self.assertRaises(UpdateUserException) as cm: + update(self.user, 'user_data', {PROFILE_SLUG: 123456}) + self.assertEqual(cm.exception.message, "User data profile not found") def test_update_groups_succeeds(self): group = Group({"name": "test", "domain": self.user.domain}) @@ -283,7 +287,7 @@ def test_update_phone_numbers_does_not_log_no_change(self): self.assertNotIn('phone_numbers', self.user_change_logger.change_messages.keys()) def test_update_user_data_logs_change(self): - self.user.update_metadata({'custom_data': "initial custom data"}) + self.user.get_user_data(self.domain)['custom_data'] = "initial custom data" update(self.user, 'user_data', @@ -293,7 +297,7 @@ def test_update_user_data_logs_change(self): self.assertIn('user_data', self.user_change_logger.fields_changed.keys()) def test_update_user_data_does_not_log_no_change(self): - self.user.update_metadata({'custom_data': "unchanged custom data"}) + self.user.get_user_data(self.domain)['custom_data'] = "unchanged custom data" update(self.user, 'user_data', {'custom_data': 'unchanged custom data'}) self.assertNotIn('user_data', self.user_change_logger.fields_changed.keys()) diff --git a/corehq/apps/api/tests/user_resources.py b/corehq/apps/api/tests/user_resources.py index 19ec86897405d..08ecd25f5ec1b 100644 --- a/corehq/apps/api/tests/user_resources.py +++ b/corehq/apps/api/tests/user_resources.py @@ -100,7 +100,7 @@ def test_get_list(self): 'last_name': '', 'phone_numbers': [], 'resource_uri': '/a/qwerty/api/v0.5/user/{}/'.format(backend_id), - 'user_data': {'commcare_project': 'qwerty'}, + 'user_data': {'commcare_project': 'qwerty', PROFILE_SLUG: ''}, 'username': 'fake_user' }) @@ -126,7 +126,7 @@ def test_get_single(self): 'last_name': '', 'phone_numbers': [], 'resource_uri': '/a/qwerty/api/v0.5/user/{}/'.format(backend_id), - 'user_data': {'commcare_project': 'qwerty'}, + 'user_data': {'commcare_project': 'qwerty', PROFILE_SLUG: ''}, 'username': 'fake_user', }) @@ -169,7 +169,7 @@ def test_create(self): self.assertEqual(user_back.email, "jdoe@example.org") self.assertEqual(user_back.language, "en") self.assertEqual(user_back.get_group_ids()[0], group._id) - self.assertEqual(user_back.user_data["chw_id"], "13/43/DFA") + self.assertEqual(user_back.get_user_data(self.domain.name)["chw_id"], "13/43/DFA") self.assertEqual(user_back.default_phone_number, "50253311399") @flag_enabled('COMMCARE_CONNECT') @@ -283,9 +283,10 @@ def test_update(self): self.assertEqual(modified.email, "tlast@example.org") self.assertEqual(modified.language, "pol") self.assertEqual(modified.get_group_ids()[0], group._id) - self.assertEqual(modified.metadata["chw_id"], "13/43/DFA") - self.assertEqual(modified.metadata[PROFILE_SLUG], self.profile.id) - self.assertEqual(modified.metadata["imaginary"], "yes") + user_data = modified.get_user_data(self.domain.name) + self.assertEqual(user_data["chw_id"], "13/43/DFA") + self.assertEqual(user_data.profile_id, self.profile.id) + self.assertEqual(user_data["imaginary"], "yes") self.assertEqual(modified.default_phone_number, "50253311399") # test user history audit @@ -298,11 +299,7 @@ def test_update(self): 'language': 'pol', 'last_name': 'last', 'first_name': 'test', - 'user_data': { - 'chw_id': '13/43/DFA', - 'commcare_profile': self.profile.id, - 'commcare_project': 'qwerty' - } + 'user_data': {'chw_id': '13/43/DFA'}, } ) self.assertTrue("50253311398" in diff --git a/corehq/apps/api/user_updates.py b/corehq/apps/api/user_updates.py index 402d1352402f6..bbb6ad188121d 100644 --- a/corehq/apps/api/user_updates.py +++ b/corehq/apps/api/user_updates.py @@ -4,6 +4,7 @@ from dimagi.utils.couch.bulk import get_docs from corehq.apps.api.exceptions import UpdateUserException +from corehq.apps.custom_data_fields.models import PROFILE_SLUG from corehq.apps.domain.forms import clean_password from corehq.apps.domain.models import Domain from corehq.apps.groups.models import Group @@ -11,6 +12,7 @@ from corehq.apps.user_importer.helpers import find_differences_in_list from corehq.apps.users.audit.change_messages import UserChangeMessage from corehq.apps.users.models_role import UserRole +from corehq.apps.users.user_data import UserDataError def update(user, field, value, user_change_logger=None): @@ -107,15 +109,17 @@ def _update_groups(user, group_ids, user_change_logger): user_change_logger.add_info(UserChangeMessage.groups_info(groups)) -def _update_user_data(user, user_data, user_change_logger): - original_user_data = user.metadata.copy() +def _update_user_data(user, new_user_data, user_change_logger): try: - user.update_metadata(user_data) - except ValueError as e: + profile_id = new_user_data.pop(PROFILE_SLUG, ...) + changed = user.get_user_data(user.domain).update(new_user_data, profile_id=profile_id) + except UserDataError as e: raise UpdateUserException(str(e)) - if user_change_logger and original_user_data != user.user_data: - user_change_logger.add_changes({'user_data': user.user_data}) + if user_change_logger and changed: + user_change_logger.add_changes({ + 'user_data': user.get_user_data(user.domain).raw + }) def _update_user_role(user, role, user_change_logger): diff --git a/corehq/apps/app_manager/app_schemas/case_properties.py b/corehq/apps/app_manager/app_schemas/case_properties.py index 722d6d0e13344..c8c5812d6b688 100644 --- a/corehq/apps/app_manager/app_schemas/case_properties.py +++ b/corehq/apps/app_manager/app_schemas/case_properties.py @@ -508,6 +508,7 @@ def get_usercase_properties(app): return {USERCASE_TYPE: []} +@quickcache(vary_on=['domain', 'include_parent_properties', 'exclude_deprecated_properties']) def all_case_properties_by_domain(domain, include_parent_properties=True, exclude_deprecated_properties=True): builder = ParentCasePropertyBuilder.for_domain( domain, include_parent_properties=include_parent_properties, diff --git a/corehq/apps/app_manager/helpers/validators.py b/corehq/apps/app_manager/helpers/validators.py index 133d742f03008..9f4f980919aa9 100644 --- a/corehq/apps/app_manager/helpers/validators.py +++ b/corehq/apps/app_manager/helpers/validators.py @@ -559,6 +559,8 @@ class ModuleDetailValidatorMixin(object): __invalid_tile_configuration_type: str = "invalid tile configuration" + __invalid_clickable_icon_configuration: str = "invalid clickable icon configuration" + def _validate_fields_with_format( self, format_value: str, @@ -576,6 +578,18 @@ def _validate_fields_with_format( .format(format_display, fields_with_address_format_str)) }) + def _validate_clickable_icons( + self, + columns: list, + errors: list + ): + for field in [c.field for c in columns if c.format == 'clickable-icon' and c.endpoint_action_id == '']: + errors.append({ + 'type': self.__invalid_clickable_icon_configuration, + 'module': self.get_module_info(), + 'reason': _('Column/Field "{}": Clickable Icons require a form to be configured.'.format(field)) + }) + ''' Validation logic common to basic and shadow modules, which both have detail configuration. ''' @@ -619,6 +633,7 @@ def validate_details_for_build(self): }) self._validate_fields_with_format('address', 'Address', detail.columns, errors) self._validate_fields_with_format('address-popup', 'Address Popup', detail.columns, errors) + self._validate_clickable_icons(detail.columns, errors) if detail.has_persistent_tile() and self.module.report_context_tile: errors.append({ diff --git a/corehq/apps/app_manager/models.py b/corehq/apps/app_manager/models.py index 4de6057671549..369abc077d263 100644 --- a/corehq/apps/app_manager/models.py +++ b/corehq/apps/app_manager/models.py @@ -65,6 +65,7 @@ remote_app, ) from corehq.apps.app_manager.app_schemas.case_properties import ( + all_case_properties_by_domain, get_all_case_properties, get_usercase_properties, ) @@ -1042,6 +1043,7 @@ class FormBase(DocumentSchema): is_release_notes_form = BooleanProperty(default=False) enable_release_notes = BooleanProperty(default=False) session_endpoint_id = StringProperty(exclude_if_none=True) # See toggles.SESSION_ENDPOINTS + respect_relevancy = BooleanProperty(default=True) # computed datums IDs that are allowed in endpoints function_datum_endpoints = StringListProperty() @@ -1305,8 +1307,6 @@ def is_auto_submitting_form(self, case_type=None): if case_type is None: return False - if self.get_module().case_type != case_type: - return False if not self.requires_case(): return False @@ -1846,13 +1846,16 @@ class DetailColumn(IndexedSchema): useXpathExpression = BooleanProperty(default=False) format = StringProperty(exclude_if_none=True) - grid_x = IntegerProperty(exclude_if_none=True) - grid_y = IntegerProperty(exclude_if_none=True) + # Only applies to custom case list tile. grid_x and grid_y are zero-based values + # representing the starting row and column. + grid_x = IntegerProperty(required=False) + grid_y = IntegerProperty(required=False) width = IntegerProperty(exclude_if_none=True) height = IntegerProperty(exclude_if_none=True) horizontal_align = StringProperty(exclude_if_none=True) vertical_align = StringProperty(exclude_if_none=True) font_size = StringProperty(exclude_if_none=True) + show_border = BooleanProperty(exclude_if_none=True) enum = SchemaListProperty(MappingItem) graph_configuration = SchemaProperty(GraphConfiguration) @@ -2166,6 +2169,7 @@ class CaseSearch(DocumentSchema): title_label = LabelProperty(default={}) description = LabelProperty(default={}) include_all_related_cases = BooleanProperty(default=False) + dynamic_search = BooleanProperty(default=False) # case property referencing another case's ID custom_related_case_property = StringProperty(exclude_if_none=True) @@ -3417,7 +3421,8 @@ class CustomDataAutoFilter(ReportAppFilter): def get_filter_value(self, user, ui_filter): from corehq.apps.reports_core.filters import Choice - return Choice(value=user.metadata[self.custom_data_property], display=None) + user_data = user.get_user_data(getattr(user, 'current_domain', user.domain)) + return Choice(value=user_data[self.custom_data_property], display=None) class StaticChoiceFilter(ReportAppFilter): @@ -4157,6 +4162,7 @@ def assert_app_v2(self): default=const.DEFAULT_LOCATION_FIXTURE_OPTION, choices=const.LOCATION_FIXTURE_OPTIONS, required=False ) + split_screen_dynamic_search = BooleanProperty(default=False) @property def id(self): @@ -4508,6 +4514,10 @@ def save(self, response_json=None, increment_version=None, **params): # expire cache unless new application self.global_app_config.clear_version_caches() get_all_case_properties.clear(self) + all_case_properties_by_domain.clear(self.domain, True, True) + all_case_properties_by_domain.clear(self.domain, True, False) + all_case_properties_by_domain.clear(self.domain, False, True) + all_case_properties_by_domain.clear(self.domain, False, False) get_usercase_properties.clear(self) get_app_languages.clear(self.domain) get_apps_in_domain.clear(self.domain, True) diff --git a/corehq/apps/app_manager/static/app_manager/js/details/column.js b/corehq/apps/app_manager/static/app_manager/js/details/column.js index 1362b2f896349..6cd499bbf9e78 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/column.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/column.js @@ -40,6 +40,7 @@ hqDefine("app_manager/js/details/column", function () { horizontal_align: "left", vertical_align: "start", font_size: "medium", + show_border: false, }; _.each(_.keys(defaults), function (key) { self.original[key] = self.original[key] || defaults[key]; @@ -50,12 +51,14 @@ hqDefine("app_manager/js/details/column", function () { self.case_tile_field = ko.observable(self.original.case_tile_field); self.coordinatesVisible = ko.observable(true); - self.tileRowMax = ko.observable(7); + self.tileRowMax = ko.observable(7); // set dynamically by screen self.tileColumnMax = ko.observable(13); - self.tileRowStart = ko.observable(self.original.grid_y || 1); - self.tileRowOptions = [""].concat(_.range(1, self.tileRowMax())); - self.tileColumnStart = ko.observable(self.original.grid_x || 1); - self.tileColumnOptions = [""].concat(_.range(1, self.tileColumnMax())); + self.tileRowStart = ko.observable(self.original.grid_y + 1 || 1); // converts from 0 to 1-based for UI + self.tileRowOptions = ko.computed(function () { + return _.range(1, self.tileRowMax()); + }); + self.tileColumnStart = ko.observable(self.original.grid_x + 1 || 1); // converts from 0 to 1-based for UI + self.tileColumnOptions = _.range(1, self.tileColumnMax()); self.tileWidth = ko.observable(self.original.width || self.tileRowMax() - 1); self.tileWidthOptions = ko.computed(function () { return _.range(1, self.tileColumnMax() + 1 - (self.tileColumnStart() || 1)); @@ -67,12 +70,26 @@ hqDefine("app_manager/js/details/column", function () { self.horizontalAlign = ko.observable(self.original.horizontal_align || 'left'); self.horizontalAlignOptions = ['left', 'center', 'right']; - self.verticalAlign = ko.observable(self.original.vertial_align || 'start'); + self.verticalAlign = ko.observable(self.original.vertical_align || 'start'); self.verticalAlignOptions = ['start', 'center', 'end']; self.fontSize = ko.observable(self.original.font_size || 'medium'); self.fontSizeOptions = ['small', 'medium', 'large']; + self.showBorder = ko.observable(self.original.show_border || false); + + self.openStyleModal = function () { + const $modalDiv = $(document.createElement("div")); + $modalDiv.attr("data-bind", "template: 'style_configuration_modal'"); + $modalDiv.koApplyBindings(self); + const $modal = $modalDiv.find('.modal'); + $modal.appendTo('body'); + $modal.modal('show'); + $modal.on('hidden.bs.modal', function () { + $modal.remove(); + }); + }; + self.tileRowEnd = ko.computed(function () { return Number(self.tileRowStart()) + Number(self.tileHeight()); }); @@ -268,7 +285,7 @@ hqDefine("app_manager/js/details/column", function () { formEndpoints.forEach(([, endpoint]) => { if (endpoint.module_name !== moduleName) { moduleName = endpoint.module_name; - formEndpointOptions.push({groupName: moduleName}); + formEndpointOptions.push({groupName: `${moduleName} (${endpoint.module_case_type})`}); } formEndpointOptions.push({value: endpoint.id, label: endpoint.form_name}); }); @@ -339,6 +356,7 @@ hqDefine("app_manager/js/details/column", function () { self.horizontalAlign.subscribe(fireChange); self.verticalAlign.subscribe(fireChange); self.fontSize.subscribe(fireChange); + self.showBorder.subscribe(fireChange); self.$format = $('
').append(self.format.ui); self.$format.find("select").css("margin-bottom", "5px"); @@ -423,13 +441,14 @@ hqDefine("app_manager/js/details/column", function () { column.date_format = self.date_extra.val(); column.enum = self.enum_extra.getItems(); column.endpoint_action_id = self.action_form_extra.val() === "-1" ? null : self.action_form_extra.val(); - column.grid_x = self.tileColumnStart(); - column.grid_y = self.tileRowStart(); + column.grid_x = self.tileColumnStart() - 1; + column.grid_y = self.tileRowStart() - 1; column.height = self.tileHeight(); column.width = self.tileWidth(); column.horizontal_align = self.horizontalAlign(); - column.vertial_align = self.verticalAlign(); + column.vertical_align = self.verticalAlign(); column.font_size = self.fontSize(); + column.show_border = self.showBorder(); column.graph_configuration = self.format.val() === "graph" ? self.graph_extra.val() : null; column.late_flag = parseInt(self.late_flag_extra.val(), 10); column.time_ago_interval = parseFloat(self.time_ago_extra.val()); diff --git a/corehq/apps/app_manager/static/app_manager/js/details/screen.js b/corehq/apps/app_manager/static/app_manager/js/details/screen.js index 2a825ad49c4f9..a2ac0d74d2bc1 100644 --- a/corehq/apps/app_manager/static/app_manager/js/details/screen.js +++ b/corehq/apps/app_manager/static/app_manager/js/details/screen.js @@ -413,6 +413,18 @@ hqDefine("app_manager/js/details/screen", function () { self.initColumnAsColumn(self.columns()[i]); } + self.caseTileRowMax = ko.computed(() => _.max([self.columns().length + 1, 7])); + self.caseTileRowMax.subscribe(function (newValue) { + self.updateTileRowMaxForColumns(newValue); + }); + + self.updateTileRowMaxForColumns = function (newValue) { + _.each(self.columns(), function (column) { + column.tileRowMax(newValue); + }); + }; + self.updateTileRowMaxForColumns(self.caseTileRowMax()); + self.saveButton = hqImport("hqwebapp/js/bootstrap3/main").initSaveButton({ unsavedMessage: gettext('You have unsaved detail screen configurations.'), save: function () { diff --git a/corehq/apps/app_manager/static/app_manager/js/vellum/manifest.txt b/corehq/apps/app_manager/static/app_manager/js/vellum/manifest.txt index c1e2d819bbe6f..fc83a92e881fd 100644 --- a/corehq/apps/app_manager/static/app_manager/js/vellum/manifest.txt +++ b/corehq/apps/app_manager/static/app_manager/js/vellum/manifest.txt @@ -1007,4 +1007,4 @@ │ ├─ buffer-crc32@~0.2.3 │ └─ fd-slicer@~1.1.0 └─ yocto-queue@0.1.0 -Done in 0.14s. +Done in 0.15s. diff --git a/corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js b/corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js index 0fa1d4c43f252..8b00efeac17ad 100644 --- a/corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js +++ b/corehq/apps/app_manager/static/app_manager/js/vellum/src/main-components.js @@ -28684,9 +28684,11 @@ define('vellum/tsv',[ }); define('vellum/exporter',[ - 'vellum/tsv' + 'vellum/tsv', + 'vellum/richText' ], function ( - tsv + tsv, + richText ) { // todo: abstract out IText stuff into part of the plugin interface var generateExportTSV = function (form) { @@ -28775,7 +28777,7 @@ define('vellum/exporter',[ row["Hint Text"] = defaultOrNothing(mug.p.hintItext, defaultLanguage, 'default'); row["Help Text"] = defaultOrNothing(mug.p.helpItext, defaultLanguage, 'default'); - row.Comment = mug.p.comment; + row.Comment = richText.sanitizeInput(mug.p.comment); // make sure there aren't any null values for (var prop in row) { @@ -48665,7 +48667,7 @@ define('vellum/core',[ form = this.data.core.form, mugs = multiselect ? mug : [mug], $baseToolbar = $(question_toolbar({ - comment: multiselect ? '' : mug.p.comment, + comment: multiselect ? '' : richText.sanitizeInput(mug.p.comment), isDeleteable: mugs && mugs.length && _.every(mugs, function (mug) { return _this.isMugRemoveable(mug, mug.hashtagPath); }), diff --git a/corehq/apps/app_manager/static/app_manager/js/vellum/version.txt b/corehq/apps/app_manager/static/app_manager/js/vellum/version.txt index c15bca43149e9..12cf6d4328a58 100644 --- a/corehq/apps/app_manager/static/app_manager/js/vellum/version.txt +++ b/corehq/apps/app_manager/static/app_manager/js/vellum/version.txt @@ -1 +1 @@ -9f539058684ab5d834fcad120e5b41c9550536b6 +9d2f2db789028bd2901ddfdc676c0b28879165a4 diff --git a/corehq/apps/app_manager/static/app_manager/json/commcare-app-settings.yml b/corehq/apps/app_manager/static/app_manager/json/commcare-app-settings.yml index d491bc41eaaa0..a4bda2535bf65 100644 --- a/corehq/apps/app_manager/static/app_manager/json/commcare-app-settings.yml +++ b/corehq/apps/app_manager/static/app_manager/json/commcare-app-settings.yml @@ -169,3 +169,12 @@ - ['only_flat_fixture', 'Only Flat Fixture'] - ['only_hierarchical_fixture', 'Only Hierarchical Fixture'] default: 'project_default' + + +- id: split_screen_dynamic_search + name: Dynamic Search for Split Screen Case Search + description: Enable searching as input values change after initial Split Screen Case Search + toggle: DYNAMICALLY_UPDATE_SEARCH_RESULTS + widget: bool + default: false + since: '2.54' diff --git a/corehq/apps/app_manager/static/app_manager/json/commcare-settings-layout.yml b/corehq/apps/app_manager/static/app_manager/json/commcare-settings-layout.yml index 9cec383a6b4aa..28cfaef5afb0e 100644 --- a/corehq/apps/app_manager/static/app_manager/json/commcare-settings-layout.yml +++ b/corehq/apps/app_manager/static/app_manager/json/commcare-settings-layout.yml @@ -61,6 +61,7 @@ - hq.translation_strategy - hq.mobile_ucr_restore_version - hq.location_fixture_restore + - hq.split_screen_dynamic_search - title: 'Disabled' id: app-settings-disabled diff --git a/corehq/apps/app_manager/static_strings.py b/corehq/apps/app_manager/static_strings.py index 708ae16f92205..5419b5e5101a0 100644 --- a/corehq/apps/app_manager/static_strings.py +++ b/corehq/apps/app_manager/static_strings.py @@ -73,9 +73,11 @@ gettext_noop('Disable'), gettext_noop('Disabled'), gettext_noop('Display root menu as a list or grid. Read more on the Help Site.'), + gettext_noop('Dynamic Search for Split Screen Case Search'), gettext_noop('Enable Menu Display Setting Per-Module'), gettext_noop('Enable'), gettext_noop('Enabled'), + gettext_noop('Enable searching as input values change after initial Split Screen Case Search'), gettext_noop('For mobile map displays, chooses a base tileset for the underlying map layer'), gettext_noop('Forms Menu Display'), gettext_noop('Forms are Never Removed'), diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.json b/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.json new file mode 100644 index 0000000000000..821d79e53f9b0 --- /dev/null +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.json @@ -0,0 +1,21 @@ +{ + "slug": "bha_referrals", + "filename": "bha_referrals.xml", + "has_map": false, + "fields": ["header", "top_left", "top_center", "top_right", + "middle0_left", "middle0_right", "middle1_left", "middle1_right", + "middle2", "bottom_left_img", "bottom_right"], + "grid": { + "header": {"height": 1, "width": 12, "x": 0, "y": 0, "horz-align":"left", "vert-align":"center"}, + "top_left": {"height": 1, "width": 4, "x": 0, "y": 1, "horz-align":"left", "vert-align":"center"}, + "top_center": {"height": 1, "width": 4, "x": 4, "y": 1, "horz-align":"left", "vert-align":"center"}, + "top_right": {"height": 1, "width": 4, "x": 8, "y": 1, "horz-align":"left", "vert-align":"center"}, + "middle0_left": {"height": 1, "width": 4, "x": 0, "y": 2, "horz-align":"left", "vert-align":"center"}, + "middle0_right": {"height": 1, "width": 8, "x": 4, "y": 2, "horz-align":"left", "vert-align":"center"}, + "middle1_left": {"height": 1, "width": 6, "x": 0, "y": 3, "horz-align":"left", "vert-align":"center"}, + "middle1_right": {"height": 1, "width": 6, "x": 6, "y": 3, "horz-align":"left", "vert-align":"center"}, + "middle2": {"height": 1, "width": 12, "x": 0, "y": 4, "horz-align":"left", "vert-align":"center"}, + "bottom_left_img": {"height": 1, "width": 1, "x": 0, "y": 5, "horz-align":"center", "vert-align":"start"}, + "bottom_right": {"height": 1, "width": 11, "x": 1, "y": 5, "horz-align":"left", "vert-align":"start"} + } +} \ No newline at end of file diff --git a/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.xml b/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.xml new file mode 100644 index 0000000000000..9abd4cae34d0e --- /dev/null +++ b/corehq/apps/app_manager/suite_xml/case_tile_templates/bha_referrals.xml @@ -0,0 +1,216 @@ +{{ error_message }}
+{% endblock %} diff --git a/corehq/apps/cloudcare/templates/cloudcare/formplayer_home.html b/corehq/apps/cloudcare/templates/cloudcare/formplayer_home.html index e5930c62d12f7..163ef0f4e364c 100644 --- a/corehq/apps/cloudcare/templates/cloudcare/formplayer_home.html +++ b/corehq/apps/cloudcare/templates/cloudcare/formplayer_home.html @@ -88,6 +88,7 @@ {% registerurl 'report_formplayer_error' request.domain %} {% registerurl 'report_sentry_error' request.domain %} {% registerurl 'dialer_view' request.domain %} + {% registerurl 'api_histogram_metrics' request.domain %} {% if integrations.gaen_otp_enabled %} {% registerurl 'gaen_otp_view' request.domain %} {% endif %} diff --git a/corehq/apps/cloudcare/templates/form_entry/templates.html b/corehq/apps/cloudcare/templates/form_entry/templates.html index ba412a61e9204..96fd625ef9193 100644 --- a/corehq/apps/cloudcare/templates/form_entry/templates.html +++ b/corehq/apps/cloudcare/templates/form_entry/templates.html @@ -27,7 +27,10 @@ 'tabindex': isVisibleGroup() ? '0' : '-1' }, click: toggleChildren, - event: {keypress: keyPressAction}"> + event: {keypress: keyPressAction}, + style: {'background-color': headerBackgroundColor(), + 'color': headerBackgroundColor() ? 'white' : '', + }">