Repository: dj-stripe/dj-stripe

Issue:
Currency field size too small for Stripe Large Charges
The public max currency size is shown [in the stripe docs](https://stripe.com/docs/currencies#minimum-and-maximum-charge-amounts) as being 8 digits.

The current djstripe fields allow for this:

- [StripeQuantumCurrencyAmountField](https://github.com/dj-stripe/dj-stripe/blob/master/djstripe/fields.py#L49) uses [IntegerField](https://docs.djangoproject.com/en/2.2/ref/models/fields/#integerfield) which allows up to 2147483647 so covers the 8 digits
- [StripeDecimalCurrencyAmountField](https://github.com/dj-stripe/dj-stripe/blob/master/djstripe/fields.py#L57) uses `DecimalField` with 8 `max_digits`, so that allows the 8 digits stripe specifies also.

We ran in to a problem with Stripe recently using IDR - it's fairly weak compared to the other currencies so the max transaction (999999.99 IDR) was around US$70 - pretty useless. We contacted stripe and they quickly changed the limit on our account to 11 digits instead of 8 (we aren't even live yet). The problem now is that the 2 djstripe currency fields don't fit these limits:

- The Quantum only allows a bit more than 9 digits
- The Decimal amount only allows 8 digits all up

To work for this use case of stripe upgrading our account, we'd need the `BigIntegerField` for QuantumAmount and `max_digits` of `11` for DecimalAmount.

The trade off is storage space - I guess this change would require extra storage for all your users to support this use case which mightn't be very common. Maybe there's a smarter way to do it though with some setting like a `DJSTRIPE_LARGE_CHARGES_MODE` (large charges is the terminology the stripe rep used).

I'd be happy to submit a PR if this is something you'd like to support.


Discussion:
Assuming the large charge change is going to be rolled out globally I think I'd prefer change to `BigIntegerField` for `StripeQuantumCurrencyAmountField`, I'd rather not add more settings that affect the database structure because it's a maintenance headache.  Given the amount of data we store for other fields I don't think the increased integer field size is a concern.

So yes, a PR would be great please, along with a simple unit test (eg fake a charge value to over the existing max value).

Do you know if/when large charges will be rolled out?
@therefromhere 

> Do you know if/when large charges will be rolled out?

I don't know if it will, nothing the rep said made me think it was (or wasn't) going to be rolled out for everyone. I've contacted them and will report back when I get a response (I'm not sure if they would readily share that sort of info though).
@therefromhere Their response didn't directly answer it, but it sounds like

- It's done on an account by account basis because it doesn't work for some use cases. i.e. for some use cases they said "These restrictions are inherent in the financial infrastructure upstream of Stripe"
- There's no plans to roll out globally and they can't unless some of the upstream financial institutions change

Based on the above, what's your updated thoughts?
@kavdev thoughts?

I don't object to changing to 11 digits/BigIntegerField to accommodate this, I just wonder how many of these long-lived feature flags there are on the stripe side?
@therefromhere yeah seems fine. If we're just going from IntegerField to BigIntegerField, it's not that big of a performance hit

Resolving patch:
diff --git a/djstripe/fields.py b/djstripe/fields.py
--- a/djstripe/fields.py
+++ b/djstripe/fields.py
@@ -46,9 +46,11 @@ def __init__(self, *args, **kwargs):
         super().__init__(*args, **defaults)
 
 
-class StripeQuantumCurrencyAmountField(models.IntegerField):
+class StripeQuantumCurrencyAmountField(models.BigIntegerField):
     """
     A field used to store currency amounts in cents (etc) as per stripe.
+    By contacting stripe support, some accounts will have their limit raised to 11
+    digits, hence the use of BigIntegerField instead of IntegerField
     """
 
     pass
@@ -66,8 +68,11 @@ class StripeDecimalCurrencyAmountField(models.DecimalField):
     """
 
     def __init__(self, *args, **kwargs):
-        """Assign default args to this field."""
-        defaults = {"decimal_places": 2, "max_digits": 8}
+        """
+        Assign default args to this field. By contacting stripe support, some accounts
+        will have their limit raised to 11 digits
+        """
+        defaults = {"decimal_places": 2, "max_digits": 11}
         defaults.update(kwargs)
         super().__init__(*args, **defaults)
 
diff --git a/djstripe/migrations/0007_auto_20191107_0118.py b/djstripe/migrations/0007_auto_20191107_0118.py
new file mode 100644
--- /dev/null
+++ b/djstripe/migrations/0007_auto_20191107_0118.py
@@ -0,0 +1,190 @@
+# Generated by Django 3.0b1 on 2019-11-07 01:18
+
+from django.db import migrations
+
+import djstripe.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ("djstripe", "0006_auto_20191005_0907"),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name="charge",
+            name="amount",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                help_text="Amount charged (as decimal).",
+                max_digits=11,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="charge",
+            name="amount_refunded",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                help_text="Amount (as decimal) refunded (can be less than the amount "
+                "attribute on the charge if a partial refund was issued).",
+                max_digits=11,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="coupon",
+            name="amount_off",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                blank=True,
+                decimal_places=2,
+                help_text="Amount (as decimal) that will be taken off the subtotal of "
+                "any invoices for this customer.",
+                max_digits=11,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="invoice",
+            name="amount_due",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                help_text="Final amount due (as decimal) at this time for this "
+                "invoice. If the invoice's total is smaller than the minimum charge "
+                "amount, for example, or if there is account credit that can be "
+                "applied to the invoice, the amount_due may be 0. If there is a "
+                "positive starting_balance for the invoice (the customer owes money), "
+                "the amount_due will also take that into account. The charge that gets "
+                "generated for the invoice will be for the amount specified in "
+                "amount_due.",
+                max_digits=11,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="invoice",
+            name="amount_paid",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                help_text="The amount, (as decimal), that was paid.",
+                max_digits=11,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="invoice",
+            name="amount_remaining",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                help_text="The amount remaining, (as decimal), that is due.",
+                max_digits=11,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="invoice",
+            name="application_fee_amount",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                blank=True,
+                decimal_places=2,
+                help_text="The fee (as decimal) that will be applied to the invoice "
+                "and transferred to the application owner's Stripe account when the "
+                "invoice is paid.",
+                max_digits=11,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="invoice",
+            name="subtotal",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                help_text="Total (as decimal) of all subscriptions, invoice items, and "
+                "prorations on the invoice before any discount or tax is applied.",
+                max_digits=11,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="invoice",
+            name="tax",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                blank=True,
+                decimal_places=2,
+                help_text="The amount (as decimal) of tax included in the total, "
+                "calculated from ``tax_percent`` and the subtotal. If no "
+                "``tax_percent`` is defined, this value will be null.",
+                max_digits=11,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="invoice",
+            name="total",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                max_digits=11,
+                verbose_name="Total (as decimal) after discount.",
+            ),
+        ),
+        migrations.AlterField(
+            model_name="invoiceitem",
+            name="amount",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                help_text="Amount invoiced (as decimal).",
+                max_digits=11,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="payout",
+            name="amount",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2,
+                help_text="Amount (as decimal) to be transferred to your bank account "
+                "or debit card.",
+                max_digits=11,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="plan",
+            name="amount",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                blank=True,
+                decimal_places=2,
+                help_text="Amount (as decimal) to be charged on the interval "
+                "specified.",
+                max_digits=11,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="source",
+            name="amount",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                blank=True,
+                decimal_places=2,
+                help_text="Amount (as decimal) associated with the source. This is the "
+                "amount for which the source will be chargeable once ready. Required "
+                "for `single_use` sources.",
+                max_digits=11,
+                null=True,
+            ),
+        ),
+        migrations.AlterField(
+            model_name="transfer",
+            name="amount",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                decimal_places=2, help_text="The amount transferred", max_digits=11
+            ),
+        ),
+        migrations.AlterField(
+            model_name="transfer",
+            name="amount_reversed",
+            field=djstripe.fields.StripeDecimalCurrencyAmountField(
+                blank=True,
+                decimal_places=2,
+                help_text="The amount (as decimal) reversed (can be less than the "
+                "amount attribute on the transfer if a partial reversal was issued).",
+                max_digits=11,
+                null=True,
+            ),
+        ),
+    ]


Test patch:
diff --git a/tests/test_charge.py b/tests/test_charge.py
--- a/tests/test_charge.py
+++ b/tests/test_charge.py
@@ -903,3 +903,67 @@ def test__attach_objects_hook_missing_source_data(
         # source shouldn't be touched
         self.assertEqual(starting_source, charge.source)
         mock_payment_method._get_or_create_source.assert_not_called()
+
+    @patch(
+        "djstripe.models.Account.get_default_account",
+        autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED
+        and IS_STATICMETHOD_AUTOSPEC_SUPPORTED,
+    )
+    @patch("stripe.BalanceTransaction.retrieve", autospec=True)
+    @patch("stripe.Charge.retrieve", autospec=IS_STATICMETHOD_AUTOSPEC_SUPPORTED)
+    @patch(
+        "stripe.Invoice.retrieve", return_value=deepcopy(FAKE_INVOICE), autospec=True
+    )
+    @patch(
+        "stripe.PaymentIntent.retrieve",
+        return_value=deepcopy(FAKE_PAYMENT_INTENT_I),
+        autospec=True,
+    )
+    @patch(
+        "stripe.PaymentMethod.retrieve",
+        return_value=deepcopy(FAKE_CARD_AS_PAYMENT_METHOD),
+        autospec=True,
+    )
+    @patch("stripe.Plan.retrieve", return_value=deepcopy(FAKE_PLAN), autospec=True)
+    @patch(
+        "stripe.Product.retrieve", return_value=deepcopy(FAKE_PRODUCT), autospec=True
+    )
+    @patch(
+        "stripe.Subscription.retrieve",
+        return_value=deepcopy(FAKE_SUBSCRIPTION),
+        autospec=True,
+    )
+    def test_max_size_large_charge_on_decimal_amount(
+        self,
+        subscription_retrieve_mock,
+        product_retrieve_mock,
+        plan_retrieve_mock,
+        paymentmethod_card_retrieve_mock,
+        payment_intent_retrieve_mock,
+        invoice_retrieve_mock,
+        charge_retrieve_mock,
+        balance_transaction_retrieve_mock,
+        default_account_mock,
+    ):
+        """
+        By contacting stripe support, some accounts will have their limit raised to 11
+        digits
+        """
+        amount = 99999999999
+        assert len(str(amount)) == 11
+
+        fake_transaction = deepcopy(FAKE_BALANCE_TRANSACTION)
+        fake_transaction.update({"amount": amount})
+
+        default_account_mock.return_value = self.account
+        balance_transaction_retrieve_mock.return_value = fake_transaction
+
+        fake_charge = deepcopy(FAKE_CHARGE)
+        fake_charge.update({"amount": amount})
+
+        charge = Charge.sync_from_stripe_data(fake_charge)
+
+        charge_retrieve_mock.assert_not_called()
+        self.assertTrue(bool(charge.pk))
+        self.assertEqual(charge.amount, Decimal("999999999.99"))
+        self.assertEqual(charge.balance_transaction.amount, 99999999999)
diff --git a/tests/test_subscription.py b/tests/test_subscription.py
--- a/tests/test_subscription.py
+++ b/tests/test_subscription.py
@@ -397,8 +397,8 @@ def test_cancel_now(
         canceled_subscription_fake["canceled_at"] = cancel_timestamp
         canceled_subscription_fake["ended_at"] = cancel_timestamp
         subscription_retrieve_mock.return_value = (
-            canceled_subscription_fake
-        )  # retrieve().delete()
+            canceled_subscription_fake  # retrieve().delete()
+        )
 
         self.assertTrue(self.customer.has_active_subscription())
         self.assertEqual(self.customer.active_subscriptions.count(), 1)
@@ -447,8 +447,8 @@ def test_cancel_at_period_end(
         )
         canceled_subscription_fake["canceled_at"] = datetime_to_unix(timezone.now())
         subscription_retrieve_mock.return_value = (
-            canceled_subscription_fake
-        )  # retrieve().delete()
+            canceled_subscription_fake  # retrieve().delete()
+        )
 
         self.assertTrue(self.customer.has_active_subscription())
         self.assertTrue(self.customer.has_any_active_subscription())
@@ -498,8 +498,8 @@ def test_cancel_during_trial_sets_at_period_end(
         canceled_subscription_fake["canceled_at"] = cancel_timestamp
         canceled_subscription_fake["ended_at"] = cancel_timestamp
         subscription_retrieve_mock.return_value = (
-            canceled_subscription_fake
-        )  # retrieve().delete()
+            canceled_subscription_fake  # retrieve().delete()
+        )
 
         self.assertTrue(self.customer.has_active_subscription())
         self.assertTrue(self.customer.has_any_active_subscription())