Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create invoice item #436

Open
wants to merge 24 commits into
base: original
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
a03b657
ideal support; sry no tests yet!
Paul424 Apr 19, 2017
bae60ab
Extend webhooks required for ideal (source status) and add relevant f…
Paul424 Apr 20, 2017
c8acb98
Allow for source in update subscription
Paul424 Apr 21, 2017
c0acca1
Merge branch 'master' of github.com:Paul424/pinax-stripe
Paul424 May 29, 2017
3226b70
Merge branch 'master' of github.com:pinax/pinax-stripe
Paul424 May 29, 2017
8011951
Revert "Allow for source in update subscription"
Paul424 May 29, 2017
b9e56ed
Revert "Extend webhooks required for ideal (source status) and add re…
Paul424 May 29, 2017
810a066
Revert "ideal support; sry no tests yet!"
Paul424 May 29, 2017
4501b0d
Add action to create invoice item
Paul424 May 30, 2017
54bf5c1
Merge branch 'master' of github.com:pinax/pinax-stripe
Paul424 Oct 15, 2017
6c721ea
Merge branch 'master' into create-invoice-item
Paul424 Oct 15, 2017
95145cd
Support billing/due_date on subscription and invoice + support adding…
Paul424 Oct 19, 2017
9e472e9
Update after review
Paul424 Oct 26, 2017
9c1660c
Merge branch 'master' into create-invoice-item
Paul424 Oct 30, 2017
04a6d1c
Merge migrations
Paul424 Oct 30, 2017
3211821
Add tests
Paul424 Oct 31, 2017
975bb9b
Fix issue that subscription is not sync'ed when change made from Stri…
Paul424 Nov 6, 2017
e111401
Merge branch 'master' of github.com:pinax/pinax-stripe
Paul424 Nov 8, 2017
b586d3b
Merge branch 'master' of github.com:pinax/pinax-stripe
Paul424 Dec 5, 2017
027d1e1
Merge branch 'master' into create-invoice-item + fix migrations (0010…
Paul424 Dec 6, 2017
27bd049
Fix default in migration
Paul424 Dec 6, 2017
01c88b6
Fix new tests
Paul424 Dec 6, 2017
0c2ced3
Fix tests
Paul424 Dec 6, 2017
2e9b3ef
Revert fix migrations to enable delivery to master
Paul424 Dec 7, 2017
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions pinax/stripe/actions/invoices.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

s/paid/forgiven/ ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or both?

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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand Down
18 changes: 15 additions & 3 deletions pinax/stripe/actions/subscriptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def cancel(subscription, at_period_end=True):
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

Expand All @@ -35,13 +35,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 data representing the subscription object that was created
"""
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:
Expand Down Expand Up @@ -156,6 +157,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
Expand All @@ -164,7 +170,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

Expand All @@ -175,6 +181,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:
Expand All @@ -188,6 +196,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)
sync_subscription_from_stripe_data(customer, sub)
51 changes: 51 additions & 0 deletions pinax/stripe/migrations/0011_auto_20171019_1321.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
25 changes: 25 additions & 0 deletions pinax/stripe/migrations/0012_auto_20171026_1544.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
16 changes: 16 additions & 0 deletions pinax/stripe/migrations/0014_merge_20171030_1242.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# -*- 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', '0013_auto_20171025_2153'),
('pinax_stripe', '0012_auto_20171026_1544'),
]

operations = [
]
25 changes: 23 additions & 2 deletions pinax/stripe/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,8 @@ class BitcoinReceiver(StripeObject):
class Subscription(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)
application_fee_percent = models.DecimalField(decimal_places=2, max_digits=3, default=None, blank=True, null=True)
cancel_at_period_end = models.BooleanField(default=False)
canceled_at = models.DateTimeField(blank=True, null=True)
current_period_end = models.DateTimeField(blank=True, null=True)
Expand All @@ -260,6 +259,16 @@ class Subscription(StripeObject):
trial_end = models.DateTimeField(blank=True, null=True)
trial_start = models.DateTimeField(blank=True, null=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.Customer.retrieve(self.customer.stripe_id).subscriptions.retrieve(self.stripe_id)
Expand Down Expand Up @@ -306,6 +315,7 @@ class Invoice(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)
Expand All @@ -318,6 +328,17 @@ class Invoice(StripeObject):
date = models.DateTimeField()
webhooks_delivered_at = models.DateTimeField(null=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"
Expand Down
8 changes: 8 additions & 0 deletions pinax/stripe/webhooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,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."
Expand Down