Line # Revision Author
1 693 ahitrov package money::Provider::Dreamkas;
2
3 use strict;
4 use warnings 'all';
5
6 use base 'money::Provider::Base';
7 use Contenido::Globals;
8 use money::Keeper;
9 use MIME::Base64;
10 use URI;
11 use URI::QueryParam;
12 use JSON::XS;
13 use Data::Dumper;
14
15 use constant (
16 TAXMODE_DEFAULT => 'DEFAULT',
17 );
18
19 our %TAX_MODES = (
20 'DEFAULT' => 1, # Общая
21 'SIMPLE' => 1, # Упрощенная доход
22 'SIMPLE_WO' => 1, # Упрощенная доход минус расход
23 'ENVD' => 1, # Единый налог на вмененный доход
24 'AGRICULT' => 1, # Единый сельскохозяйственный
25 'PATENT' => 1, # Патентная система налогообложения
26 );
27
28 our %TAX_NDS = (
29 'NDS_NO_TAX' => 0,
30 'NDS_0' => 0,
31 'NDS_10' => 0.10,
32 'NDS_18' => 0.18,
33 'NDS_20' => 0.20,
34 'NDS_10_CALCULATED' => 0.10/110,
35 'NDS_18_CALCULATED' => 0.10/110,
36 );
37
38 our %OP_STATUS = (
39 'PENDING' => 0,
40 'IN_PROGRESS' => 1,
41 'SUCCESS' => 2,
42 'ERROR' => -1,
43 );
44
45 sub new {
46 my ($proto, %params) = @_;
47 my $class = ref($proto) || $proto;
48 my $self = {};
49 my $prefix = $class =~ /\:\:(\w+)$/ ? lc($1) : undef;
50 return unless $prefix;
51
52 $self->{prefix} = $prefix;
53 704 ahitrov $self->{provider} = $prefix;
54 693 ahitrov $self->{app_id} = $state->{money}{$prefix."_app_id"};
55 $self->{secret} = $state->{money}{$prefix."_app_secret"};
56 715 ahitrov $self->{token} = $state->{money}{$prefix."_token"};
57 693 ahitrov $self->{tax_mode} = $state->{money}{$prefix."_tax_mode"};
58 unless ( exists $TAX_MODES{$self->{tax_mode}} ) {
59 698 ahitrov warn "Неверная мнемоника типа налоговой системы [".$self->{tax_mode}."]\n";
60 693 ahitrov return undef;
61 }
62 $self->{tax_nds} = $state->{money}{$prefix."_tax_nds"};
63 unless ( exists $TAX_NDS{$self->{tax_nds}} ) {
64 698 ahitrov warn "Неверная мнемоника типа НДС [".$self->{tax_nds}."]\n";
65 693 ahitrov return undef;
66 }
67 $self->{device_id} = $state->{money}{$prefix."_device_id"};
68 unless ( $self->{device_id} ) {
69 warn "Не указан или неверно указан ID кассового аппарата\n";
70 return undef;
71 }
72 $self->{test_mode} = exists $params{test_mode} ? $params{test_mode} : $state->{money}->{$prefix."_test_mode"};
73 $self->{return_url} = $params{return_url} || $state->{money}{$prefix."_return_url"};
74 $self->{fail_url} = $params{fail_url} || $state->{money}{$prefix."_fail_url"};
75
76 $self->{currency} = $state->{money}{$prefix."_currency_code"};
77
78 694 ahitrov $self->{base_url} = 'https://'. ($self->{test_mode} ? 'private-anon-f6c2f7b545-kabinet.apiary-mock.com' : 'kabinet.dreamkas.ru').'/api';
79 693 ahitrov $self->{result} = {};
80
81 bless $self, $class;
82 return $self;
83 }
84
85
86 =for rem RECEIPT
87 # Фискализация чека (только для Дримкас-Ф)
88
89 $mm->receipt({
90 # обязательные:
91 order => webshop::Order
92 # или
93 order_id => ID от webshop::Order
94 total => общая сумма заказа, если не передан order
95
96 profile => Профиль пользователя, объект
97 # или
98 attributes => Атрибуты, пример внизу
99 # или
100 email => E-mail
101 phone => Phone в формате +79163332222
102
103 # необязательные:
104 basket => Если есть order или order_id, можно не передавать
105 # или
106 positions => ARRAY_REF, если не передан order или basket
107
108 type => SALE || REFUND || OUTFLOW || OUTFLOW_REFUND || SALE
109 timeout => Таймаут фискализации в секундах (по умолчанию - 300 секунд).
110 Если в течение этого времени не удастся произвести фискализацию,
111 то операция будет отменена с ошибкой.
112 payment_type => CASH || CASHLESS
113 715 ahitrov # или
114 payments => ARRAY_REF по стандарту примера
115 734 ahitrov
116 dryrun => Только заполнение полей, без отправки по API
117 693 ahitrov });
118
119 JSON тела вызова:
120
121 {
122 "deviceId": 1385,
123 "type": "SALE",
124 "timeout": 180,
125 "taxMode": "DEFAULT",
126 "positions": [
127 {
128 "name": "Шоколад Сникерс",
129 "type": "COUNTABLE",
130 "quantity": 2,
131 "price": 4500,
132 "priceSum": 9000,
133 "tax": "NDS_18",
134 "taxSum": 1620
135 }
136 ],
137 "payments": [
138 {
139 "sum": 9000,
140 "type": "CASHLESS"
141 }
142 ],
143 "attributes": {
144 "email": "john.smith@example.com",
145 "phone": "+71239994499"
146 },
147 "total": {
148 "priceSum": 9000
149 }
150 }
151
152
153 Результат:
154
155 {
156 "id": "5956889136fdd7733f19cfe6",
157 "createdAt": "2017-06-20 12:01:47.990Z",
158 "status": "PENDING"
159 }
160
161 status:
162
163 PENDING - В обработке
164 IN_PROGRESS - Задача принята в обработку (например, устройство приняло чек на фискализацию)
165 SUCCESS - Завершено успешно
166 ERROR - Завершено с ошибкой
167
168 =cut
169 ##########################################################
170 sub receipt {
171 my $self = shift;
172 my $opts = shift // {};
173
174 my $type = delete $opts->{type};
175 if ( $type && $type =~ /^(SALE|REFUND|OUTFLOW|OUTFLOW_REFUND)$/ ) {
176 $self->{result}{error} = 'Неверно указан тип операции';
177 return $self;
178 }
179 715 ahitrov $type ||= 'SALE';
180 693 ahitrov
181 my $data = {
182 type => $type,
183 deviceId=> $self->{device_id},
184 taxMode => $self->{tax_mode},
185 };
186
187 if ( exists $opts->{order_id} ) {
188 $opts->{order} = $keeper->{webshop}->get_orders( id => $opts->{order_id} );
189 unless ( ref $opts->{order} eq 'webshop::Order' ) {
190 $self->{result}{error} = 'Заказ не найден. Передан неверный order_id';
191 return $self;
192 }
193 }
194
195 701 ahitrov my $MR;
196 693 ahitrov if ( exists $opts->{order} ) {
197 701 ahitrov $MR = $self->_GetLastMoneyReceipt( $opts->{order}->id );
198 693 ahitrov }
199 734 ahitrov if ( ref $MR && $MR->session_id && $MR->name eq $type ) {
200 701 ahitrov $self->{result}{receipt} = $MR;
201 693 ahitrov return $self;
202 }
203 701 ahitrov unless ( $MR ) {
204 $MR = money::Receipt->new( $keeper );
205 $MR->name( $opts->{type} );
206 $MR->provider( $self->{prefix} );
207 $MR->status( $self->{test_mode} );
208 $MR->success( 0 );
209 693 ahitrov if ( ref $opts->{order} ) {
210 701 ahitrov $MR->order_id( $opts->{order}->id );
211 693 ahitrov }
212 701 ahitrov $MR->currency_code( $self->{currency} );
213 693 ahitrov }
214
215 if ( exists $opts->{order} && !exists $opts->{basket} ) {
216 $opts->{basket} = $keeper->{webshop}->get_basket( order_id => $opts->{order}->id, with_products => 1 );
217 unless ( ref $opts->{basket} eq 'ARRAY' && @{$opts->{basket}} ) {
218 $self->{result}{error} = 'Невозможно получить список товарных позиций в заказе';
219 return $self;
220 }
221 }
222
223 if ( exists $opts->{basket} && ref $opts->{basket} eq 'ARRAY' ) {
224 my $positions = [];
225 703 ahitrov my $discount = 0;
226 if ( ref $opts->{order} && $opts->{order}->sum_discount > 0 ) {
227 $discount = $opts->{order}->sum_discount / $opts->{order}->sum;
228 }
229 733 ahitrov my $order_sum = int(($opts->{order}->sum - $opts->{order}->sum_discount) * 100);
230 my $counted_sum = 0;
231 693 ahitrov foreach my $bi ( @{$opts->{basket}} ) {
232 my $item = $bi->{item};
233 next unless ref $item;
234 703 ahitrov my $price = int(($bi->{item}->price - $discount * $bi->{item}->price) * 100);
235 693 ahitrov my $pos = {
236 name => $bi->name,
237 type => 'COUNTABLE',
238 quantity => $bi->number,
239 price => $price,
240 priceSum => $price * $bi->number,
241 tax => $self->{tax_nds},
242 taxSum => ($price * $bi->number) * $TAX_NDS{$self->{tax_nds}},
243 };
244 push @$positions, $pos;
245 733 ahitrov $counted_sum += $pos->{priceSum};
246 693 ahitrov }
247 733 ahitrov # Может быть разница в копейках между итоговой суммой и суммой по товарам. Её необходимо устранить
248 734 ahitrov my $diff = $order_sum - $counted_sum;
249 733 ahitrov if ( $diff != 0 ) {
250 foreach my $pos ( @$positions ) {
251 if ( $pos->{quantity} == 1 && ($pos->{priceSum} + $diff) > 0 ) {
252 $pos->{price} += $diff;
253 $pos->{priceSum} += $diff;
254 $diff = 0;
255 last;
256 }
257 }
258 if ( $diff != 0 ) {
259 my $pos = $positions->[-1];
260 $pos->{priceSum} += $diff;
261 if ( int($diff / $pos->{quantity}) > 1 ) {
262 $pos->{price} += int($diff / $pos->{quantity});
263 }
264 }
265 }
266 693 ahitrov unless ( @$positions ) {
267 703 ahitrov $self->{result}{error} = 'Cписок товарных позиций в заказе неверный. Возможно, в состав корзины не включены товары';
268 693 ahitrov return $self;
269 }
270 703 ahitrov if ( ref $opts->{order} && $opts->{order}->sum_delivery > 0 ) {
271 my $price = int($opts->{order}->sum_delivery * 100);
272 my $pos = {
273 name => 'Доставка',
274 type => 'COUNTABLE',
275 quantity => 1,
276 price => $price,
277 priceSum => $price,
278 tax => $self->{tax_nds},
279 taxSum => $price * $TAX_NDS{$self->{tax_nds}},
280 };
281 push @$positions, $pos;
282 }
283 693 ahitrov $data->{positions} = $positions;
284 } elsif ( exists $opts->{positions} && ref $opts->{positions} eq 'ARRAY' && @{$opts->{positions}} ) {
285 $data->{positions} = $opts->{positions};
286 }
287
288 # Заполняем атрибуты плательщика
289 if ( exists $opts->{profile} && ref $opts->{profile} eq $state->{users}->profile_document_class ) {
290 my $profile = $opts->{profile};
291 my $email = $profile->email;
292 my $attributes = { email => "$email" };
293 $data->{attributes} = $attributes;
294 } elsif ( exists $opts->{attributes} ) {
295 $data->{attributes} = $data->{attributes};
296 } elsif ( exists $opts->{email} || $opts->{phone} ) {
297 my $attributes = {};
298 if ( exists $opts->{email} && $opts->{email} ) {
299 if ( ref $opts->{email} ) {
300 694 ahitrov $attributes->{email} = $opts->{email}->name;
301 693 ahitrov } else {
302 694 ahitrov $attributes->{email} = $opts->{email};
303 693 ahitrov }
304 }
305 if ( exists $opts->{phone} && $opts->{phone} ) {
306 if ( ref $opts->{phone} ) {
307 694 ahitrov $attributes->{phone} = $opts->{phone}->name;
308 693 ahitrov } else {
309 694 ahitrov $attributes->{phone} = $opts->{phone};
310 693 ahitrov }
311 }
312 $data->{attributes} = $attributes;
313 715 ahitrov } elsif ( exists $opts->{order} && exists $keeper->{users} ) {
314 my $profile = $keeper->{users}->get_profile( id => $opts->{order}->uid );
315 if ( ref $profile ) {
316 my $email = $profile->email;
317 my $attributes = { email => "$email" };
318 $data->{attributes} = $attributes;
319 }
320 693 ahitrov }
321
322 # Заполняем параметры оплаты: total и payments
323 if ( exists $opts->{order} && ref $opts->{order} eq 'webshop::Order' ) {
324 703 ahitrov $data->{total}{priceSum} = int($opts->{order}->sum_total * 100);
325 693 ahitrov } else {
326 if ( $opts->{total} ) {
327 $data->{total}{priceSum} = $opts->{total};
328 }
329 }
330 unless ( $data->{total}{priceSum} ) {
331 $self->{result}{error} = 'Не указана итоговая сумма. Необходимо передать параметр total или order';
332 return $self;
333 }
334 703 ahitrov $MR->sum( sprintf("%.2f", $data->{total}{priceSum} / 100) );
335 715 ahitrov
336 if ( exists $opts->{payments} && ref $opts->{payments} eq 'ARRAY' ) {
337 $data->{payments} = $opts->{payments};
338 } else {
339 my $payment = {sum => $data->{total}{priceSum}};
340 if ( exists $opts->{payment_type} ) {
341 $payment->{type} = $opts->{payment_type};
342 } else {
343 $payment->{type} = 'CASHLESS';
344 }
345 $data->{payments} = [$payment];
346 693 ahitrov }
347
348 694 ahitrov my $api_url = 'receipts';
349 693 ahitrov
350 715 ahitrov warn "DREAMKAS receipt data: ".Data::Dumper::Dumper( $data ) if $DEBUG;
351 734 ahitrov unless ( $opts->{dryrun} ) {
352 $self->_MakeRequest( $api_url, 'post', $data );
353 warn Data::Dumper::Dumper( $self->{result} ) if $DEBUG;
354 if ( $self->{result}{code} == 202 || $self->{result}{code} == 200 ) {
355 $MR->success( $OP_STATUS{$self->{result}{content}{status}} );
356 $MR->session_id( $self->{result}{content}{id} );
357 $MR->store;
358 $self->{result}{receipt} = $MR;
359 } else {
360 $self->{result}{error} = $self->{result}{status};
361 }
362 693 ahitrov } else {
363 734 ahitrov warn "Dry run\n";
364 $self->{data} = $data;
365 693 ahitrov }
366
367 return $self;
368 }
369
370
371 698 ahitrov =for rem CHECK
372 693 ahitrov # Информация о статусе операции
373
374 704 ahitrov $mm->check({ operation_id => $operation_id });
375 $mm->check({ receipt => $receipt });
376 693 ahitrov
377 Передается ID, полученное на этапе запроса на фискализацию чека
378
379 732 ahitrov Дополнительные параметры:
380
381 dryrun => undef - проверка API без сохранения результата
382
383 693 ahitrov Результат:
384
385 {
386 "id": "5956889136fdd7733f19cfe6",
387 "createdAt": "2017-06-20 12:01:47.990Z",
388 "status": "ERROR",
389 "completedAt": "2017-06-20 12:03:12.440Z",
390 "data": {
391 "error": {
392 "code": "NeedUpdateCash",
393 "message": "Требуется обновление кассы"
394 }
395 }
396 }
397
398 status:
399
400 PENDING - В обработке
401 IN_PROGRESS - Задача принята в обработку (например, устройство приняло чек на фискализацию)
402 SUCCESS - Завершено успешно
403 ERROR - Завершено с ошибкой
404
405 =cut
406 ##########################################################
407 sub check {
408 my $self = shift;
409 my $opts = shift // {};
410 if ( exists $self->{result} && exists $self->{result}{error} ) {
411 return $self;
412 }
413
414 701 ahitrov my $MR;
415 if ( exists $self->{result}{receipt} ) {
416 $MR = $self->{result}{receipt};
417 } elsif ( exists $opts->{receipt} ) {
418 $MR = $opts->{receipt};
419 693 ahitrov } elsif ( $opts->{operation_id} ) {
420 701 ahitrov ($MR) = $self->_GetReceiptByOperationId( $opts->{operation_id} );
421 693 ahitrov }
422 701 ahitrov unless ( ref $MR ) {
423 693 ahitrov $self->{result}{error} = 'Не найден объект "движение денежных средств". Проверьте входные параметры';
424 return $self;
425 }
426
427 701 ahitrov my $api_url = 'operations/'.$MR->session_id;
428 693 ahitrov
429 $self->_MakeRequest( $api_url, 'get' );
430 703 ahitrov warn Data::Dumper::Dumper( $self->{result} ) if $DEBUG;
431 693 ahitrov if ( $self->{result}{code} == 200 ) {
432 if ( $self->{result}{content}{status} eq 'ERROR' ) {
433 705 ahitrov $self->{result}{error} = Encode::encode('utf-8', $self->{result}{content}{data}{error}{message});
434 693 ahitrov $self->{result}{code} = $self->{result}{content}{data}{error}{code};
435 718 ahitrov $MR->comment( $self->{result}{error} );
436 693 ahitrov }
437 718 ahitrov $MR->success( $OP_STATUS{$self->{result}{content}{status}} );
438 732 ahitrov unless ( $opts->{dryrun} ) {
439 $MR->store;
440 }
441 718 ahitrov $self->{result}{receipt} = $MR;
442 693 ahitrov } else {
443 $self->{result}{error} = $self->{result}{status};
444 }
445
446 return $self;
447 }
448
449
450 sub _MakeRequest {
451 my ($self, $url, $type, $body) = @_;
452 $type ||= 'post';
453
454 my $ua = LWP::UserAgent->new;
455 $ua->timeout( 10 );
456 $ua->agent('Mozilla/5.0');
457
458 695 ahitrov if ( exists $self->{token} && $self->{token} ) {
459 715 ahitrov warn "Auth by token: ".$self->{token}."\n" if $DEBUG;
460 695 ahitrov $ua->default_header( 'Authorization' => "Bearer ".$self->{token} );
461 } elsif ( $self->{app_id} && $self->{secret} ) {
462 715 ahitrov warn "Auth by app id: ".$self->{app_id}."|".$self->{secret}."\n" if $DEBUG;
463 695 ahitrov my $auth = encode_base64($self->{app_id}.':'.$self->{secret});
464 715 ahitrov warn "base64: $auth\n" if $DEBUG;
465 695 ahitrov $ua->default_header( 'Authorization' => "Application: {$auth}" );
466 }
467 693 ahitrov $ua->default_header( 'Content-Type' => 'application/json' );
468
469 if ( ref $body ) {
470 $body = encode_json( $body );
471 }
472
473 715 ahitrov my $uri = URI->new( $self->{base_url}.($url =~ /^\// ? '' : '/').$url );
474
475 693 ahitrov my $res;
476 if ( $type eq 'post' ) {
477 715 ahitrov my $req = HTTP::Request->new(POST => $uri);
478 $req->content_type('application/json');
479 $req->content($body);
480 warn "DREAMKAS post JSON: $body\n" if $DEBUG;
481 $res = $ua->request($req);
482 693 ahitrov } elsif ( $type eq 'delete' ) {
483 715 ahitrov $res = $ua->delete( $uri );
484 693 ahitrov } else {
485 715 ahitrov $res = $ua->get( $uri );
486 693 ahitrov }
487 $self->{result} = {
488 code => $res->code,
489 status => $res->status_line,
490 715 ahitrov content => Data::Recursive::Encode->encode_utf8( JSON::XS->new->utf8->decode( $res->decoded_content ) ),
491 694 ahitrov };
492 693 ahitrov return $self;
493 }
494
495 701 ahitrov sub _GetLastMoneyReceipt {
496 693 ahitrov my $self = shift;
497 my $order_id = shift;
498 my ($mm) = $keeper->get_documents(
499 701 ahitrov class => 'money::Receipt',
500 693 ahitrov limit => 1,
501 694 ahitrov provider => $self->{prefix},
502 order_id => $order_id,
503 693 ahitrov order_by => 'id desc',
504 );
505 return $mm;
506 }
507
508 701 ahitrov sub _GetReceiptByOperationId {
509 693 ahitrov my $self = shift;
510 my $op_id = shift;
511 my ($mm) = $keeper->get_documents(
512 701 ahitrov class => 'money::Receipt',
513 693 ahitrov limit => 1,
514 694 ahitrov provider => $self->{prefix},
515 693 ahitrov session_id => $op_id,
516 );
517 return $mm;
518 }
519
520 1;