Enregistrement / visualisation des échanges avec PayPal.
[photoprint.git] / order.py
1 # -*- coding: utf-8 -*-
2 #######################################################################################
3 # Plinn - http://plinn.org #
4 # Copyright (C) 2009-2013 Benoît PIN <benoit.pin@ensmp.fr> #
5 # #
6 # This program is free software; you can redistribute it and/or #
7 # modify it under the terms of the GNU General Public License #
8 # as published by the Free Software Foundation; either version 2 #
9 # of the License, or (at your option) any later version. #
10 # #
11 # This program is distributed in the hope that it will be useful, #
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of #
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the #
14 # GNU General Public License for more details. #
15 # #
16 # You should have received a copy of the GNU General Public License #
17 # along with this program; if not, write to the Free Software #
18 # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #
19 #######################################################################################
20 """
21 Print order classes
22
23
24
25 """
26
27 from Globals import InitializeClass, PersistentMapping, Persistent
28 from Acquisition import Implicit
29 from AccessControl import ClassSecurityInfo
30 from AccessControl.requestmethod import postonly
31 from zope.interface import implements
32 from zope.component.factory import Factory
33 from persistent.list import PersistentList
34 from OFS.SimpleItem import SimpleItem
35 from ZTUtils import make_query
36 from DateTime import DateTime
37 from Products.CMFCore.PortalContent import PortalContent
38 from Products.CMFCore.permissions import ModifyPortalContent, View, ManagePortal
39 from Products.CMFCore.utils import getToolByName, getUtilityByInterfaceName
40 from Products.CMFDefault.DublinCore import DefaultDublinCoreImpl
41 from Products.Plinn.utils import getPreferredLanguages
42 from interfaces import IPrintOrderTemplate, IPrintOrder
43 from permissions import ManagePrintOrderTemplate, ManagePrintOrders
44 from price import Price
45 from xml.dom.minidom import Document
46 from tool import COPIES_COUNTERS
47 from App.config import getConfiguration
48 from paypal.interface import PayPalInterface
49 from logging import getLogger
50 console = getLogger('Products.photoprint.order')
51
52
53 def getPayPalConfig() :
54 zopeConf = getConfiguration()
55 try :
56 conf = zopeConf.product_config['photoprint']
57 except KeyError :
58 EnvironmentError("No photoprint configuration found in Zope environment.")
59
60 ppconf = {'API_ENVIRONMENT' : conf['paypal_api_environment'],
61 'API_USERNAME' : conf['paypal_username'],
62 'API_PASSWORD' : conf['paypal_password'],
63 'API_SIGNATURE' : conf['paypal_signature']}
64
65 return ppconf
66
67
68 class PrintOrderTemplate(SimpleItem) :
69 """
70 predefined print order
71 """
72 implements(IPrintOrderTemplate)
73
74 security = ClassSecurityInfo()
75
76 def __init__(self
77 , id
78 , title=''
79 , description=''
80 , productReference=''
81 , maxCopies=0
82 , price=0
83 , VATRate=0) :
84 self.id = id
85 self.title = title
86 self.description = description
87 self.productReference = productReference
88 self.maxCopies = maxCopies # 0 means unlimited
89 self.price = Price(price, VATRate)
90
91 security.declareProtected(ManagePrintOrderTemplate, 'edit')
92 def edit( self
93 , title=''
94 , description=''
95 , productReference=''
96 , maxCopies=0
97 , price=0
98 , VATRate=0 ) :
99 self.title = title
100 self.description = description
101 self.productReference = productReference
102 self.maxCopies = maxCopies
103 self.price = Price(price, VATRate)
104
105 security.declareProtected(ManagePrintOrderTemplate, 'formWidgetData')
106 def formWidgetData(self, REQUEST=None, RESPONSE=None):
107 """formWidgetData documentation
108 """
109 d = Document()
110 d.encoding = 'utf-8'
111 root = d.createElement('formdata')
112 d.appendChild(root)
113
114 def gua(name) :
115 return str(getattr(self, name, '')).decode('utf-8')
116
117 id = d.createElement('id')
118 id.appendChild(d.createTextNode(self.getId()))
119 root.appendChild(id)
120
121 title = d.createElement('title')
122 title.appendChild(d.createTextNode(gua('title')))
123 root.appendChild(title)
124
125 description = d.createElement('description')
126 description.appendChild(d.createTextNode(gua('description')))
127 root.appendChild(description)
128
129 productReference = d.createElement('productReference')
130 productReference.appendChild(d.createTextNode(gua('productReference')))
131 root.appendChild(productReference)
132
133 maxCopies = d.createElement('maxCopies')
134 maxCopies.appendChild(d.createTextNode(str(self.maxCopies)))
135 root.appendChild(maxCopies)
136
137 price = d.createElement('price')
138 price.appendChild(d.createTextNode(str(self.price.taxed)))
139 root.appendChild(price)
140
141 vatrate = d.createElement('VATRate')
142 vatrate.appendChild(d.createTextNode(str(self.price.vat)))
143 root.appendChild(vatrate)
144
145 if RESPONSE is not None :
146 RESPONSE.setHeader('content-type', 'text/xml; charset=utf-8')
147
148 manager = getToolByName(self, 'caching_policy_manager', None)
149 if manager is not None:
150 view_name = 'formWidgetData'
151 headers = manager.getHTTPCachingHeaders(
152 self, view_name, {}
153 )
154
155 for key, value in headers:
156 if key == 'ETag':
157 RESPONSE.setHeader(key, value, literal=1)
158 else:
159 RESPONSE.setHeader(key, value)
160 if headers:
161 RESPONSE.setHeader('X-Cache-Headers-Set-By',
162 'CachingPolicyManager: %s' %
163 '/'.join(manager.getPhysicalPath()))
164
165
166 return d.toxml('utf-8')
167
168
169 InitializeClass(PrintOrderTemplate)
170 PrintOrderTemplateFactory = Factory(PrintOrderTemplate)
171
172 class PrintOrder(PortalContent, DefaultDublinCoreImpl) :
173
174 implements(IPrintOrder)
175 security = ClassSecurityInfo()
176
177 def __init__( self, id) :
178 DefaultDublinCoreImpl.__init__(self)
179 self.id = id
180 self.items = []
181 self.quantity = 0
182 self.price = Price(0, 0)
183 # billing and shipping addresses
184 self.billing = PersistentMapping()
185 self.shipping = PersistentMapping()
186 self.shippingFees = Price(0,0)
187 self._paypalLog = PersistentList()
188
189 @property
190 def amountWithFees(self) :
191 return self.price + self.shippingFees
192
193
194 security.declareProtected(ModifyPortalContent, 'editBilling')
195 def editBilling(self
196 , name
197 , address
198 , city
199 , zipcode
200 , country
201 , phone) :
202 self.billing['name'] = name
203 self.billing['address'] = address
204 self.billing['city'] = city
205 self.billing['zipcode'] = zipcode
206 self.billing['country'] = country
207 self.billing['phone'] = phone
208
209 security.declareProtected(ModifyPortalContent, 'editShipping')
210 def editShipping(self, name, address, city, zipcode, country) :
211 self.shipping['name'] = name
212 self.shipping['address'] = address
213 self.shipping['city'] = city
214 self.shipping['zipcode'] = zipcode
215 self.shipping['country'] = country
216
217 security.declarePrivate('loadCart')
218 def loadCart(self, cart):
219 pptool = getToolByName(self, 'portal_photo_print')
220 uidh = getToolByName(self, 'portal_uidhandler')
221 mtool = getToolByName(self, 'portal_membership')
222
223 items = []
224 for item in cart :
225 photo = uidh.getObject(item['cmf_uid'])
226 pOptions = pptool.getPrintingOptionsContainerFor(photo)
227 template = getattr(pOptions, item['printing_template'])
228
229 reference = template.productReference
230 quantity = item['quantity']
231 uPrice = template.price
232 self.quantity += quantity
233
234 d = {'cmf_uid' : item['cmf_uid']
235 ,'url' : photo.absolute_url()
236 ,'title' : template.title
237 ,'description' : template.description
238 ,'unit_price' : Price(uPrice._taxed, uPrice._rate)
239 ,'quantity' : quantity
240 ,'productReference' : reference
241 }
242 items.append(d)
243 self.price += uPrice * quantity
244 # confirm counters
245 if template.maxCopies :
246 counters = getattr(photo, COPIES_COUNTERS)
247 counters.confirm(reference, quantity)
248
249 self.items = tuple(items)
250
251 member = mtool.getAuthenticatedMember()
252 mg = lambda name : member.getProperty(name, '')
253 billing = {'name' : member.getMemberFullName(nameBefore=0)
254 ,'address' : mg('billing_address')
255 ,'city' : mg('billing_city')
256 ,'zipcode' : mg('billing_zipcode')
257 ,'country' : mg('country')
258 ,'phone' : mg('phone') }
259 self.editBilling(**billing)
260
261 sg = lambda name : cart._shippingInfo.get(name, '')
262 shipping = {'name' : sg('shipping_fullname')
263 ,'address' : sg('shipping_address')
264 ,'city' : sg('shipping_city')
265 ,'zipcode' : sg('shipping_zipcode')
266 ,'country' : sg('shipping_country')}
267 self.editShipping(**shipping)
268
269 self.shippingFees = pptool.getShippingFeesFor(shippable=self)
270
271 cart._confirmed = True
272 cart.pendingOrderPath = self.getPhysicalPath()
273
274 security.declareProtected(ManagePrintOrders, 'resetCopiesCounters')
275 def resetCopiesCounters(self) :
276 pptool = getToolByName(self, 'portal_photo_print')
277 uidh = getToolByName(self, 'portal_uidhandler')
278
279 for item in self.items :
280 photo = uidh.getObject(item['cmf_uid'])
281 counters = getattr(photo, COPIES_COUNTERS, None)
282 if counters :
283 counters.cancel(item['productReference'],
284 item['quantity'])
285
286
287 def _initPayPalInterface(self) :
288 config = getPayPalConfig()
289 config['API_AUTHENTICATION_MODE'] = '3TOKEN'
290 ppi = PayPalInterface(**config)
291 return ppi
292
293
294 @staticmethod
295 def recordifyPPResp(response) :
296 d = {}
297 d['zopeTime'] = DateTime()
298 for k, v in response.raw.iteritems() :
299 if len(v) == 1 :
300 d[k] = v[0]
301 else :
302 d[k] = v
303 return d
304
305 # paypal api
306 security.declareProtected(ModifyPortalContent, 'ppSetExpressCheckout')
307 def ppSetExpressCheckout(self) :
308 utool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IURLTool')
309 mtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IMembershipTool')
310 portal_url = utool()
311 portal = utool.getPortalObject()
312 member = mtool.getAuthenticatedMember()
313
314 options = {#'PAYMENTREQUEST_0_AMT' : '99.55', # todo
315 'PAYMENTREQUEST_0_CURRENCYCODE' : 'EUR',
316 'PAYMENTREQUEST_0_PAYMENTACTION' : 'Sale',
317 'RETURNURL' : '%s/photoprint_order_confirm' % self.absolute_url(),
318 'CANCELURL' : '%s/photoprint_order_cancel' % self.absolute_url(),
319 # 'CALLBACK' : TODO
320 'ALLOWNOTE' : 0, # The buyer is unable to enter a note to the merchant.
321 'HDRIMG' : '%s/logo.gif' % portal_url,
322 'EMAIL' : member.getProperty('email'),
323 'SOLUTIONTYPE' : 'Sole', # Buyer does not need to create a PayPal account to check out. This is referred to as PayPal Account Optional.
324 'LANDINGPAGE' : 'Billing', # Non-PayPal account
325 'BRANDNAME' : portal.getProperty('title'),
326 'GIFTMESSAGEENABLE' : 0,
327 'GIFTRECEIPTENABLE' : 0,
328 'BUYEREMAILOPTINENABLE' : 0, # Do not enable buyer to provide email address.
329 'NOSHIPPING' : 1, # PayPal does not display shipping address fields whatsoever.
330 # 'PAYMENTREQUEST_0_NOTIFYURL' : TODO
331
332 'PAYMENTREQUEST_0_SHIPTONAME' : self.billing['name'],
333 'PAYMENTREQUEST_0_SHIPTOSTREET' : self.billing['address'],
334 'PAYMENTREQUEST_0_SHIPTOCITY' : self.billing['city'],
335 'PAYMENTREQUEST_0_SHIPTOZIP' : self.billing['zipcode'],
336 'PAYMENTREQUEST_0_SHIPTOPHONENUM' : self.billing['phone'],
337 }
338
339 if len(self.items) > 1 :
340 quantitySum = reduce(lambda a, b : a['quantity'] + b['quantity'], self.items)
341 else :
342 quantitySum = self.items[0]['quantity']
343 total = round(self.amountWithFees.getValues()['taxed'], 2)
344
345 options['L_PAYMENTREQUEST_0_NAME0'] = 'Commande realis photo ref. %s' % self.getId()
346 if quantitySum == 1 :
347 options['L_PAYMENTREQUEST_0_DESC0'] = "Commande d'un tirage photographique"
348 else :
349 options['L_PAYMENTREQUEST_0_DESC0'] = 'Commande de %d tirages photographiques' % quantitySum
350 options['L_PAYMENTREQUEST_0_AMT0'] = total
351 options['PAYMENTINFO_0_SHIPPINGAMT'] = round(self.shippingFees.getValues()['taxed'], 2)
352 # options['L_PAYMENTREQUEST_0_TAXAMT0'] = tax
353 # options['L_PAYMENTREQUEST_0_QTY%d' % n] = 1
354 options['PAYMENTREQUEST_0_AMT'] = total
355
356 ppi = self._initPayPalInterface()
357 response = ppi.set_express_checkout(**options)
358 response = PrintOrder.recordifyPPResp(response)
359 self._paypalLog.append(response)
360 response['url'] = ppi.generate_express_checkout_redirect_url(response['TOKEN'])
361 console.info(options)
362 console.info(response)
363 return response
364
365 security.declarePrivate('ppGetExpressCheckoutDetails')
366 def ppGetExpressCheckoutDetails(self, token) :
367 ppi = self._initPayPalInterface()
368 response = ppi.get_express_checkout_details(TOKEN=token)
369 response = PrintOrder.recordifyPPResp(response)
370 self._paypalLog.append(response)
371 return response
372
373 security.declarePrivate('ppDoExpressCheckoutPayment')
374 def ppDoExpressCheckoutPayment(self, token, payerid, amt) :
375 ppi = self._initPayPalInterface()
376 response = ppi.do_express_checkout_payment(PAYMENTREQUEST_0_PAYMENTACTION='Sale',
377 PAYMENTREQUEST_0_AMT=amt,
378 PAYMENTREQUEST_0_CURRENCYCODE='EUR',
379 TOKEN=token,
380 PAYERID=payerid)
381 response = PrintOrder.recordifyPPResp(response)
382 self._paypalLog.append(response)
383 return response
384
385 security.declareProtected(ModifyPortalContent, 'ppPay')
386 def ppPay(self, token, payerid):
387 # assure le paiement paypal en une passe :
388 # récupération des détails et validation de la transaction.
389
390 wtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IWorkflowTool')
391 wfstate = wtool.getInfoFor(self, 'review_state', 'order_workflow')
392 paid = wfstate == 'paid'
393
394 if not paid :
395 details = self.ppGetExpressCheckoutDetails(token)
396
397 if payerid != details['PAYERID'] :
398 return False
399
400 if details['ACK'] == 'Success' :
401 response = self.ppDoExpressCheckoutPayment(token,
402 payerid,
403 details['AMT'])
404 if response['ACK'] == 'Success' and \
405 response['PAYMENTINFO_0_ACK'] == 'Success' and \
406 response['PAYMENTINFO_0_PAYMENTSTATUS'] == 'Completed' :
407 self.paid = (DateTime(), 'paypal')
408 wtool = getUtilityByInterfaceName('Products.CMFCore.interfaces.IWorkflowTool')
409 wtool.doActionFor( self
410 , 'paypal_pay'
411 , wf_id='order_workflow'
412 , comments='Paiement par PayPal')
413 return True
414 return False
415 else :
416 return True
417
418 security.declareProtected(ModifyPortalContent, 'ppCancel')
419 def ppCancel(self, token) :
420 details = self.ppGetExpressCheckoutDetails(token)
421
422 security.declareProtected(ManagePortal, 'getPPLog')
423 def getPPLog(self) :
424 return self._paypalLog
425
426 def getCustomerSummary(self) :
427 ' '
428 return {'quantity':self.quantity,
429 'price':self.price}
430
431
432 InitializeClass(PrintOrder)
433 PrintOrderFactory = Factory(PrintOrder)
434
435
436 class CopiesCounters(Persistent, Implicit) :
437
438 def __init__(self):
439 self._mapping = PersistentMapping()
440
441 def getBrowserId(self):
442 sdm = self.session_data_manager
443 bim = sdm.getBrowserIdManager()
444 browserId = bim.getBrowserId(create=1)
445 return browserId
446
447 def _checkBrowserId(self, browserId) :
448 sdm = self.session_data_manager
449 sd = sdm.getSessionDataByKey(browserId)
450 return not not sd
451
452 def __setitem__(self, reference, count) :
453 if not self._mapping.has_key(reference):
454 self._mapping[reference] = PersistentMapping()
455 self._mapping[reference]['pending'] = PersistentMapping()
456 self._mapping[reference]['confirmed'] = 0
457
458 globalCount = self[reference]
459 delta = count - globalCount
460 bid = self.getBrowserId()
461 if not self._mapping[reference]['pending'].has_key(bid) :
462 self._mapping[reference]['pending'][bid] = delta
463 else :
464 self._mapping[reference]['pending'][bid] += delta
465
466
467 def __getitem__(self, reference) :
468 item = self._mapping[reference]
469 globalCount = item['confirmed']
470
471 for browserId, count in item['pending'].items() :
472 if self._checkBrowserId(browserId) :
473 globalCount += count
474 else :
475 del self._mapping[reference]['pending'][browserId]
476
477 return globalCount
478
479 def get(self, reference, default=0) :
480 if self._mapping.has_key(reference) :
481 return self[reference]
482 else :
483 return default
484
485 def getPendingCounter(self, reference) :
486 bid = self.getBrowserId()
487 if not self._checkBrowserId(bid) :
488 console.warn('BrowserId not found: %s' % bid)
489 return 0
490
491 count = self._mapping[reference]['pending'].get(bid, None)
492 if count is None :
493 console.warn('No pending data found for browserId %s' % bid)
494 return 0
495 else :
496 return count
497
498 def confirm(self, reference, quantity) :
499 pending = self.getPendingCounter(reference)
500 if pending != quantity :
501 console.warn('Pending quantity mismatch with the confirmed value: (%d, %d)' % (pending, quantity))
502
503 browserId = self.getBrowserId()
504 if self._mapping[reference]['pending'].has_key(browserId) :
505 del self._mapping[reference]['pending'][browserId]
506 self._mapping[reference]['confirmed'] += quantity
507
508 def cancel(self, reference, quantity) :
509 self._mapping[reference]['confirmed'] -= quantity
510
511 def __str__(self):
512 return str(self._mapping)