Merge remote-tracking branch 'upstream/4.3' into 4.3-master-2013-05-24-02-32-04
[civicrm-core.git] / CRM / Core / Payment / GoogleIPN.php
CommitLineData
6a488035
TO
1<?php
2
3/**
4 * Copyright (C) 2006 Google Inc.
5 *
6 * Licensed under the Apache License, Version 2.0 (the "License");
7 * you may not use this file except in compliance with the License.
8 * You may obtain a copy of the License at
9 *
10 * http://www.apache.org/licenses/LICENSE-2.0
11 *
12 * Unless required by applicable law or agreed to in writing, software
13 * distributed under the License is distributed on an "AS IS" BASIS,
14 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 * See the License for the specific language governing permissions and
16 * limitations under the License.
17 */
18
19/* This is the response handler code that will be invoked every time
20 * a notification or request is sent by the Google Server
21 *
22 * To allow this code to receive responses, the url for this file
23 * must be set on the seller page under Settings->Integration as the
24 * "API Callback URL'
25 * Order processing commands can be sent automatically by placing these
26 * commands appropriately
27 *
28 * To use this code for merchant-calculated feedback, this url must be
29 * set also as the merchant-calculations-url when the cart is posted
30 * Depending on your calculations for shipping, taxes, coupons and gift
31 * certificates update parts of the code as required
32 *
33 */
34
35
36
37define('GOOGLE_DEBUG_PP', 0);
38class CRM_Core_Payment_GoogleIPN extends CRM_Core_Payment_BaseIPN {
39
40 /**
41 * We only need one instance of this object. So we use the singleton
42 * pattern and cache the instance in this variable
43 *
44 * @var object
45 * @static
46 */
47 static private $_singleton = NULL;
48
49 /**
50 * mode of operation: live or test
51 *
52 * @var object
53 */
54 protected $_mode = NULL;
55
56 static function retrieve($name, $type, $object, $abort = TRUE) {
57 $value = CRM_Utils_Array::value($name, $object);
58 if ($abort && $value === NULL) {
59 CRM_Core_Error::debug_log_message("Could not find an entry for $name");
60 echo "Failure: Missing Parameter<p>";
61 exit();
62 }
63
64 if ($value) {
65 if (!CRM_Utils_Type::validate($value, $type)) {
66 CRM_Core_Error::debug_log_message("Could not find a valid entry for $name");
67 echo "Failure: Invalid Parameter<p>";
68 exit();
69 }
70 }
71
72 return $value;
73 }
74
75 /**
76 * Constructor
77 *
78 * @param string $mode the mode of operation: live or test
79 *
80 * @return void
81 */
82 function __construct($mode, &$paymentProcessor) {
83 parent::__construct();
84
85 $this->_mode = $mode;
86 $this->_paymentProcessor = $paymentProcessor;
87 }
88
89 /**
90 * The function gets called when a new order takes place.
91 *
92 * @param xml $dataRoot response send by google in xml format
93 * @param array $privateData contains the name value pair of <merchant-private-data>
94 *
95 * @return void
96 *
97 */
98 function newOrderNotify($dataRoot, $privateData, $component) {
99 $ids = $input = $params = array();
100
101 $input['component'] = strtolower($component);
102
103 $ids['contact'] = self::retrieve('contactID', 'Integer', $privateData, TRUE);
104 $ids['contribution'] = self::retrieve('contributionID', 'Integer', $privateData, TRUE);
105
106 $ids['contributionRecur'] = $ids['contributionPage'] = NULL;
107 if ($input['component'] == "event") {
108 $ids['event'] = self::retrieve('eventID', 'Integer', $privateData, TRUE);
109 $ids['participant'] = self::retrieve('participantID', 'Integer', $privateData, TRUE);
110 $ids['membership'] = NULL;
111 }
112 else {
113 $ids['membership'] = self::retrieve('membershipID', 'Integer', $privateData, FALSE);
114 $ids['related_contact'] = self::retrieve('relatedContactID', 'Integer', $privateData, FALSE);
115 $ids['onbehalf_dupe_alert'] = self::retrieve('onBehalfDupeAlert', 'Integer', $privateData, FALSE);
116 $ids['contributionRecur'] = self::retrieve('contributionRecurID', 'Integer', $privateData, FALSE);
117 }
118
119 $paymentProcessorID = CRM_Core_DAO::getFieldValue(
120 'CRM_Financial_DAO_PaymentProcessorType',
121 'Google_Checkout',
122 'id',
123 'payment_processor_type'
124 );
125
126 if (!$this->validateData($input, $ids, $objects, TRUE, $paymentProcessorID)) {
127 return FALSE;
128 }
129
130 $input['invoice'] = $privateData['invoiceID'];
131 $input['newInvoice'] = $dataRoot['google-order-number']['VALUE'];
132
133 if ($ids['contributionRecur']) {
134 if ($objects['contributionRecur']->invoice_id == $dataRoot['serial-number']) {
135 CRM_Core_Error::debug_log_message("The new order notification already handled: {$dataRoot['serial-number']}.");
136 return;
137 }
138 else {
139 $transaction = new CRM_Core_Transaction();
140
141 CRM_Core_Error::debug_log_message("New order for an installment received.");
142 $recur = &$objects['contributionRecur'];
143
144 // fix dates that already exist
145 $dates = array('create', 'start', 'end', 'cancel', 'modified');
146 foreach ($dates as $date) {
147 $name = "{$date}_date";
148 if ($recur->$name) {
149 $recur->$name = CRM_Utils_Date::isoToMysql($recur->$name);
150 }
151 }
152 $recur->invoice_id = $dataRoot['serial-number'];
153 $recur->processor_id = $input['newInvoice'];
154 $recur->save();
155
156 if ($objects['contribution']->contribution_status_id == 1) {
157 // create a contribution and then get it processed
158 $contribution = new CRM_Contribute_DAO_Contribution();
159 $contribution->contact_id = $ids['contact'];
160 $contribution->financial_type_id = $objects['contributionType']->id;
161 $contribution->contribution_page_id = $objects['contribution']->contribution_page_id;
162 $contribution->contribution_recur_id = $ids['contributionRecur'];
163 $contribution->receive_date = date('YmdHis');
164 $contribution->currency = $objects['contribution']->currency;
165 $contribution->payment_instrument_id = $objects['contribution']->payment_instrument_id;
166 $contribution->amount_level = $objects['contribution']->amount_level;
167 $contribution->address_id = $objects['contribution']->address_id;
168 $contribution->invoice_id = $input['invoice'];
169 $contribution->total_amount = $dataRoot['order-total']['VALUE'];
170 $contribution->contribution_status_id = 2;
171 $objects['contribution'] = $contribution;
172 }
173 $transaction->commit();
174 }
175 }
176
177 // make sure the invoice is valid and matches what we have in the contribution record
178 $contribution = &$objects['contribution'];
179
180 if ($contribution->invoice_id != $input['invoice']) {
181 CRM_Core_Error::debug_log_message("Invoice values dont match between database and IPN request");
182 return;
183 }
184
185 // lets replace invoice-id with google-order-number because thats what is common and unique
186 // in subsequent calls or notifications sent by google.
187 $contribution->invoice_id = $input['newInvoice'];
188
189 $input['amount'] = $dataRoot['order-total']['VALUE'];
190
191 if ($contribution->total_amount != $input['amount']) {
192 CRM_Core_Error::debug_log_message("Amount values dont match between database and IPN request");
193 return;
194 }
195
196 if (!$this->getInput($input, $ids, $dataRoot)) {
197 return FALSE;
198 }
199
200 $transaction = new CRM_Core_Transaction();
201
202 // check if contribution is already completed, if so we ignore this ipn
203 if ($contribution->contribution_status_id == 1) {
204 CRM_Core_Error::debug_log_message("returning since contribution has already been handled");
205 return;
206 }
207 else {
208 /* Since trxn_id hasn't got any use here,
209 * lets make use of it by passing the eventID/membershipTypeID to next level.
210 * And change trxn_id to google-order-number before finishing db update */
211
212 if (CRM_Utils_Array::value('event', $ids)) {
213 $contribution->trxn_id = $ids['event'] . CRM_Core_DAO::VALUE_SEPARATOR . $ids['participant'];
214 }
215 elseif (CRM_Utils_Array::value('membership', $ids)) {
216 $contribution->trxn_id = $ids['membership'][0] . CRM_Core_DAO::VALUE_SEPARATOR . $ids['related_contact'] . CRM_Core_DAO::VALUE_SEPARATOR . $ids['onbehalf_dupe_alert'];
217 }
218 }
219
220 // CRM_Core_Error::debug_var( 'c', $contribution );
221 $contribution->save();
222 $transaction->commit();
223
224 return TRUE;
225 }
226
227 /**
228 * The function gets called when the state(CHARGED, CANCELLED..) changes for an order
229 *
230 * @param string $status status of the transaction send by google
231 * @param array $privateData contains the name value pair of <merchant-private-data>
232 *
233 * @return void
234 *
235 */
236 function orderStateChange($status, $dataRoot, $privateData, $component) {
237 $input = $objects = $ids = array();
238 $input['component'] = strtolower($component);
239
240 $ids['contributionRecur'] = self::retrieve('contributionRecurID', 'Integer', $privateData, FALSE);
241 $serial = $dataRoot['serial-number'];
242 $orderNo = $dataRoot['google-order-number']['VALUE'];
243
244 $contribution = new CRM_Contribute_BAO_Contribution();
245 $contribution->invoice_id = $orderNo;
246
247 if (!$contribution->find(TRUE)) {
248 CRM_Core_Error::debug_log_message("orderStateChange: Could not find contribution record with invoice id: $serial");
249 return;
250 }
251
252 // Google sends the charged notification twice.
253 // So to make sure, code is not executed again.
254 if ($contribution->contribution_status_id == 1) {
255 CRM_Core_Error::debug_log_message("Contribution already handled (ContributionID = {$contribution->id}).");
256 return;
257 }
258
259 // make sure invoice is set to serial no for recurring payments, to avoid violating uniqueness
260 $contribution->invoice_id = $ids['contributionRecur'] ? $serial : $orderNo;
261
262 $objects['contribution'] = &$contribution;
263 $ids['contribution'] = $contribution->id;
264 $ids['contact'] = $contribution->contact_id;
265
266 $ids['event'] = $ids['participant'] = $ids['membership'] = NULL;
267 $ids['contributionPage'] = NULL;
268
269 if ($input['component'] == "event") {
270 list($ids['event'], $ids['participant']) = explode(CRM_Core_DAO::VALUE_SEPARATOR,
271 $contribution->trxn_id
272 );
273 }
274 else {
275 $ids['related_contact'] = NULL;
276 $ids['onbehalf_dupe_alert'] = NULL;
277 if ($contribution->trxn_id) {
278 list($ids['membership'], $ids['related_contact'], $ids['onbehalf_dupe_alert']) = explode(CRM_Core_DAO::VALUE_SEPARATOR,
279 $contribution->trxn_id
280 );
281 }
282 foreach (array(
283 'membership', 'related_contact', 'onbehalf_dupe_alert') as $fld) {
284 if (!is_numeric($ids[$fld])) {
285 unset($ids[$fld]);
286 }
287 }
288 }
289
290 $paymentProcessorID = CRM_Core_DAO::getFieldValue(
291 'CRM_Financial_DAO_PaymentProcessorType',
292 'Google_Checkout',
293 'id',
294 'payment_processor_type'
295 );
296
297 $this->loadObjects($input, $ids, $objects, TRUE, $paymentProcessorID);
298
299 $transaction = new CRM_Core_Transaction();
300
301 // CRM_Core_Error::debug_var( 'c', $contribution );
302 if ($status == 'PAYMENT_DECLINED' ||
303 $status == 'CANCELLED_BY_GOOGLE' ||
304 $status == 'CANCELLED'
305 ) {
306 return $this->failed($objects, $transaction);
307 }
308
309 $input['amount'] = $contribution->total_amount;
310 $input['fee_amount'] = NULL;
311 $input['net_amount'] = NULL;
312 $input['trxn_id'] = $ids['contributionRecur'] ? $serial : $dataRoot['google-order-number']['VALUE'];
313 $input['is_test'] = $contribution->is_test;
314
315 $recur = NULL;
316 if ($ids['contributionRecur']) {
317 $recur = $objects['contributionRecur'];
318 }
319 $this->completeTransaction($input, $ids, $objects, $transaction, $recur);
320
321 $this->completeRecur($input, $ids, $objects);
322 }
323
324 function completeRecur($input, $ids, $objects) {
325 if ($ids['contributionRecur']) {
326 $recur = &$objects['contributionRecur'];
327 $contributionCount = CRM_Core_DAO::singleValueQuery("
328SELECT count(*)
329FROM civicrm_contribution
330WHERE contribution_recur_id = {$ids['contributionRecur']}
331");
332 $autoRenewMembership = FALSE;
333 if ($recur->id &&
334 isset($ids['membership']) &&
335 $ids['membership']
336 ) {
337 $autoRenewMembership = TRUE;
338 }
339 if ($recur->installments && ($contributionCount >= $recur->installments)) {
340 $contributionStatus = CRM_Contribute_PseudoConstant::contributionStatus(NULL, 'name');
341
342 $recur->create_date = CRM_Utils_Date::isoToMysql($recur->create_date);
343 $recur->start_date = CRM_Utils_Date::isoToMysql($recur->start_date);
344 $recur->cancel_date = CRM_Utils_Date::isoToMysql($recur->cancel_date);
345 $recur->end_date = date('YmdHis');
346 $recur->modified_date = date('YmdHis');
347 $recur->contribution_status_id = array_search('Completed', $contributionStatus);
348 $recur->trnx_id = $dataRoot['google-order-number']['VALUE'];
349 $recur->save();
350
351 //send recurring Notification email for user
352 CRM_Contribute_BAO_ContributionPage::recurringNotify(
353 CRM_Core_Payment::RECURRING_PAYMENT_END,
354 $ids['contact'],
355 $ids['contributionPage'],
356 $recur,
357 $autoRenewMembership
358 );
359 }
360 elseif ($contributionCount == 1) {
361 CRM_Contribute_BAO_ContributionPage::recurringNotify(
362 CRM_Core_Payment::RECURRING_PAYMENT_START,
363 $ids['contact'],
364 $ids['contributionPage'],
365 $recur,
366 $autoRenewMembership
367 );
368 }
369 }
370 }
371
372 /**
373 * singleton function used to manage this object
374 *
375 * @param string $mode the mode of operation: live or test
376 *
377 * @return object
378 * @static
379 */
380 static function &singleton($mode, $component, &$paymentProcessor) {
381 if (self::$_singleton === NULL) {
382 self::$_singleton = new CRM_Core_Payment_GoogleIPN($mode, $paymentProcessor);
383 }
384 return self::$_singleton;
385 }
386
387 /**
388 * The function retrieves the amount the contribution is for, based on the order-no google sends
389 *
390 * @param int $orderNo <order-total> send by google
391 *
392 * @return amount
393 * @access public
394 */
395 function getAmount($orderNo) {
396 $contribution = new CRM_Contribute_DAO_Contribution();
397 $contribution->invoice_id = $orderNo;
398 if (!$contribution->find(TRUE)) {
399 CRM_Core_Error::debug_log_message("getAmount: Could not find contribution record with invoice id: $orderNo");
400 echo "Failure: Could not find contribution record with invoice id: $orderNo <p>";
401 exit();
402 }
403 return $contribution->total_amount;
404 }
405
406 /**
407 * The function returns the component(Event/Contribute..), given the google-order-no and merchant-private-data
408 *
409 * @param xml $xml_response response send by google in xml format
410 * @param array $privateData contains the name value pair of <merchant-private-data>
411 * @param int $orderNo <order-total> send by google
412 * @param string $root root of xml-response
413 *
414 * @return array context of this call (test, module, payment processor id)
415 * @static
416 */
417 function getContext($privateData, $orderNo, $root, $response, $serial) {
418 $contributionID = CRM_Utils_Array::value('contributionID', $privateData);
419 $contribution = new CRM_Contribute_DAO_Contribution();
420 if ($root == 'new-order-notification') {
421 $contribution->id = $contributionID;
422 }
423 else {
424 $contribution->invoice_id = $orderNo;
425 }
426 if (!$contribution->find(TRUE)) {
427 CRM_Core_Error::debug_log_message("getContext: Could not find contribution record with invoice id: $orderNo");
428 $response->SendAck($serial);
429 }
430
431 $module = 'Contribute';
432 if (stristr($contribution->source, ts('Online Contribution'))) {
433 $module = 'Contribute';
434 }
435 elseif (stristr($contribution->source, ts('Online Event Registration'))) {
436 $module = 'Event';
437 }
438 $isTest = $contribution->is_test;
439
440 $ids = $input = $objects = array();
441 $objects['contribution'] = &$contribution;
442 $ids['contributionRecur'] = self::retrieve('contributionRecurID', 'Integer', $privateData, FALSE);
443 $input['component'] = strtolower($module);
444
445 if (!$ids['contributionRecur'] && $contribution->contribution_status_id == 1) {
446 CRM_Core_Error::debug_log_message("Contribution already handled (ContributionID = {$contribution->id}).");
447 // There is no point in going further. Return ack so we don't receive the same ipn.
448 $response->SendAck($serial);
449 }
450
451 if ($input['component'] == 'event') {
452 if ($root == 'new-order-notification') {
453 $ids['event'] = $privateData['eventID'];
454 }
455 else {
456 list($ids['event'], $ids['participant']) =
457 explode(CRM_Core_DAO::VALUE_SEPARATOR, $contribution->trxn_id);
458 }
459 }
460
461 $paymentProcessorID = CRM_Core_DAO::getFieldValue(
462 'CRM_Financial_DAO_PaymentProcessor',
463 'Google_Checkout',
464 'id',
465 'payment_processor_type'
466 );
467
468 $this->loadObjects($input, $ids, $objects, FALSE, $paymentProcessorID);
469
470 if (!$ids['paymentProcessor']) {
471 CRM_Core_Error::debug_log_message("Payment processor could not be retrieved.");
472 // There is no point in going further. Return ack so we don't receive the same ipn.
473 $response->SendAck($serial);
474 }
475
476 return array($isTest, $input['component'], $ids['paymentProcessor']);
477 }
478
479 /**
480 * This method is handles the response that will be invoked (from extern/googleNotify) every time
481 * a notification or request is sent by the Google Server.
482 *
483 */
484 static function main($xml_response) {
485 require_once 'Google/library/googleresponse.php';
486 require_once 'Google/library/googlerequest.php';
487 require_once 'Google/library/googlemerchantcalculations.php';
488 require_once 'Google/library/googleresult.php';
489 require_once 'Google/library/xml-processing/gc_xmlparser.php';
490
491 $config = CRM_Core_Config::singleton();
492
493 // Retrieve the XML sent in the HTTP POST request to the ResponseHandler
494 if (get_magic_quotes_gpc()) {
495 $xml_response = stripslashes($xml_response);
496 }
497
498 $headers = CRM_Utils_System::getAllHeaders();
499
500 if (GOOGLE_DEBUG_PP) {
501 CRM_Core_Error::debug_var('RESPONSE', $xml_response, TRUE, TRUE, 'Google');
502 }
503
504 // Retrieve the root and data from the xml response
505 $response = new GoogleResponse();
506 list($root, $data) = $response->GetParsedXML($xml_response);
507 // lets retrieve the private-data & order-no
508 $privateData = NULL;
509 if (array_key_exists('shopping-cart', $data[$root])) {
510 $privateData = $data[$root]['shopping-cart']['merchant-private-data']['VALUE'];
511 }
512 if (empty($privateData) && array_key_exists('order-summary', $data[$root])
513 && array_key_exists('shopping-cart', $data[$root]['order-summary'])) {
514 $privateData = $data[$root]['order-summary']['shopping-cart']['merchant-private-data']['VALUE'];
515 }
516 $privateData = $privateData ? self::stringToArray($privateData) : '';
517 $orderNo = $data[$root]['google-order-number']['VALUE'];
518 $serial = $data[$root]['serial-number'];
519
520 // a dummy object to call get context and a parent function inside it.
521 $ipn = new CRM_Core_Payment_GoogleIPN('live', $dummyProcessor);
522 list($mode, $module, $paymentProcessorID) = $ipn->getContext($privateData, $orderNo, $root, $response, $serial);
523 $mode = $mode ? 'test' : 'live';
524
525 $paymentProcessor = CRM_Financial_BAO_PaymentProcessor::getPayment($paymentProcessorID, $mode);
526 $merchant_id = $paymentProcessor['user_name'];
527 $merchant_key = $paymentProcessor['password'];
528 $response->SetMerchantAuthentication($merchant_id, $merchant_key);
529
530 $server_type = ($mode == 'test') ? 'sandbox' : 'production';
531 $request = new GoogleRequest($merchant_id, $merchant_key, $server_type);
532
533 $ipn = self::singleton($mode, $module, $paymentProcessor);
534
535 if (GOOGLE_DEBUG_PP) {
536 CRM_Core_Error::debug_var('RESPONSE-ROOT', $response->root, TRUE, TRUE, 'Google');
537 }
538
539 //Check status and take appropriate action
540 $status = $response->HttpAuthentication($headers);
541
542 switch ($root) {
543 case "request-received":
544 case "error":
545 case "diagnosis":
546 case "checkout-redirect":
547 case "merchant-calculation-callback":
548 break;
549
550 case "new-order-notification": {
551 $response->SendAck($serial, FALSE);
552 $ipn->newOrderNotify($data[$root], $privateData, $module);
553 break;
554 }
555
556 case "order-state-change-notification": {
557 $response->SendAck($serial, FALSE);
558 $new_financial_state = $data[$root]['new-financial-order-state']['VALUE'];
559 $new_fulfillment_order = $data[$root]['new-fulfillment-order-state']['VALUE'];
560
561 switch ($new_financial_state) {
562 case 'CHARGEABLE':
563 break;
564
565 case 'CHARGED':
566 case 'PAYMENT_DECLINED':
567 case 'CANCELLED':
568 case 'CANCELLED_BY_GOOGLE':
569 $ipn->orderStateChange($new_financial_state, $data[$root], $privateData, $module);
570 break;
571
572 case 'REVIEWING':
573 case 'CHARGING':
574 break;
575
576 default:
577 break;
578 }
579 break;
580 }
581
582 case "authorization-amount-notification": {
583 $response->SendAck($serial, FALSE);
584 $new_financial_state = $data[$root]['order-summary']['financial-order-state']['VALUE'];
585 $new_fulfillment_order = $data[$root]['order-summary']['fulfillment-order-state']['VALUE'];
586
587 switch ($new_financial_state) {
588 case 'CHARGEABLE':
589 // For google-handled subscriptions chargeorder needn't be initiated,
590 // assuming auto-charging is turned on.
591 //$request->SendProcessOrder($data[$root]['google-order-number']['VALUE']);
592 //$request->SendChargeOrder($data[$root]['google-order-number']['VALUE'],'');
593 break;
594
595 case 'CHARGED':
596 case 'PAYMENT_DECLINED':
597 case 'CANCELLED':
598 break;
599
600 case 'REVIEWING':
601 case 'CHARGING':
602 case 'CANCELLED_BY_GOOGLE':
603 break;
604
605 default:
606 break;
607 }
608 break;
609 }
610
611 case "charge-amount-notification":
612 case "chargeback-amount-notification":
613 case "refund-amount-notification":
614 case "risk-information-notification":
615 $response->SendAck($serial);
616 break;
617
618 default:
619 break;
620 }
621 }
622
623 function getInput(&$input, &$ids, $dataRoot) {
624 if (!$this->getBillingID($ids)) {
625 return FALSE;
626 }
627
628 $billingID = $ids['billing'];
629 $lookup = array(
630 "first_name" => 'contact-name',
631 // "last-name" not available with google (every thing in contact-name)
632 "last_name" => 'last_name',
633 "street_address-{$billingID}" => 'address1',
634 "city-{$billingID}" => 'city',
635 "state-{$billingID}" => 'region',
636 "postal_code-{$billingID}" => 'postal-code',
637 "country-{$billingID}" => 'country-code',
638 );
639
640 foreach ($lookup as $name => $googleName) {
641 if (array_key_exists($googleName, $dataRoot['buyer-billing-address'])) {
642 $value = $dataRoot['buyer-billing-address'][$googleName]['VALUE'];
643 }
644 $input[$name] = $value ? $value : NULL;
645 }
646 return TRUE;
647 }
648
649 /**
650 * Converts the comma separated name-value pairs in <merchant-private-data>
651 * to an array of name-value pairs.
652 */
653 static function stringToArray($str) {
654 $vars = $labels = array();
655 $labels = explode(',', $str);
656 foreach ($labels as $label) {
657 $terms = explode('=', $label);
658 $vars[$terms[0]] = $terms[1];
659 }
660 return $vars;
661 }
662}
663