diff --git a/pinax/stripe/actions/invoices.py b/pinax/stripe/actions/invoices.py index 0b71de610..5abbf6b3f 100644 --- a/pinax/stripe/actions/invoices.py +++ b/pinax/stripe/actions/invoices.py @@ -63,6 +63,110 @@ def pay(invoice, send_receipt=True): return False +def mark_paid(invoice): + """ + Sometimes customers may want to pay with payment methods outside of Stripe, such as check. + In these situations, Stripe still allows you to keep track of the payment status of your invoices. + Once you receive an invoice payment from a customer outside of Stripe, you can manually + mark their invoices as paid. + + Args: + invoice: the invoice object to close + """ + if not invoice.paid: + stripe_invoice = invoice.stripe_invoice + stripe_invoice.paid = True + sync_invoice_from_stripe_data(stripe_invoice.save()) + + +def forgive(invoice): + """ + Forgiving an invoice instructs us to update the subscription status as if the invoice were + successfully paid. Once an invoice has been forgiven, it cannot be unforgiven or reopened. + + Args: + invoice: the invoice object to close + """ + if not invoice.paid: + stripe_invoice = invoice.stripe_invoice + stripe_invoice.forgiven = True + sync_invoice_from_stripe_data(stripe_invoice.save()) + + +def close(invoice): + """ + Cause an invoice to be closed; This prevents Stripe from automatically charging your customer for the invoice amount. + + Args: + invoice: the invoice object to close + """ + if not invoice.closed: + stripe_invoice = invoice.stripe_invoice + stripe_invoice.closed = True + sync_invoice_from_stripe_data(stripe_invoice.save()) + + +def reopen(invoice): + """ + (re)-open a closed invoice (which is hold for review) + + Args: + invoice: the invoice object to open + """ + if invoice.closed: + stripe_invoice = invoice.stripe_invoice + stripe_invoice.closed = False + sync_invoice_from_stripe_data(stripe_invoice.save()) + + +def create_invoice_item(customer, invoice, subscription, amount, currency, description, metadata=None): + """ + :param customer: The pinax-stripe Customer + :param invoice: + :param subscription: + :param amount: + :param currency: + :param description: + :param metadata: Any optional metadata that is attached to the invoice item + :return: + """ + stripe_invoice_item = stripe.InvoiceItem.create( + customer=customer.stripe_id, + amount=utils.convert_amount_for_api(amount, currency), + currency=currency, + description=description, + invoice=invoice.stripe_id, + discountable=True, + metadata=metadata, + subscription=subscription.stripe_id, + ) + + period_end = utils.convert_tstamp(stripe_invoice_item["period"], "end") + period_start = utils.convert_tstamp(stripe_invoice_item["period"], "start") + + # We can safely take the plan from the subscription here because we are creating a new invoice item for this new invoice that is applicable + # to the current subscription/current plan. + plan = subscription.plan + + defaults = dict( + amount=utils.convert_amount_for_db(stripe_invoice_item["amount"], stripe_invoice_item["currency"]), + currency=stripe_invoice_item["currency"], + proration=stripe_invoice_item["proration"], + description=description, + line_type=stripe_invoice_item["object"], + plan=plan, + period_start=period_start, + period_end=period_end, + quantity=stripe_invoice_item.get("quantity"), + subscription=subscription, + ) + inv_item, inv_item_created = invoice.items.get_or_create( + stripe_id=stripe_invoice_item["id"], + defaults=defaults + ) + return utils.update_with_defaults(inv_item, defaults, inv_item_created) + + def sync_invoice_from_stripe_data(stripe_invoice, send_receipt=settings.PINAX_STRIPE_SEND_EMAIL_RECEIPTS): """ Synchronizes a local invoice with data from the Stripe API @@ -97,6 +201,7 @@ def sync_invoice_from_stripe_data(stripe_invoice, send_receipt=settings.PINAX_ST attempt_count=stripe_invoice["attempt_count"], amount_due=utils.convert_amount_for_db(stripe_invoice["amount_due"], stripe_invoice["currency"]), closed=stripe_invoice["closed"], + forgiven=stripe_invoice["forgiven"], paid=stripe_invoice["paid"], period_end=period_end, period_start=period_start, @@ -109,7 +214,13 @@ def sync_invoice_from_stripe_data(stripe_invoice, send_receipt=settings.PINAX_ST charge=charge, subscription=subscription, receipt_number=stripe_invoice["receipt_number"] or "", + metadata=stripe_invoice["metadata"] ) + if "billing" in stripe_invoice: + defaults.update({ + "billing": stripe_invoice["billing"], + "due_date": utils.convert_tstamp(stripe_invoice, "due_date") if stripe_invoice.get("due_date", None) is not None else None + }) invoice, created = models.Invoice.objects.get_or_create( stripe_id=stripe_invoice["id"], defaults=defaults @@ -135,6 +246,13 @@ def sync_invoices_for_customer(customer): sync_invoice_from_stripe_data(invoice, send_receipt=False) +def sync_invoice(invoice): + """ + Syncronizes a specific invoice + """ + sync_invoice_from_stripe_data(invoice.stripe_invoice, send_receipt=False) + + def sync_invoice_items(invoice, items): """ Synchronizes all invoice line items for a particular invoice diff --git a/pinax/stripe/actions/subscriptions.py b/pinax/stripe/actions/subscriptions.py index 1a2e77853..8c5fed758 100644 --- a/pinax/stripe/actions/subscriptions.py +++ b/pinax/stripe/actions/subscriptions.py @@ -25,7 +25,7 @@ def cancel(subscription, at_period_end=True): return sync_subscription_from_stripe_data(subscription.customer, sub) -def create(customer, plan, quantity=None, trial_days=None, token=None, coupon=None, tax_percent=None): +def create(customer, plan, quantity=None, trial_days=None, token=None, coupon=None, tax_percent=None, **kwargs): """ Creates a subscription for the given customer @@ -40,13 +40,14 @@ def create(customer, plan, quantity=None, trial_days=None, token=None, coupon=No will be used coupon: if provided, a coupon to apply towards the subscription tax_percent: if provided, add percentage as tax + kwargs: any additional arguments are passed, easy for new features Returns: the pinax.stripe.models.Subscription object (created or updated) """ quantity = hooks.hookset.adjust_subscription_quantity(customer=customer, plan=plan, quantity=quantity) - subscription_params = {} + subscription_params = kwargs if trial_days: subscription_params["trial_end"] = datetime.datetime.utcnow() + datetime.timedelta(days=trial_days) if token: @@ -162,6 +163,11 @@ def sync_subscription_from_stripe_data(customer, subscription): trial_start=utils.convert_tstamp(subscription["trial_start"]) if subscription["trial_start"] else None, trial_end=utils.convert_tstamp(subscription["trial_end"]) if subscription["trial_end"] else None ) + if "billing" in subscription: + defaults.update({ + "billing": subscription["billing"], + "days_until_due": subscription["days_until_due"] if "days_until_due" in subscription else None, + }) sub, created = models.Subscription.objects.get_or_create( stripe_id=subscription["id"], defaults=defaults @@ -170,7 +176,7 @@ def sync_subscription_from_stripe_data(customer, subscription): return sub -def update(subscription, plan=None, quantity=None, prorate=True, coupon=None, charge_immediately=False): +def update(subscription, plan=None, quantity=None, prorate=True, coupon=None, charge_immediately=False, billing=None, days_until_due=None): """ Updates a subscription @@ -181,6 +187,8 @@ def update(subscription, plan=None, quantity=None, prorate=True, coupon=None, ch prorate: optionally, if the subscription should be prorated or not coupon: optionally, a coupon to apply to the subscription charge_immediately: optionally, whether or not to charge immediately + billing: Either charge_automatically or send_invoice + days_until_due: Number of days a customer has to pay invoices generated by this subscription. Only valid for subscriptions where billing=send_invoice. """ stripe_subscription = subscription.stripe_subscription if plan: @@ -194,6 +202,10 @@ def update(subscription, plan=None, quantity=None, prorate=True, coupon=None, ch if charge_immediately: if stripe_subscription.trial_end is not None and utils.convert_tstamp(stripe_subscription.trial_end) > timezone.now(): stripe_subscription.trial_end = "now" + if billing is not None: + stripe_subscription.billing = billing + if days_until_due is not None: + stripe_subscription.days_until_due = days_until_due sub = stripe_subscription.save() customer = models.Customer.objects.get(pk=subscription.customer.pk) return sync_subscription_from_stripe_data(customer, sub) diff --git a/pinax/stripe/migrations/0011_auto_20171019_1321.py b/pinax/stripe/migrations/0011_auto_20171019_1321.py new file mode 100644 index 000000000..e301a231b --- /dev/null +++ b/pinax/stripe/migrations/0011_auto_20171019_1321.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-10-19 13:21 +from __future__ import unicode_literals + +from django.db import migrations, models +import jsonfield.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0010_connect'), + ] + + operations = [ + migrations.AddField( + model_name='invoice', + name='billing', + field=models.CharField(default='charge_automatically', max_length=32), + ), + migrations.AddField( + model_name='invoice', + name='due_date', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='invoice', + name='forgiven', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='invoice', + name='metadata', + field=jsonfield.fields.JSONField(null=True), + ), + migrations.AddField( + model_name='subscription', + name='billing', + field=models.CharField(default='charge_automatically', max_length=32), + ), + migrations.AddField( + model_name='subscription', + name='days_until_due', + field=models.IntegerField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='subscription', + name='application_fee_percent', + field=models.DecimalField(blank=True, decimal_places=2, default=None, max_digits=3, null=True), + ), + ] diff --git a/pinax/stripe/migrations/0012_auto_20171026_1544.py b/pinax/stripe/migrations/0012_auto_20171026_1544.py new file mode 100644 index 000000000..408a70c86 --- /dev/null +++ b/pinax/stripe/migrations/0012_auto_20171026_1544.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-10-26 15:44 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0011_auto_20171019_1321'), + ] + + operations = [ + migrations.AlterField( + model_name='invoice', + name='billing', + field=models.CharField(choices=[('charge_automatically', 'Charge automatically'), ('send_invoice', 'Send invoice')], default='charge_automatically', max_length=32), + ), + migrations.AlterField( + model_name='subscription', + name='billing', + field=models.CharField(choices=[('charge_automatically', 'Charge automatically'), ('send_invoice', 'Send invoice')], default='charge_automatically', max_length=32), + ), + ] diff --git a/pinax/stripe/migrations/0014_merge_20171030_1242.py b/pinax/stripe/migrations/0014_merge_20171030_1242.py new file mode 100644 index 000000000..146103f5b --- /dev/null +++ b/pinax/stripe/migrations/0014_merge_20171030_1242.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-10-30 12:42 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0012_auto_20171026_1544'), + ] + + operations = [ + ] diff --git a/pinax/stripe/migrations/0015_merge_20171205_2228.py b/pinax/stripe/migrations/0015_merge_20171205_2228.py new file mode 100644 index 000000000..b0f834b05 --- /dev/null +++ b/pinax/stripe/migrations/0015_merge_20171205_2228.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.7 on 2017-12-05 22:28 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pinax_stripe', '0013_charge_outcome'), + ('pinax_stripe', '0014_merge_20171030_1242'), + ] + + operations = [ + ] diff --git a/pinax/stripe/models.py b/pinax/stripe/models.py index 5d619441d..be9f5ef47 100644 --- a/pinax/stripe/models.py +++ b/pinax/stripe/models.py @@ -351,7 +351,6 @@ class BitcoinReceiver(StripeObject): class Subscription(StripeAccountFromCustomerMixin, StripeObject): STATUS_CURRENT = ["trialing", "active"] - customer = models.ForeignKey(Customer, on_delete=models.CASCADE) application_fee_percent = models.DecimalField(decimal_places=2, max_digits=3, default=None, null=True, blank=True) cancel_at_period_end = models.BooleanField(default=False) @@ -366,6 +365,16 @@ class Subscription(StripeAccountFromCustomerMixin, StripeObject): trial_end = models.DateTimeField(null=True, blank=True) trial_start = models.DateTimeField(null=True, blank=True) + BILLING_CHARGE_AUTOMATICALLY = "charge_automatically" + BILLING_SEND_INVOICE = "send_invoice" + BILLING_CHOICES = ( + (BILLING_CHARGE_AUTOMATICALLY, "Charge automatically"), + (BILLING_SEND_INVOICE, "Send invoice"), + ) + billing = models.CharField(max_length=32, choices=BILLING_CHOICES, default=BILLING_CHARGE_AUTOMATICALLY) + + days_until_due = models.IntegerField(default=None, blank=True, null=True) + @property def stripe_subscription(self): return stripe.Subscription.retrieve(self.stripe_id, stripe_account=self.stripe_account_stripe_id) @@ -412,6 +421,7 @@ class Invoice(StripeAccountFromCustomerMixin, StripeObject): statement_descriptor = models.TextField(blank=True) currency = models.CharField(max_length=10, default="usd") closed = models.BooleanField(default=False) + forgiven = models.BooleanField(default=False) description = models.TextField(blank=True) paid = models.BooleanField(default=False) receipt_number = models.TextField(blank=True) @@ -424,6 +434,17 @@ class Invoice(StripeAccountFromCustomerMixin, StripeObject): date = models.DateTimeField() webhooks_delivered_at = models.DateTimeField(null=True, blank=True) + BILLING_CHARGE_AUTOMATICALLY = "charge_automatically" + BILLING_SEND_INVOICE = "send_invoice" + BILLING_CHOICES = ( + (BILLING_CHARGE_AUTOMATICALLY, "Charge automatically"), + (BILLING_SEND_INVOICE, "Send invoice"), + ) + billing = models.CharField(max_length=32, choices=BILLING_CHOICES, default=BILLING_CHARGE_AUTOMATICALLY) + + due_date = models.DateTimeField(null=True, blank=True) + metadata = JSONField(null=True) + @property def status(self): return "Paid" if self.paid else "Open" diff --git a/pinax/stripe/tests/test_actions.py b/pinax/stripe/tests/test_actions.py index 11ed7ce27..e5e952265 100644 --- a/pinax/stripe/tests/test_actions.py +++ b/pinax/stripe/tests/test_actions.py @@ -715,6 +715,38 @@ def test_pay_invoice_closed(self): self.assertFalse(invoices.pay(invoice)) self.assertFalse(invoice.stripe_invoice.pay.called) + @patch("pinax.stripe.actions.invoices.sync_invoice_from_stripe_data") + def test_mark_paid(self, SyncMock): + invoice = Mock() + invoice.paid = False + invoices.mark_paid(invoice) + self.assertTrue(invoice.stripe_invoice.paid) + self.assertTrue(SyncMock.called) + + @patch("pinax.stripe.actions.invoices.sync_invoice_from_stripe_data") + def test_forgive(self, SyncMock): + invoice = Mock() + invoice.paid = False + invoices.forgive(invoice) + self.assertTrue(invoice.stripe_invoice.forgiven) + self.assertTrue(SyncMock.called) + + @patch("pinax.stripe.actions.invoices.sync_invoice_from_stripe_data") + def test_close(self, SyncMock): + invoice = Mock() + invoice.closed = False + invoices.close(invoice) + self.assertTrue(invoice.stripe_invoice.closed) + self.assertTrue(SyncMock.called) + + @patch("pinax.stripe.actions.invoices.sync_invoice_from_stripe_data") + def test_reopen(self, SyncMock): + invoice = Mock() + invoice.closed = True + invoices.reopen(invoice) + self.assertFalse(invoice.stripe_invoice.closed) + self.assertTrue(SyncMock.called) + @patch("stripe.Invoice.create") def test_create_and_pay(self, CreateMock): invoice = CreateMock() @@ -742,6 +774,41 @@ def test_create_and_pay_invalid_request_error_on_create(self, CreateMock): CreateMock.side_effect = stripe.InvalidRequestError("Bad", "error") self.assertFalse(invoices.create_and_pay(Mock())) + @patch("stripe.InvoiceItem.create") + def test_create_invoice_item(self, CreateMock): + customer = Mock() + invoice = Mock() + invoice.stripe_id = "my_id" + invoice.items = Mock() + invoice.items.get_or_create.return_value = None, True + subscription = Mock() + CreateMock.return_value = { + "id": "my_id", + "object": "invoiceitem", + "amount": 100, + "currency": "eur", + "customer": customer, + "date": 1496069416, + "description": "my_description", + "discountable": True, + "invoice": invoice, + "livemode": False, + "metadata": { + + }, + "period": { + "start": 1496069416, + "end": 1496069416 + }, + "plan": None, + "proration": False, + "quantity": None, + "subscription": subscription, + } + self.assertIsNone(invoices.create_invoice_item(customer, invoice, subscription, 100, "eur", "my_foo", metadata={})) + self.assertTrue(CreateMock.called) + self.assertTrue(invoice.items.get_or_create.called) + class RefundsTests(TestCase): diff --git a/pinax/stripe/webhooks.py b/pinax/stripe/webhooks.py index c76612a20..91bd3f62a 100644 --- a/pinax/stripe/webhooks.py +++ b/pinax/stripe/webhooks.py @@ -426,6 +426,14 @@ def process_webhook(self): ) +class InvoiceUpcomingWebhook(Webhook): + """ + Notice on invoice.upcoming, the invoice has not been created yet, and therefor we cannot sync it (the payload does not have an id) and so it does not inherit from InvoiceWebhook + """ + name = "invoice.upcoming" + description = "Occurs X number of days before a subscription is scheduled to create an invoice that is charged automatically, where X is determined by your subscriptions settings." + + class InvoiceCreatedWebhook(InvoiceWebhook): name = "invoice.created" description = "Occurs whenever a new invoice is created. If you are using webhooks, Stripe will wait one hour after they have all succeeded to attempt to pay the invoice; the only exception here is on the first invoice, which gets created and paid immediately when you subscribe a customer to a plan. If your webhooks do not all respond successfully, Stripe will continue retrying the webhooks every hour and will not attempt to pay the invoice. After 3 days, Stripe will attempt to pay the invoice regardless of whether or not your webhooks have succeeded. See how to respond to a webhook."