source: main/waeup.ikoba/trunk/src/waeup/ikoba/payments/paypal.py @ 12387

Last change on this file since 12387 was 12326, checked in by uli, 10 years ago

We cannot do creditcard payments w/o creditcards.

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