1# Copyright (c) 2012 Andy Davidoff http://www.disruptek.com/
2# Copyright (c) 2010 Jason R. Coombs http://www.jaraco.com/
3# Copyright (c) 2008 Chris Moyer http://coredumped.org/
4#
5# Permission is hereby granted, free of charge, to any person obtaining a
6# copy of this software and associated documentation files (the
7# "Software"), to deal in the Software without restriction, including
8# without limitation the rights to use, copy, modify, merge, publish, dis-
9# tribute, sublicense, and/or sell copies of the Software, and to permit
10# persons to whom the Software is furnished to do so, subject to the fol-
11# lowing conditions:
12#
13# The above copyright notice and this permission notice shall be included
14# in all copies or substantial portions of the Software.
15#
16# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
17# OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABIL-
18# ITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
19# SHALL THE AUTHOR BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
20# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
22# IN THE SOFTWARE.
23
24import urllib
25import uuid
26from boto.connection import AWSQueryConnection
27from boto.fps.exception import ResponseErrorFactory
28from boto.fps.response import ResponseFactory
29import boto.fps.response
30
31__all__ = ['FPSConnection']
32
33decorated_attrs = ('action', 'response')
34
35
36def add_attrs_from(func, to):
37    for attr in decorated_attrs:
38        setattr(to, attr, getattr(func, attr, None))
39    return to
40
41
42def complex_amounts(*fields):
43    def decorator(func):
44        def wrapper(self, *args, **kw):
45            for field in filter(kw.has_key, fields):
46                amount = kw.pop(field)
47                kw[field + '.Value'] = getattr(amount, 'Value', str(amount))
48                kw[field + '.CurrencyCode'] = getattr(amount, 'CurrencyCode',
49                                                      self.currencycode)
50            return func(self, *args, **kw)
51        wrapper.__doc__ = "{0}\nComplex Amounts: {1}".format(func.__doc__,
52                                                 ', '.join(fields))
53        return add_attrs_from(func, to=wrapper)
54    return decorator
55
56
57def requires(*groups):
58
59    def decorator(func):
60
61        def wrapper(*args, **kw):
62            hasgroup = lambda x: len(x) == len(filter(kw.has_key, x))
63            if 1 != len(filter(hasgroup, groups)):
64                message = ' OR '.join(['+'.join(g) for g in groups])
65                message = "{0} requires {1} argument(s)" \
66                          "".format(getattr(func, 'action', 'Method'), message)
67                raise KeyError(message)
68            return func(*args, **kw)
69        message = ' OR '.join(['+'.join(g) for g in groups])
70        wrapper.__doc__ = "{0}\nRequired: {1}".format(func.__doc__,
71                                                           message)
72        return add_attrs_from(func, to=wrapper)
73    return decorator
74
75
76def needs_caller_reference(func):
77
78    def wrapper(*args, **kw):
79        kw.setdefault('CallerReference', uuid.uuid4())
80        return func(*args, **kw)
81    wrapper.__doc__ = "{0}\nUses CallerReference, defaults " \
82                      "to uuid.uuid4()".format(func.__doc__)
83    return add_attrs_from(func, to=wrapper)
84
85
86def api_action(*api):
87
88    def decorator(func):
89        action = ''.join(api or map(str.capitalize, func.__name__.split('_')))
90        response = ResponseFactory(action)
91        if hasattr(boto.fps.response, action + 'Response'):
92            response = getattr(boto.fps.response, action + 'Response')
93
94        def wrapper(self, *args, **kw):
95            return func(self, action, response, *args, **kw)
96        wrapper.action, wrapper.response = action, response
97        wrapper.__doc__ = "FPS {0} API call\n{1}".format(action,
98                                                         func.__doc__)
99        return wrapper
100    return decorator
101
102
103class FPSConnection(AWSQueryConnection):
104
105    APIVersion = '2010-08-28'
106    ResponseError = ResponseErrorFactory
107    currencycode = 'USD'
108
109    def __init__(self, *args, **kw):
110        self.currencycode = kw.pop('CurrencyCode', self.currencycode)
111        kw.setdefault('host', 'fps.sandbox.amazonaws.com')
112        super(FPSConnection, self).__init__(*args, **kw)
113
114    def _required_auth_capability(self):
115        return ['fps']
116
117    @needs_caller_reference
118    @complex_amounts('SettlementAmount')
119    @requires(['CreditInstrumentId', 'SettlementAmount.Value',
120               'SenderTokenId',      'SettlementAmount.CurrencyCode'])
121    @api_action()
122    def settle_debt(self, action, response, **kw):
123        """
124        Allows a caller to initiate a transaction that atomically transfers
125        money from a sender's payment instrument to the recipient, while
126        decreasing corresponding debt balance.
127        """
128        return self.get_object(action, kw, response)
129
130    @requires(['TransactionId'])
131    @api_action()
132    def get_transaction_status(self, action, response, **kw):
133        """
134        Gets the latest status of a transaction.
135        """
136        return self.get_object(action, kw, response)
137
138    @requires(['StartDate'])
139    @api_action()
140    def get_account_activity(self, action, response, **kw):
141        """
142        Returns transactions for a given date range.
143        """
144        return self.get_object(action, kw, response)
145
146    @requires(['TransactionId'])
147    @api_action()
148    def get_transaction(self, action, response, **kw):
149        """
150        Returns all details of a transaction.
151        """
152        return self.get_object(action, kw, response)
153
154    @api_action()
155    def get_outstanding_debt_balance(self, action, response):
156        """
157        Returns the total outstanding balance for all the credit instruments
158        for the given creditor account.
159        """
160        return self.get_object(action, {}, response)
161
162    @requires(['PrepaidInstrumentId'])
163    @api_action()
164    def get_prepaid_balance(self, action, response, **kw):
165        """
166        Returns the balance available on the given prepaid instrument.
167        """
168        return self.get_object(action, kw, response)
169
170    @api_action()
171    def get_total_prepaid_liability(self, action, response):
172        """
173        Returns the total liability held by the given account corresponding to
174        all the prepaid instruments owned by the account.
175        """
176        return self.get_object(action, {}, response)
177
178    @api_action()
179    def get_account_balance(self, action, response):
180        """
181        Returns the account balance for an account in real time.
182        """
183        return self.get_object(action, {}, response)
184
185    @needs_caller_reference
186    @requires(['PaymentInstruction', 'TokenType'])
187    @api_action()
188    def install_payment_instruction(self, action, response, **kw):
189        """
190        Installs a payment instruction for caller.
191        """
192        return self.get_object(action, kw, response)
193
194    @needs_caller_reference
195    @requires(['returnURL', 'pipelineName'])
196    def cbui_url(self, **kw):
197        """
198        Generate a signed URL for the Co-Branded service API given arguments as
199        payload.
200        """
201        sandbox = 'sandbox' in self.host and 'payments-sandbox' or 'payments'
202        endpoint = 'authorize.{0}.amazon.com'.format(sandbox)
203        base = '/cobranded-ui/actions/start'
204
205        validpipelines = ('SingleUse', 'MultiUse', 'Recurring', 'Recipient',
206                          'SetupPrepaid', 'SetupPostpaid', 'EditToken')
207        assert kw['pipelineName'] in validpipelines, "Invalid pipelineName"
208        kw.update({
209            'signatureMethod':  'HmacSHA256',
210            'signatureVersion': '2',
211        })
212        kw.setdefault('callerKey', self.aws_access_key_id)
213
214        safestr = lambda x: x is not None and str(x) or ''
215        safequote = lambda x: urllib.quote(safestr(x), safe='~')
216        payload = sorted([(k, safequote(v)) for k, v in kw.items()])
217
218        encoded = lambda p: '&'.join([k + '=' + v for k, v in p])
219        canonical = '\n'.join(['GET', endpoint, base, encoded(payload)])
220        signature = self._auth_handler.sign_string(canonical)
221        payload += [('signature', safequote(signature))]
222        payload.sort()
223
224        return 'https://{0}{1}?{2}'.format(endpoint, base, encoded(payload))
225
226    @needs_caller_reference
227    @complex_amounts('TransactionAmount')
228    @requires(['SenderTokenId', 'TransactionAmount.Value',
229                                'TransactionAmount.CurrencyCode'])
230    @api_action()
231    def reserve(self, action, response, **kw):
232        """
233        Reserve API is part of the Reserve and Settle API conjunction that
234        serve the purpose of a pay where the authorization and settlement have
235        a timing difference.
236        """
237        return self.get_object(action, kw, response)
238
239    @needs_caller_reference
240    @complex_amounts('TransactionAmount')
241    @requires(['SenderTokenId', 'TransactionAmount.Value',
242                                'TransactionAmount.CurrencyCode'])
243    @api_action()
244    def pay(self, action, response, **kw):
245        """
246        Allows calling applications to move money from a sender to a recipient.
247        """
248        return self.get_object(action, kw, response)
249
250    @requires(['TransactionId'])
251    @api_action()
252    def cancel(self, action, response, **kw):
253        """
254        Cancels an ongoing transaction and puts it in cancelled state.
255        """
256        return self.get_object(action, kw, response)
257
258    @complex_amounts('TransactionAmount')
259    @requires(['ReserveTransactionId', 'TransactionAmount.Value',
260                                       'TransactionAmount.CurrencyCode'])
261    @api_action()
262    def settle(self, action, response, **kw):
263        """
264        The Settle API is used in conjunction with the Reserve API and is used
265        to settle previously reserved transaction.
266        """
267        return self.get_object(action, kw, response)
268
269    @complex_amounts('RefundAmount')
270    @requires(['TransactionId',   'RefundAmount.Value',
271               'CallerReference', 'RefundAmount.CurrencyCode'])
272    @api_action()
273    def refund(self, action, response, **kw):
274        """
275        Refunds a previously completed transaction.
276        """
277        return self.get_object(action, kw, response)
278
279    @requires(['RecipientTokenId'])
280    @api_action()
281    def get_recipient_verification_status(self, action, response, **kw):
282        """
283        Returns the recipient status.
284        """
285        return self.get_object(action, kw, response)
286
287    @requires(['CallerReference'], ['TokenId'])
288    @api_action()
289    def get_token_by_caller(self, action, response, **kw):
290        """
291        Returns the details of a particular token installed by this calling
292        application using the subway co-branded UI.
293        """
294        return self.get_object(action, kw, response)
295
296    @requires(['UrlEndPoint', 'HttpParameters'])
297    @api_action()
298    def verify_signature(self, action, response, **kw):
299        """
300        Verify the signature that FPS sent in IPN or callback urls.
301        """
302        return self.get_object(action, kw, response)
303
304    @api_action()
305    def get_tokens(self, action, response, **kw):
306        """
307        Returns a list of tokens installed on the given account.
308        """
309        return self.get_object(action, kw, response)
310
311    @requires(['TokenId'])
312    @api_action()
313    def get_token_usage(self, action, response, **kw):
314        """
315        Returns the usage of a token.
316        """
317        return self.get_object(action, kw, response)
318
319    @requires(['TokenId'])
320    @api_action()
321    def cancel_token(self, action, response, **kw):
322        """
323        Cancels any token installed by the calling application on its own
324        account.
325        """
326        return self.get_object(action, kw, response)
327
328    @needs_caller_reference
329    @complex_amounts('FundingAmount')
330    @requires(['PrepaidInstrumentId', 'FundingAmount.Value',
331               'SenderTokenId',       'FundingAmount.CurrencyCode'])
332    @api_action()
333    def fund_prepaid(self, action, response, **kw):
334        """
335        Funds the prepaid balance on the given prepaid instrument.
336        """
337        return self.get_object(action, kw, response)
338
339    @requires(['CreditInstrumentId'])
340    @api_action()
341    def get_debt_balance(self, action, response, **kw):
342        """
343        Returns the balance corresponding to the given credit instrument.
344        """
345        return self.get_object(action, kw, response)
346
347    @needs_caller_reference
348    @complex_amounts('AdjustmentAmount')
349    @requires(['CreditInstrumentId', 'AdjustmentAmount.Value',
350                                     'AdjustmentAmount.CurrencyCode'])
351    @api_action()
352    def write_off_debt(self, action, response, **kw):
353        """
354        Allows a creditor to write off the debt balance accumulated partially
355        or fully at any time.
356        """
357        return self.get_object(action, kw, response)
358
359    @requires(['SubscriptionId'])
360    @api_action()
361    def get_transactions_for_subscription(self, action, response, **kw):
362        """
363        Returns the transactions for a given subscriptionID.
364        """
365        return self.get_object(action, kw, response)
366
367    @requires(['SubscriptionId'])
368    @api_action()
369    def get_subscription_details(self, action, response, **kw):
370        """
371        Returns the details of Subscription for a given subscriptionID.
372        """
373        return self.get_object(action, kw, response)
374
375    @needs_caller_reference
376    @complex_amounts('RefundAmount')
377    @requires(['SubscriptionId'])
378    @api_action()
379    def cancel_subscription_and_refund(self, action, response, **kw):
380        """
381        Cancels a subscription.
382        """
383        message = "If you specify a RefundAmount, " \
384                  "you must specify CallerReference."
385        assert not 'RefundAmount.Value' in kw \
386                or 'CallerReference' in kw, message
387        return self.get_object(action, kw, response)
388
389    @requires(['TokenId'])
390    @api_action()
391    def get_payment_instruction(self, action, response, **kw):
392        """
393        Gets the payment instruction of a token.
394        """
395        return self.get_object(action, kw, response)
396