source: main/waeup.ikoba/branches/uli-payments/src/waeup/ikoba/payments/paypal.py @ 12691

Last change on this file since 12691 was 12498, checked in by uli, 10 years ago

Fix tests and bugs revealed by tests.

  • Property svn:keywords set to Id
File size: 36.8 KB
Line 
1## $Id: paypal.py 12498 2015-01-20 06:53:55Z uli $
2##
3## Copyright (C) 2014 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""Support for PayPal payments.
19"""
20import decimal
21import grok
22import ConfigParser
23import inspect
24import paypalrestsdk
25import re
26import uuid
27from zope import schema
28from zope.component import queryUtility
29from zope.container.interfaces import IContainer
30from zope.interface import Interface, Attribute
31from waeup.ikoba.interfaces import MessageFactory as _
32from waeup.ikoba.interfaces import IPayPalConfig, SimpleIkobaVocabulary
33from waeup.ikoba.utils.helpers import attrs_to_fields
34from waeup.ikoba.payments.interfaces import (
35    IPayment, IPaymentGatewayService, IPayer, IPaymentItem, IPayee,
36    )
37from waeup.ikoba.payments.paypal_countries import COUNTRIES_VOCAB
38from waeup.ikoba.payments.paypal_currencies import CURRENCIES_VOCAB
39
40#: Intents allowed for paypal based payments
41PAYMENT_INTENTS = ('sale', 'authorize', 'order')
42
43#: Payment methods allowed by PayPal
44PAYMENT_METHODS = ('credit_card', 'paypal')
45
46#: Payer status set/accepted by PayPal
47PAYER_STATUS = ('VERIFIED', 'UNVERIFIED')
48
49#: Tax ID types accepted by PayPal (yes, this list is complete)
50TAX_ID_TYPES = ('BR_CPF', 'BR_CNPJ')
51
52#: Address types accepted by PayPal
53ADDRESS_TYPES = ('residential', 'business', 'mailbox')
54
55#: A vocabulary with address types
56ADDRESS_TYPES_VOCAB = SimpleIkobaVocabulary(
57    *[(_(x), x) for x in ADDRESS_TYPES])
58
59#: Credit card types accepted by PayPal
60CREDIT_CARD_TYPES = ('visa', 'mastercard', 'discover', 'amex')
61
62CREDIT_CARD_TYPES_VOCAB = SimpleIkobaVocabulary(
63    *[(_(x), x) for x in CREDIT_CARD_TYPES])
64
65#: Credit card status accepted by PayPal
66CREDIT_CARD_STATUS = ('expired', 'ok')
67
68CREDIT_CARD_STATUS_VOCAB = SimpleIkobaVocabulary(
69    *[(_(x), x) for x in CREDIT_CARD_STATUS])
70
71#: Stock keeping units we support.
72STOCK_KEEPING_UNITS = {
73    "pcs": _("pieces"),
74    "license": _("license"),
75    }
76
77STOCK_KEEPING_UNITS_VOCAB = SimpleIkobaVocabulary(
78    *[(value, key) for key, value in STOCK_KEEPING_UNITS.items()])
79
80#: Payment methods (flags) used by PayPal
81PAYMENT_OPTION_METHODS = ('INSTANT_FUNDING_SOURCE', )
82PAYMENT_OPTION_METHODS_VOCAB = SimpleIkobaVocabulary(
83    *[(_(x), x) for x in PAYMENT_OPTION_METHODS])
84
85
86def to_dict(obj, name_map={}):
87    """Turn `obj` into some dict representation.
88    """
89    result = dict()
90    for name in dir(obj):
91        if name.startswith('_'):
92            continue
93        value = getattr(obj, name)
94        if value is None:
95            continue
96        if inspect.ismethod(value):
97            continue
98        if hasattr(value, 'to_dict'):
99            value = value.to_dict()
100        elif isinstance(value, list):
101            value = [x.to_dict() for x in value]
102        elif isinstance(value, decimal.Decimal):
103            value = u"%.2f" % (value, )
104        else:
105            value = unicode(value)
106        name = name_map.get(name, name)
107        result[name] = value
108    return result
109
110
111def parse_paypal_config(path):
112    """Get paypal credentials from config file.
113
114    Returns a dict with following keys set:
115
116      ``client_id``, ``client_secret``, ``mode``.
117
118    Please note, that values might be `None`.
119    """
120    parser = ConfigParser.SafeConfigParser(
121        {'id': None, 'secret': None, 'mode': 'sandbox', }
122        )
123    parser.readfp(open(path))
124    return {
125        'client_id': parser.get('rest-client', 'id', None),
126        'client_secret': parser.get('rest-client', 'secret', None),
127        'mode': parser.get('rest-client', 'mode', 'sandbox')
128        }
129
130
131def get_paypal_config_file_path():
132    """Get the path of the configuration file (if any).
133
134    If no such file is registered, we get `None`.
135    """
136    util = queryUtility(IPayPalConfig)
137    if util is None:
138        return None
139    return util['path']
140
141
142def configure_sdk(path):
143    """Configure the PayPal SDK with values from config in `path`.
144
145    Parse paypal configuration from file in `path` and set
146    configuration in SDK accordingly.
147
148    Returns a dict with values from config file and path set.
149
150    This function will normally be called during start-up and only
151    this one time.
152
153    It is neccessary to authorize later calls to other part of the
154    PayPal API.
155    """
156    conf = parse_paypal_config(path)
157    paypalrestsdk.configure({
158        'mode': conf['mode'],
159        'client_id': conf['client_id'],
160        'client_secret': conf['client_secret'],
161        })
162    conf['path'] = path
163    return conf
164
165
166def get_access_token():
167    """Get an access token for further calls.
168    """
169    conf = parse_paypal_config(get_paypal_config_file_path())
170    api = paypalrestsdk.set_config(
171        mode=conf['mode'],  # sandbox or live
172        client_id=conf['client_id'],
173        client_secret=conf['client_secret'],
174        )
175    return api.get_access_token()
176
177
178class Payer(object):
179    """A payer as required in paypal payments.
180
181    According to Paypal docs:
182
183    `payment_method` must be one of ``'paypal'`` or ``'credit_card'``
184    as stored in `PAYMENT_METHODS`.
185
186    `funding_instruments` is a list of `FundingInstrument` objects. I
187    think the list must be empty for ``paypal`` payments and must
188    contain at least one entry for ``credit_card`` payments.
189
190    `payer_info` must be provided for ``paypal`` payments and might be
191    provided for ``credit_card`` payments. It's a `PayerInfo` object.
192
193    `status` reflects the payer's PayPal account status and is
194    currently supported for ``paypal`` payments. Allowed values are
195    ``'VERIFIED'`` and ``'UNVERIFIED'`` as stored in `PAYER_STATUS`.
196    """
197    def __init__(self, payment_method, funding_instruments=[],
198                 payer_info=None, status=None):
199        if payment_method not in PAYMENT_METHODS:
200            raise ValueError(
201                "Invalid payment method: use one of %s" %
202                (PAYMENT_METHODS, )
203                )
204        if status and status not in PAYER_STATUS:
205            raise ValueError(
206                "Invalid status: use one of %s" % (PAYER_STATUS, )
207                )
208        self.payment_method = payment_method
209        self.funding_instruments = funding_instruments
210        self.payer_info = payer_info
211        self.status = status
212
213
214class PayerInfo(object):
215    """Payer infos as required by Paypal payers.
216
217    Normally used with a `Payer` instance (which in turn is part of a
218    `payment`).
219
220    According to PayPal docs:
221
222    Pre-filled by PayPal when `payment_method` is ``'paypal'``.
223
224    `email`: 127 chars max. I don't think, this value is pre-filled.
225
226    `first_name`: First name of payer. Assigned by PayPal.
227
228    `last_naem`: Last name of payer. Assigned by PayPal.
229
230    `payer_id`: Payer ID as assigned by PayPal. Do not mix up with any
231    Ikoba Payer IDs.
232
233    `phone`: Phone number representing the payer. 20 chars max.
234
235    `shipping_address`: a shipping address object of payer. Assigned
236    by PayPal.
237
238    `tax_id_type`: Payer's tax ID type. Allowed values: ``'BR_CPF'``,
239    ``'BR_CNPJ'`` (I have not the slightest clue what that means).
240    Supported (PayPal-wise) with ``paypal`` payment method only (not
241    with ``credit_card``).
242
243    `tax_id`: Payer's tax ID. Here the same as for `tax_id_type`
244    applies (except that also other values are accepted).
245
246    By default all values are set to the empty string and shipping
247    address to `None`.
248
249    See also: :class:`Payer`
250    """
251    def __init__(self, email='', first_name='', last_name='',
252                 payer_id='', phone='', shipping_address=None,
253                 tax_id_type='', tax_id=''):
254        if tax_id_type and tax_id_type not in TAX_ID_TYPES:
255            raise ValueError(
256                "Invalid tax id type: use one of %s" %
257                (TAX_ID_TYPES, )
258                )
259        self.email = email
260        self.first_name = first_name
261        self.last_name = last_name
262        self.payer_id = payer_id
263        self.phone = phone
264        self.shipping_address = shipping_address
265        self.tax_id_type = tax_id_type
266        self.tax_id = tax_id
267
268
269class FundingInstrument(object):
270    """Representation of a payer's funding instrument as required by PayPal.
271
272    Represents always a credit card. Either by a complete set of
273    credit card infos or by a credit card token, which contains only a
274    limited set of credit card data and represents a full set stored
275    in PayPal vault.
276    """
277
278    def __init__(self, credit_card=None, credit_card_token=None):
279        if credit_card is None and credit_card_token is None:
280            raise ValueError(
281                "Must provide credit card data or a token")
282        if credit_card is not None and credit_card_token is not None:
283            raise ValueError(
284                "Must provide credit card data *or* a token, not both.")
285        self.credit_card = credit_card
286        self.credit_card_token = credit_card_token
287
288    def to_dict(self):
289        return to_dict(self)
290
291
292class ICreditCard(Interface):
293    """A credit card (full data set) as accepted by PayPal.
294    """
295
296    paypal_id = schema.TextLine(
297        title=u'PayPal ID',
298        description=u'ID of the credit card provided by PayPal.',
299        required=False,
300        )
301
302    external_customer_id = schema.TextLine(
303        title=u'Payer ID',
304        description=(u'A unique identifier for the credit card. This '
305                     u'identifier is provided by Ikoba and must be set '
306                     u'with all future transactions, once put into '
307                     u'PayPal vault.'),
308        required=True,
309        )
310
311    number = schema.TextLine(
312        title=u'Credit Card Number',
313        description=u'Numeric characters only w/o spaces or punctuation',
314        required=True,
315        )
316
317    credit_card_type = schema.Choice(
318        title=u'Credit Card Type',
319        description=u'Credit card types supported by PayPal.',
320        required=True,
321        source=CREDIT_CARD_TYPES_VOCAB,
322        )
323
324    expire_month = schema.Int(
325        title=u'Expiration Month',
326        description=u"Month, the credit card expires.",
327        required=True,
328        default=1,
329        min=1,
330        max=12,
331        )
332
333    expire_year = schema.Int(
334        title=u'Expiration Year',
335        description=u'4-digit expiration year.',
336        required=True,
337        default=2020,
338        min=1900,
339        max=9999,
340        )
341
342    cvv2 = schema.TextLine(
343        title=u'CVV2',
344        description=u'3-4 digit card validation code',
345        required=False,
346        )
347
348    first_name = schema.TextLine(
349        title=u'First Name',
350        description=u"Cardholder's first name.",
351        required=False,
352        )
353
354    last_name = schema.TextLine(
355        title=u'Last Name',
356        description=u"Cardholder's last name.",
357        required=False,
358        )
359
360    billing_address = Attribute(
361        "Billing address associated with card.")
362
363    state = schema.Choice(
364        title=u"Status",
365        description=(u"Status of credit card funding instrument. "
366                     u"Value assigned by PayPal."
367                     ),
368        required=False,
369        source=CREDIT_CARD_STATUS_VOCAB,
370        default=None,
371        )
372
373    valid_unti = schema.TextLine(
374        title=u'Valid until',
375        description=(u'Funding instrument expiratiopn date, '
376                     u'assigned by PayPal'),
377        required=False,
378        )
379
380
381@attrs_to_fields
382class CreditCard(object):
383    """A credit card (full info set) as used by PayPal.
384
385    Normally used with a `FundingInstrument` instance.
386
387    According to PayPal docs:
388
389    `paypal_id`: provided by PayPal when storing credit card
390          data. Required if using a stored credit card.
391
392    `external_customer_id`: Unique identifier. If none is given, we
393          assign a uuid. The uuid reads 'PAYER_<32 hex digits>'.
394
395    `number`: Credit card number. Numeric characters only with no
396          spaces or punctuation. The string must conform with modulo
397          and length required by each credit card type. Redacted in
398          responses. Required.
399
400    `credit_card_type`: One of ``'visa'``, ``'mastercard'``,
401          ``'discover'``, ``'amex'``. Required.
402
403    `expire_month`: Expiration month. A number from 1 through
404          12. Required.
405
406    `expire_year`: 4-digit expiration year. Required.
407
408    `cvv2`: 3-4 digit card validation code.
409
410    `first_name`: card holders first name.
411
412    `last_name`: card holders last name.
413
414    `billing_address`: Billing address associated with card. A
415      `Billing` instance.
416
417    `state`: state of the credit card funding instrument. Valid values
418      are ``'expired'`` and ``'ok'``. *Value assigned by PayPal.*
419
420    `paypal_valid_until`: Funding instrument expiration date.
421       *Value assigned by PayPal.* Not to confuse with the credit cards
422       expiration date, which is set via `expire_month` and
423       `expire_year`.
424
425    """
426    grok.implements(ICreditCard)
427
428    def __init__(self, paypal_id=None, external_customer_id=None,
429                 number=None, credit_card_type=None, expire_month=1,
430                 expire_year=2000, cvv2=None, first_name=None,
431                 last_name=None, billing_address=None, state=None,
432                 paypal_valid_until=None):
433        if not re.match('^[0-9]+$', number):
434            raise ValueError("Credit card number may "
435                             "not contain non-numbers.")
436        if external_customer_id is None:
437            external_customer_id = u'PAYER_' + unicode(uuid.uuid4().hex)
438        self.paypal_id = paypal_id
439        self.external_customer_id = external_customer_id
440        self.number = number
441        self.credit_card_type = credit_card_type
442        self.expire_month = expire_month
443        self.expire_year = expire_year
444        self.cvv2 = cvv2
445        self.first_name = first_name
446        self.last_name = last_name
447        self.billing_address = billing_address
448        self.state = state
449        self.paypal_valid_until = paypal_valid_until
450
451    def to_dict(self):
452        return to_dict(self, name_map={'credit_card_type': 'type'})
453
454
455class ICreditCardToken(Interface):
456    """A credit card token corresponding to a credit card stored with PayPal.
457    """
458    credit_card_id = schema.TextLine(
459        title=u"Credit Card ID",
460        description=u"ID if credit card previously stored with PayPal",
461        required=True,
462        )
463
464    external_customer_id = schema.TextLine(
465        title=u'Payer ID',
466        description=(u'A unique identifier for the credit card. This '
467                     u'identifier is provided by Ikoba and must be set '
468                     u'with all future transactions, once put into '
469                     u'PayPal vault.'),
470        required=True,
471        )
472
473    last4 = schema.TextLine(
474        title=u"Credit Card's last 4 numbers",
475        description=(
476            u"Last four digits of the stored credit card number. "
477            u"Value assigned by PayPal."),
478        required=False,
479        min_length=4,
480        max_length=4
481        )
482
483    credit_card_type = schema.Choice(
484        title=u'Credit Card Type',
485        description=(
486            u'Credit card type supported by PayPal. Value assigned '
487            u'by PayPal.'),
488        required=False,
489        source=CREDIT_CARD_TYPES_VOCAB,
490        )
491
492    expire_month = schema.Int(
493        title=u'Expiration Month',
494        description=u"Month, the credit card expires. Assigned by PayPal.",
495        required=True,
496        default=1,
497        min=1,
498        max=12,
499        )
500
501    expire_year = schema.Int(
502        title=u'Expiration Year',
503        description=u'4-digit expiration year. Assigned by PayPal.',
504        required=True,
505        default=2020,
506        min=1900,
507        max=9999,
508        )
509
510
511class CreditCardToken(object):
512    grok.implements(ICreditCardToken)
513
514    def __init__(self, credit_card_id, external_customer_id=None, last4=None,
515                 credit_card_type=None, expire_month=None, expire_year=None):
516        self.credit_card_id = credit_card_id
517        self.external_customer_id = external_customer_id
518        self.last4 = last4
519        self.credit_card_type = credit_card_type
520        self.expire_month = expire_month
521        self.expire_year = expire_year
522
523    def to_dict(self):
524        return to_dict(self, name_map={'credit_card_type': 'type'})
525
526
527class IShippingAddress(Interface):
528    """A shipping address as accepted by PayPal.
529    """
530    recipient_name = schema.TextLine(
531        title=u'Recipient Name',
532        required=True,
533        description=u'Name of the recipient at this address.',
534        max_length=50,
535        )
536
537    type = schema.Choice(
538        title=u'Address Type',
539        description=u'Address Type.',
540        required=False,
541        source=ADDRESS_TYPES_VOCAB,
542        )
543
544    line1 = schema.TextLine(
545        title=u'Address Line 1',
546        required=True,
547        description=u'Line 1 of the address (e.g., Number, street, etc.)',
548        max_length=100,
549        )
550
551    line2 = schema.TextLine(
552        title=u'Address Line 2',
553        required=False,
554        description=u'Line 2 of the address (e.g., Suite, apt #, etc.)',
555        max_length=100,
556        )
557
558    city = schema.TextLine(
559        title=u'City',
560        required=True,
561        description=u'City name',
562        max_length=50,
563        )
564
565    country_code = schema.Choice(
566        title=u'Country',
567        required=True,
568        description=u'2-letter country code',
569        source=COUNTRIES_VOCAB,
570        )
571
572    postal_code = schema.TextLine(
573        title=u'Postal code',
574        required=False,
575        description=(u'Zip code or equivalent is usually required '
576                     u'for countries that have them.'),
577        max_length=20,
578        )
579
580    state = schema.TextLine(
581        title=u'State',
582        required=False,
583        description=(u'2-letter code for US stated, and the '
584                     u'equivalent for other countries.'),
585        max_length=100,
586        )
587
588    phone = schema.TextLine(
589        title=u'Phone',
590        required=False,
591        description=u'Phone number in E.123 format.',
592        max_length=50,
593        )
594
595
596@attrs_to_fields
597class ShippingAddress(object):
598    """A shipping address as used in PayPal transactions.
599    """
600    grok.implements(IShippingAddress)
601
602    def __init__(self, recipient_name, type='residential', line1='',
603                 line2=None, city='', country_code=None,
604                 postal_code=None, state=None, phone=None):
605        self.recipient_name = recipient_name
606        self.type = type
607        self.line1 = line1
608        self.line2 = line2
609        self.city = city
610        self.country_code = country_code
611        self.postal_code = postal_code
612        self.state = state
613        self.phone = phone
614
615    def to_dict(self):
616        return to_dict(self)
617
618
619class IAddress(Interface):
620    """An address as accepted by PayPal.
621    """
622    line1 = schema.TextLine(
623        title=u'Address Line 1',
624        required=True,
625        description=u'Line 1 of the address (e.g., Number, street, etc.)',
626        max_length=100,
627        )
628
629    line2 = schema.TextLine(
630        title=u'Address Line 2',
631        required=False,
632        description=u'Line 2 of the address (e.g., Suite, apt #, etc.)',
633        max_length=100,
634        )
635
636    city = schema.TextLine(
637        title=u'City',
638        required=True,
639        description=u'City name',
640        max_length=50,
641        )
642
643    country_code = schema.Choice(
644        title=u'Country',
645        required=True,
646        description=u'2-letter country code',
647        source=COUNTRIES_VOCAB,
648        )
649
650    postal_code = schema.TextLine(
651        title=u'Postal code',
652        required=False,
653        description=(u'Zip code or equivalent is usually required '
654                     u'for countries that have them.'),
655        max_length=20,
656        )
657
658    state = schema.TextLine(
659        title=u'State',
660        required=False,
661        description=(u'2-letter code for US stated, and the '
662                     u'equivalent for other countries.'),
663        max_length=100,
664        )
665
666    phone = schema.TextLine(
667        title=u'Phone',
668        required=False,
669        description=u'Phone number in E.123 format.',
670        max_length=50,
671        )
672
673
674@attrs_to_fields
675class Address(object):
676    """A postal address as used in PayPal transactions.
677    """
678    grok.implements(IAddress)
679
680    def __init__(self, line1='', line2=None, city='', country_code=None,
681                 postal_code=None, state=None, phone=None):
682        self.line1 = line1
683        self.line2 = line2
684        self.city = city
685        self.country_code = country_code
686        self.postal_code = postal_code
687        self.state = state
688        self.phone = phone
689
690    def to_dict(self):
691        """Turn Adress into a dict that can be fed to PayPal classes.
692        """
693        return to_dict(self)
694
695
696class AmountDetails(object):
697    """Amount details can be given with Amount objects.
698
699    All parameters are passed in as decimals (`decimal.Decimal`).
700
701    All numbers stored here, might have 10 characters max with
702    support for two decimal places.
703
704    No parameter is strictly required, except `subtotal`, which must
705    be set if any of the other values is set.
706
707    `shipping`: Amount charged for shipping.
708
709    `subtotal`: Amount for subtotal of the items. Automatically
710      computed. If no other item was set, subtotal is `None`.
711
712    `tax`: Amount charged for tax.
713
714    `fee`: Fee charged by PayPal. In case of a refund, this is the fee
715      amount refunded to the original recipient of the payment. Value
716      assigned by PayPal.
717
718    `handling_fee`: Amount being charged for the handling
719      fee. Currently supported with paypal payment_method only, but
720      available for credit_card payment_method at a later date.
721
722    `insurance`: Amount being charged for the insurance fee. Currently
723      supported with paypal payment_method only, but available for
724      credit_card payment_method at a later date.
725
726    `shipping_discount`: Amount being discounted for the shipping
727      fee. Currently supported with paypal payment_method only, but
728      available for credit_card payment_method at a later date.
729    """
730    def __init__(self, shipping=None, tax=None, fee=None,
731                 handling_fee=None, insurance=None, shipping_discount=None):
732        self.shipping = shipping
733        self.tax = tax
734        self.fee = fee
735        self.handling_fee = handling_fee
736        self.insurance = insurance
737        self.shipping_discount = shipping_discount
738
739    @property
740    def subtotal(self):
741        names = (
742            'shipping', 'tax', 'fee', 'handling_fee', 'insurance',
743            'shipping_discount'
744            )
745        result = None
746        for name in names:
747            val = getattr(self, name)
748            if name == 'shipping_discount' and val is not None:
749                val = -val
750            if val is not None:
751                if result is None:
752                    result = val
753                else:
754                    result += val
755        return result
756
757    def to_dict(self):
758        return to_dict(self)
759
760
761class IAmount(Interface):
762    """An amount as used by PayPal in payments.
763    """
764    currency = schema.Choice(
765        title=u'Currency',
766        description=u'PayPal does not support all currencies. Required.',
767        required=True,
768        source=CURRENCIES_VOCAB,
769        default=u'USD',
770        )
771
772    total = schema.Decimal(
773        title=u'Total',
774        description=(
775            u'Total amount charged from the payer to the payee. '
776            u'In case of a refund, this is the refunded amount to '
777            u'the original payer from the payee.'),
778        required=True,
779        default=decimal.Decimal("0.00"),
780        max=decimal.Decimal("9999999.99")
781        )
782
783    details = Attribute(
784        """Additional details related to a payment amount.
785        """)
786
787
788@attrs_to_fields
789class Amount(object):
790    grok.implements(IAmount)
791
792    def __init__(self, currency="USD", total=decimal.Decimal("0.00"),
793                 details=None):
794        self.currency = currency
795        self.total = total
796        self.details = details
797
798    def to_dict(self):
799        return to_dict(self)
800
801
802class IItem(Interface):
803    """PayPal Item.
804
805    Items in a PayPal context are goods sold to customers.
806    """
807    quantity = schema.Int(
808        title=u"Quantity",
809        description=u"Number of this particular items.",
810        required=True,
811        default=1,
812        max=9999999999,
813        )
814
815    name = schema.TextLine(
816        title=u"Name",
817        description=u"Item name",
818        required=True,
819        max_length=127,
820        )
821
822    price = schema.Decimal(
823        title=u"Price",
824        description=u"Price",
825        required=True,
826        max=decimal.Decimal("9999999.99"),
827        )
828
829    currency = schema.Choice(
830        title=u"Currency",
831        description=u"Currency",
832        source=CURRENCIES_VOCAB,
833        default=u'USD',
834        required=True,
835        )
836
837    sku = schema.Choice(
838        title=u"SKU",
839        description=u"Stock keeping unit corresponding to item.",
840        source=STOCK_KEEPING_UNITS_VOCAB,
841        required=False,
842        )
843
844    description = schema.TextLine(
845        title=u"Description",
846        description=(
847            u"Description of Item. Currently supported with paypal "
848            u"payments only."
849            ),
850        max_length=127,
851        required=False,
852        )
853
854    tax = schema.Decimal(
855        title=u"Tax",
856        description=(
857            u"Tax of the item. Currently supported with paypal "
858            u"payments only."
859            ),
860        required=False,
861        )
862
863
864@attrs_to_fields
865class Item(object):
866    """See IItem for docs.
867    """
868    grok.implements(IItem)
869
870    def __init__(self, name, quantity=1, price=decimal.Decimal("0.00"),
871                 currency="USD", sku=None, description=None, tax=None):
872        self.name = name
873        self.quantity = quantity
874        self.price = price
875        self.currency = currency
876        self.sku = sku
877        self.description = description
878        self.tax = tax
879
880    def to_dict(self):
881        return to_dict(self)
882
883
884class IItemList(Interface):
885    """List of `Item` objects and a related `ShippingAddress`.
886
887    `items`: can be a simple list of `Item` objects.
888
889    `shipping_address`: a `ShippingAddress` object. Only needed if
890      different from Payer address.
891    """
892    items = schema.List(
893        title=u"Items",
894        description=u"PayPal Items are sold goods",
895        value_type=schema.Object(
896            title=u"Item",
897            description=u"Item in a list",
898            schema=IItem,
899            )
900        )
901
902    shipping_address = schema.Object(
903        title=u"Shipping Address",
904        description=u"Shipping address of receiver if different from payee.",
905        schema=IShippingAddress,
906        required=False,
907        )
908
909
910@attrs_to_fields
911class ItemList(object):
912    """List of `Item` objects and a related `ShippingAddress`.
913
914    `items`: can be a simple list of `Item` objects.
915
916    `shipping_address`: a `ShippingAddress` object. Only needed if
917      different from Payer address.
918    """
919    grok.implements(IItemList)
920
921    def __init__(self, items=[], shipping_address=None):
922        self.items = items
923        self.shipping_address = shipping_address
924
925    def to_dict(self):
926        return to_dict(self)
927
928
929class IPaymentOptions(Interface):
930    """Payment options requested for a certain purchase unit.
931
932    `allowed_payment_method`: Optional payment method type. If
933      specified, the transaction will go through for only instant
934      payment. Allowed values: ``INSTANT_FUNDING_SOURCE``. Only for
935      use with the ``paypal`` payment_method, not relevant for the
936      ``credit_card`` payment_method.
937    """
938    allowed_payment_method = schema.Choice(
939        title=u"Allowed payment method",
940        description=(
941            u"Optional payment type. If specified, the transaction "
942            u"will go through for only instant payment. Only for use "
943            u"with paypal payment method, not relevant for credit cards."
944            ),
945        required=False,
946        source=PAYMENT_OPTION_METHODS_VOCAB,
947        )
948
949
950@attrs_to_fields
951class PaymentOptions(object):
952    """Payment options requested for a certain purchase unit.
953    """
954    grok.implements(IPaymentOptions)
955
956    def __init__(self, allowed_payment_method=None):
957        if allowed_payment_method not in (
958            None, 'INSTANT_FUNDING_SOURCE'):
959            raise ValueError(
960                "allowed_payment_method of PaymentOptions must be None or "
961                "'INSTANT_FUNDING_SOURCE'"
962                )
963        self.allowed_payment_method = allowed_payment_method
964
965    def to_dict(self):
966        return to_dict(self)
967
968
969class ITransaction(Interface):
970    """PayPal transactions provide payment transaction details.
971    """
972    amount = schema.Object(
973        title=u"Amount",
974        description=u"Amount being collected.",
975        schema=IAmount,
976        required=True,
977        )
978
979    description = schema.TextLine(
980        title=u"Description",
981        description=u"Description of transaction",
982        required=False,
983        max_length=127,
984        )
985
986    item_list = schema.Object(
987        title=u"Item List",
988        description=u"List of items",
989        required=False,
990        schema=IItemList,
991        )
992
993    # XXX: This should be defined more precisely: What kind of objects, etc.
994    #      PayPal docs say: "array of sale, authorization, capture, or refund,
995    #      objects"
996    related_resources = Attribute("Arbitrary objects")
997
998    invoice_number = schema.TextLine(
999        title=u"Invoice Number",
1000        description=(
1001            u"Invoice number used to track the payment. "
1002            u"Currently supported with paypal payment_method only."
1003            ),
1004        required=False,
1005        max_length=256,
1006        )
1007
1008    custom = schema.TextLine(
1009        title=u"Custom text",
1010        description=(
1011            u"Free-form field for the use of clients. Currently "
1012            u"supported with paypal payment_method only."),
1013        required=False,
1014        max_length=256,
1015        )
1016
1017    soft_descriptor = schema.TextLine(
1018        title=u"Soft descriptor",
1019        description=(
1020            u"Soft descriptor used when charging this funding "
1021            u"source. Currently supported with paypal payment_method only"
1022            ),
1023        required=False,
1024        max_length=22,
1025        )
1026
1027    payment_options = schema.Object(
1028        title=u"Payment Options",
1029        description=u"Payment options requested for this purchase unit.",
1030        required=False,
1031        schema=IPaymentOptions,
1032        )
1033
1034
1035@attrs_to_fields
1036class Transaction(object):
1037    # See ITransaction for description
1038
1039    grok.implements(ITransaction)
1040
1041    def __init__(self, amount, description=None, item_list=None,
1042                 related_resources=[], invoice_number=None, custom=None,
1043                 soft_descriptor=None, payment_options=None):
1044        self.amount = amount
1045        self.description = description
1046        self.item_list = item_list
1047        self.related_resources = related_resources
1048        self.invoice_number = invoice_number
1049        self.custom = custom
1050        self.soft_descriptor = soft_descriptor
1051        self.payment_options = payment_options
1052
1053    def to_dict(self):
1054        """Give a `dict` representation of this `Transaction`.
1055        """
1056        return to_dict(self)
1057
1058
1059def get_payment(intent='sale', payment_method='credit_card'):
1060    """Construct a payment.
1061
1062    You have to `create()` the payment yourself.
1063
1064    Returns a paypalrestsdk Payment object, not an Ikoba payment.
1065
1066    As `intent` currently only the string ``'sale'`` is supported.
1067
1068    XXX: Just some sampledata yet.
1069    """
1070    if intent != "sale":
1071        raise ValueError(
1072            "Currently, only 'sale' is allowed as type of paypal"
1073            "payment.")
1074    payment = paypalrestsdk.Payment(
1075        {
1076            "intent": intent,
1077            "payer": {
1078                "payment_method": "credit_card",
1079                "funding_instruments": [
1080                    {
1081                        "credit_card": {
1082                            "type": "visa",
1083                            "number": "4417119669820331",
1084                            "expire_month": "11",
1085                            "expire_year": "2018",
1086                            "cvv2": "874",
1087                            "first_name": "Joe",
1088                            "last_name": "Shopper",
1089                            "billing_address": {
1090                                "line1": "52 N Main ST",
1091                                "city": "Johnstown",
1092                                "state": "OH",
1093                                "postal_code": "43210",
1094                                "country_code": "US"}
1095                            }
1096                        }
1097                    ]},
1098            "transactions": [{
1099                "amount": {
1100                    "total": "7.47",
1101                    "currency": "USD",
1102                    "details": {
1103                        "subtotal": "7.41",
1104                        "tax": "0.03",
1105                        "shipping": "0.03"}},
1106                "description": ("This is the payment "
1107                                "transaction description.")
1108                }]
1109            }
1110        )
1111    return payment
1112
1113
1114class IPayPalPayment(IPayment):
1115    """A paypal payment.
1116    """
1117
1118
1119class PayPalPayment(grok.Model):
1120    """A paypal payment.
1121    """
1122    pass
1123
1124
1125class StandardItemAdapter(grok.Adapter):
1126    grok.implements(ITransaction)
1127    grok.adapts(IPaymentItem)
1128
1129
1130def payment_item_to_transaction(item):
1131    """Turn an IPaymentItem into an ITransaction
1132    """
1133    amount = Amount(currency=item.currency, total=item.amount)
1134    description = item.title
1135    item = Item(name=item.title, price=item.amount, currency=item.currency,
1136                quantity=1, sku=_("license"))
1137    item_list = ItemList(items=[item, ])
1138    transaction = Transaction(
1139        amount=amount, description=description, item_list=item_list)
1140    return transaction
1141
1142
1143class PayPalCreditCardService(grok.GlobalUtility):
1144    grok.implements(IPaymentGatewayService)
1145    grok.name('paypal_creditcard')
1146
1147    title = _(u'Credit Card (PayPal)')
1148
1149    def get_credit_card(self, customer_id):
1150        """Get an ICreditCard for `customer_id`.
1151
1152        `None` if the payer has (yet) no credit card stored with ikoba.
1153        """
1154        site = grok.getSite()
1155        if not IContainer.providedBy(site):
1156            return None
1157        if not 'creditcards' in site:
1158            return None
1159        return site['creditcards'].get(customer_id, None)
1160
1161    def store_credit_card(self, paypal_credit_card):
1162        """Store `paypal_credit_card` in vault.
1163
1164        Returns a credit card token in case of success. Otherwise an
1165        IOError is raised.
1166
1167        `paypal_credit_card` must provide `ICreditCard`. The credit
1168        card created is stored at PayPal and gets a token stored
1169        locally (in site['creditcards']).
1170
1171        If no `creditcards` folder is available in local site, we
1172        create a new container.
1173        """
1174        site = grok.getSite()
1175        if not 'creditcards' in site:
1176            site['creditcards'] = grok.Container()
1177        pp_credit_card = paypalrestsdk.CreditCard(
1178            paypal_credit_card.to_dict())
1179        result = pp_credit_card.create()
1180        if not result:
1181            # error
1182            raise IOError(pp_credit_card.error)
1183        result = CreditCardToken(
1184            pp_credit_card.id, pp_credit_card.external_customer_id,
1185            pp_credit_card.number, pp_credit_card.type,
1186            pp_credit_card.expire_month, pp_credit_card.expire_year
1187            )
1188        site['creditcards'][pp_credit_card.external_customer_id] = result
1189        return result
1190
1191    def create_payment(self, payer, payment_item,  payee=None):
1192        """Create a creditcard payment.
1193        """
1194        if not IPayer.providedBy(payer):
1195            payer = IPayer(payer)
1196        if not IPaymentItem.providedBy(payment_item):
1197            payment_item = IPaymentItem(payment_item)
1198        if (payee is not None) and (not IPayee.providedBy(payee)):
1199            payee = IPayee(payee)
1200        credit_card = self.get_credit_card(payer.payer_id)
1201        if credit_card is None:
1202            raise ValueError("Payer %s has no credit card." % payer.payer_id)
1203        transaction = payment_item_to_transaction(payment_item)
1204        payment_dict = {
1205            "intent": "sale",
1206            "payer": {
1207                "payment_method": "credit_card",
1208                "funding_instruments": [{
1209                    "credit_card_token": {
1210                        "credit_card_id": credit_card.credit_card_id,
1211                        }}]},
1212            "transactions": [
1213                transaction.to_dict()]
1214            }
1215        payment = paypalrestsdk.Payment(payment_dict)
1216        return payment
1217
1218
1219class PayPalRegularPaymentService(grok.GlobalUtility):
1220    grok.implements(IPaymentGatewayService)
1221    grok.name('paypal_regular')
1222
1223    title = _(u'PayPal Payment')
Note: See TracBrowser for help on using the repository browser.