Skip to content
This repository has been archived by the owner on Jan 25, 2018. It is now read-only.

Commit

Permalink
add in one-off transactions with no buyer needed
Browse files Browse the repository at this point in the history
  • Loading branch information
Andy McKay committed Aug 18, 2015
1 parent 6f06208 commit 153d862
Show file tree
Hide file tree
Showing 15 changed files with 376 additions and 14 deletions.
71 changes: 71 additions & 0 deletions docs/topics/braintree.rst
Expand Up @@ -287,6 +287,77 @@ Some information about the subscripton is stored in solitude.
:query provider_id: the plan id for this subscription.
:query seller_product: the primary key of the product.

Sale
----

A sale is a one off payment to call the Braintree Transaction API. For more
information see the `Braintree documentation <https://developers.braintreepayments.com/javascript+python/reference/request/transaction/sale>`_.

This should not be used for subscriptions.

.. http:post:: /braintree/sale/
:<json amount: the amount of the transaction, within the maximum and minimum limits
:<json product_id: the product_id as defined by `payments-config <https://github.com/mozilla/payments-config/>`_.
:<json nonce: (optional) the payment nonce returned by Braintree, used when no payment method is stored.
:<json paymethod: (optional) the URI to `a payment object <payment-methods-label>`_.

.. code-block:: json
{
"mozilla": {
"generic": {
"resource_pk": 1,
"related": null,
"seller_product": "/generic/product/1/",
"currency": "USD",
"uid_pay": null,
"uuid": "",
"uid_support": "test-id",
"relations": [],
"seller": "/generic/seller/1/",
"source": null,
"provider": 4,
"pay_url": null,
"type": 0,
"status": 2,
"buyer": null,
"status_reason": null,
"created": "2015-08-17T19:17:04.296",
"notes": null,
"amount": "5.00",
"carrier": null,
"region": null,
"resource_uri": "/generic/transaction/1/"
},
"braintree": {
"kind": "",
"transaction": "/generic/transaction/1/",
"next_billing_period_amount": null,
"created": "2015-08-17T19:17:04.298",
"paymethod": null,
"counter": 0,
"billing_period_end_date": null,
"modified": "2015-08-17T19:17:04.298",
"next_billing_date": null,
"resource_pk": 1,
"resource_uri": "/braintree/mozilla/transaction/1/",
"billing_period_start_date": null,
"id": 1,
"subscription": null
}
},
"braintree": {}
}
:>json mozilla.generic: the generic transaction object.
:>json mozilla.generic.buyer: this will be the buyer or empty if no buyer is registered.
:>json mozilla.braintree: the braintree transaction object.

Notes:
* either a `nonce` or a `paymethod` must exist, but not both

Webhook
-------

Expand Down
68 changes: 68 additions & 0 deletions lib/brains/forms.py
@@ -1,3 +1,5 @@
from decimal import Decimal

from django import forms
from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
Expand Down Expand Up @@ -188,6 +190,72 @@ def clean(self):
return self.cleaned_data


class SaleForm(forms.Form):
amount = forms.DecimalField(
max_value=Decimal(settings.BRAINTREE_MAX_AMOUNT),
min_value=Decimal(settings.BRAINTREE_MIN_AMOUNT))
# This will be populated by the paymethod.
buyer = None
nonce = forms.CharField(max_length=255, required=False)
paymethod = PathRelatedFormField(
view_name='braintree:mozilla:paymethod-detail',
queryset=BraintreePaymentMethod.objects.filter(),
required=False,
allow_null=True)
# Seller and seller_product are set by looking up the
# product_id inside payments-config.
product_id = forms.CharField()
seller = None
seller_product = None

def clean_product_id(self):
product_id = self.cleaned_data.get('product_id')

try:
self.seller_product = (
SellerProduct.objects.get(public_id=product_id))
except SellerProduct.DoesNotExist:
raise forms.ValidationError(
'Product does not exist: {}'.format(product_id))

return product_id

def clean_paymethod(self):
paymethod = self.cleaned_data['paymethod']
if paymethod:
self.buyer = paymethod.braintree_buyer.buyer
return paymethod

def clean(self):
nonce = self.cleaned_data.get('nonce')
paymethod = self.cleaned_data.get('paymethod')

if nonce and paymethod:
raise forms.ValidationError(
'Cannot set both paymethod and nonce', code='invalid')

if not nonce and not paymethod:
raise forms.ValidationError(
'Either nonce or paymethod must be set', code='invalid')

return self.cleaned_data

@property
def braintree_data(self):
data = {
'amount': self.cleaned_data['amount'],
'options': {
'submit_for_settlement': True
}
}
if self.cleaned_data.get('paymethod'):
data['payment_method_token'] = (
self.cleaned_data['paymethod'].provider_id)
elif self.cleaned_data.get('nonce'):
data['payment_method_nonce'] = self.cleaned_data['nonce']
return data


class SubscriptionUpdateForm(forms.Form):
paymethod = PathRelatedFormField(
view_name='braintree:mozilla:paymethod-detail',
Expand Down
13 changes: 7 additions & 6 deletions lib/brains/models.py
Expand Up @@ -139,17 +139,18 @@ class BraintreeTransaction(Model):
A holder for Braintree specific information about the transaction since
some of this is not stored in the generic transaction.
"""
# There isn't enough information on the Transaction.
paymethod = models.ForeignKey(BraintreePaymentMethod, db_index=True)
subscription = models.ForeignKey(BraintreeSubscription, db_index=True)
paymethod = models.ForeignKey(
BraintreePaymentMethod, db_index=True, null=True, blank=True)
subscription = models.ForeignKey(
BraintreeSubscription, db_index=True, null=True, blank=True)
transaction = models.OneToOneField(
'transactions.Transaction', db_index=True)

# Data from Braintree that we'd like to store and re-use
billing_period_end_date = models.DateTimeField()
billing_period_start_date = models.DateTimeField()
billing_period_end_date = models.DateTimeField(blank=True, null=True)
billing_period_start_date = models.DateTimeField(blank=True, null=True)
kind = models.CharField(max_length=255)
next_billing_date = models.DateTimeField()
next_billing_date = models.DateTimeField(blank=True, null=True)
next_billing_period_amount = models.DecimalField(
max_digits=9, decimal_places=2, blank=True, null=True)

Expand Down
60 changes: 58 additions & 2 deletions lib/brains/tests/test_forms.py
Expand Up @@ -3,9 +3,10 @@
from nose.tools import eq_

from lib.brains.forms import (
SubscriptionForm, SubscriptionCancelForm, SubscriptionUpdateForm,
SaleForm, SubscriptionCancelForm, SubscriptionForm, SubscriptionUpdateForm,
WebhookParseForm, WebhookVerifyForm)
from lib.brains.tests.base import BraintreeTest
from lib.brains.tests.base import (
BraintreeTest, create_braintree_buyer, create_method, create_seller)


@override_settings(BRAINTREE_PROXY='http://m.o')
Expand Down Expand Up @@ -84,3 +85,58 @@ def test_missing_cancel_params(self):
errors = form.errors
# Make sure empty params are caught.
assert 'subscription' in errors, errors.as_text()


class TestSaleForm(BraintreeTest):

def process(self, data):
form = SaleForm(data)
form.is_valid()
return form, form.errors

def test_no_paymethod_nonce(self):
form, errors = self.process({})
assert '__all__' in errors, errors.as_text()

def test_both_paymethod_nonce(self):
method = create_method(create_braintree_buyer()[1])
form, errors = self.process({
'nonce': 'abc',
'paymethod': method.get_uri()
})
assert '__all__' in errors, errors.as_text()

def test_paymethod_does_not_exist(self):
form, errors = self.process({'paymethod': '/nope'})
assert 'paymethod' in errors, errors.as_text()

def test_no_product(self):
form, errors = self.process({
'amount': '5',
'nonce': 'noncey',
'product_id': 'nope',
})
assert 'product_id' in errors, errors.as_text()

def test_ok(self):
seller, seller_product = create_seller()
form, errors = self.process({
'amount': '5',
'nonce': 'noncey',
'product_id': seller_product.public_id
})
assert not errors, errors.as_text()
assert 'payment_method_token' not in form.braintree_data
eq_(form.braintree_data['payment_method_nonce'], 'noncey')

def test_ok_method(self):
seller, seller_product = create_seller()
method = create_method(create_braintree_buyer()[1])
form, errors = self.process({
'amount': '5',
'paymethod': method.get_uri(),
'product_id': seller_product.public_id
})
assert not errors, errors.as_text()
assert 'payment_method_nonce' not in form.braintree_data
eq_(form.braintree_data['payment_method_token'], method.provider_id)
4 changes: 2 additions & 2 deletions lib/brains/tests/test_models.py
Expand Up @@ -2,10 +2,10 @@
from braintree.subscription_gateway import SubscriptionGateway
from nose.tools import eq_

from lib.brains.tests.base import create_subscription, BraintreeTest
from lib.brains.tests.base import BraintreeTest, create_subscription
from lib.brains.tests.test_paymethod import successful_method
from lib.brains.tests.test_subscription import (
create_method_all, successful_subscription)
from lib.brains.tests.test_paymethod import successful_method


class TestClose(BraintreeTest):
Expand Down
97 changes: 97 additions & 0 deletions lib/brains/tests/test_sale.py
@@ -0,0 +1,97 @@
from datetime import datetime
from decimal import Decimal

from django.core.urlresolvers import reverse

import mock
from braintree.successful_result import SuccessfulResult
from braintree.transaction import Transaction
from braintree.transaction_gateway import TransactionGateway
from nose.tools import eq_

from lib.brains.tests.base import (
BraintreeTest, create_braintree_buyer, create_method, create_seller)


def transaction(**kw):
transaction = {
'amount': '5.00',
'card_type': 'visa',
'created_at': datetime.now(),
'id': 'test-id',
'last_4': '7890',
'tax_amount': '0.00',
'token': 'da-token',
'updated_at': datetime.now(),
'currency_iso_code': 'USD',
}
transaction.update(**kw)
return Transaction(None, transaction)


def successful_method(**kw):
return SuccessfulResult({'transaction': transaction(**kw)})


class TestSale(BraintreeTest):
gateways = {'sale': TransactionGateway}

def setUp(self):
super(TestSale, self).setUp()
self.url = reverse('braintree:sale')

def test_allowed(self):
self.allowed_verbs(self.url, ['post'])

def test_ok(self):
self.mocks['sale'].create.return_value = successful_method()
seller, seller_product = create_seller()
res = self.client.post(
self.url,
data={'amount': '5', 'nonce': 'some-nonce', 'product_id': 'brick'}
)

self.mocks['sale'].create.assert_called_with({
'amount': Decimal('5'),
'type': 'sale',
'options': {'submit_for_settlement': True},
'payment_method_nonce': u'some-nonce'}
)
eq_(res.status_code, 200)
generic = res.json['mozilla']['generic']
eq_(generic['buyer'], None)
eq_(generic['uid_support'], 'test-id')
eq_(generic['seller'], seller.get_uri())
eq_(generic['seller_product'], seller_product.get_uri())

def test_failure(self):
res = self.client.post(
self.url,
data={'amount': '5', 'nonce': 'some-nonce', 'product_id': 'brick'}
)
eq_(res.status_code, 422)

def test_pay_method(self):
self.mocks['sale'].create.return_value = successful_method()
seller, seller_product = create_seller()
method = create_method(create_braintree_buyer()[1])
res = self.client.post(
self.url,
data={
'amount': '5',
'paymethod': method.get_uri(),
'product_id': 'brick'
}
)

self.mocks['sale'].create.assert_called_with({
'amount': Decimal('5'),
'type': 'sale',
'options': {'submit_for_settlement': True},
'payment_method_token': mock.ANY}
)
eq_(res.status_code, 200)
generic = res.json['mozilla']['generic']
eq_(generic['buyer'], method.braintree_buyer.buyer.get_uri())
braintree = res.json['mozilla']['braintree']
eq_(braintree['paymethod'], method.get_uri())
1 change: 0 additions & 1 deletion lib/brains/tests/test_webhook.py
Expand Up @@ -16,7 +16,6 @@
from lib.brains.webhooks import Processor
from lib.transactions import constants
from lib.transactions.models import Transaction

from solitude.utils import shorter


Expand Down
1 change: 1 addition & 0 deletions lib/brains/urls.py
Expand Up @@ -20,6 +20,7 @@
url(r'^customer/$', 'customer.create', name='customer'),
url(r'^paymethod/$', 'paymethod.create', name='paymethod'),
url(r'^paymethod/delete/$', 'paymethod.delete', name='paymethod.delete'),
url(r'^sale/$', 'sale.create', name='sale'),
url(r'^subscription/$', 'subscription.create', name='subscription'),
url(r'^subscription/cancel/$', 'subscription.cancel',
name='subscription.cancel'),
Expand Down

0 comments on commit 153d862

Please sign in to comment.