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 693 ahitrov });
116
117 JSON тела вызова:
118
119 {
120 "deviceId": 1385,
121 "type": "SALE",
122 "timeout": 180,
123 "taxMode": "DEFAULT",
124 "positions": [
125 {
126 "name": "Шоколад Сникерс",
127 "type": "COUNTABLE",
128 "quantity": 2,
129 "price": 4500,
130 "priceSum": 9000,
131 "tax": "NDS_18",
132 "taxSum": 1620
133 }
134 ],
135 "payments": [
136 {
137 "sum": 9000,
138 "type": "CASHLESS"
139 }
140 ],
141 "attributes": {
142 "email": "john.smith@example.com",
143 "phone": "+71239994499"
144 },
145 "total": {
146 "priceSum": 9000
147 }
148 }
149
150
151 Результат:
152
153 {
154 "id": "5956889136fdd7733f19cfe6",
155 "createdAt": "2017-06-20 12:01:47.990Z",
156 "status": "PENDING"
157 }
158
159 status:
160
161 PENDING - В обработке
162 IN_PROGRESS - Задача принята в обработку (например, устройство приняло чек на фискализацию)
163 SUCCESS - Завершено успешно
164 ERROR - Завершено с ошибкой
165
166 =cut
167 ##########################################################
168 sub receipt {
169 my $self = shift;
170 my $opts = shift // {};
171
172 my $type = delete $opts->{type};
173 if ( $type && $type =~ /^(SALE|REFUND|OUTFLOW|OUTFLOW_REFUND)$/ ) {
174 $self->{result}{error} = 'Неверно указан тип операции';
175 return $self;
176 }
177 715 ahitrov $type ||= 'SALE';
178 693 ahitrov
179 my $data = {
180 type => $type,
181 deviceId=> $self->{device_id},
182 taxMode => $self->{tax_mode},
183 };
184
185 if ( exists $opts->{order_id} ) {
186 $opts->{order} = $keeper->{webshop}->get_orders( id => $opts->{order_id} );
187 unless ( ref $opts->{order} eq 'webshop::Order' ) {
188 $self->{result}{error} = 'Заказ не найден. Передан неверный order_id';
189 return $self;
190 }
191 }
192
193 701 ahitrov my $MR;
194 693 ahitrov if ( exists $opts->{order} ) {
195 701 ahitrov $MR = $self->_GetLastMoneyReceipt( $opts->{order}->id );
196 693 ahitrov }
197 701 ahitrov if ( ref $MR && $MR->session_id && $MR->name eq $opts->{type} ) {
198 $self->{result}{receipt} = $MR;
199 693 ahitrov return $self;
200 }
201 701 ahitrov unless ( $MR ) {
202 $MR = money::Receipt->new( $keeper );
203 $MR->name( $opts->{type} );
204 $MR->provider( $self->{prefix} );
205 $MR->status( $self->{test_mode} );
206 $MR->success( 0 );
207 693 ahitrov if ( ref $opts->{order} ) {
208 701 ahitrov $MR->order_id( $opts->{order}->id );
209 693 ahitrov }
210 701 ahitrov $MR->currency_code( $self->{currency} );
211 693 ahitrov }
212
213 if ( exists $opts->{order} && !exists $opts->{basket} ) {
214 $opts->{basket} = $keeper->{webshop}->get_basket( order_id => $opts->{order}->id, with_products => 1 );
215 unless ( ref $opts->{basket} eq 'ARRAY' && @{$opts->{basket}} ) {
216 $self->{result}{error} = 'Невозможно получить список товарных позиций в заказе';
217 return $self;
218 }
219 }
220
221 if ( exists $opts->{basket} && ref $opts->{basket} eq 'ARRAY' ) {
222 my $positions = [];
223 703 ahitrov my $discount = 0;
224 if ( ref $opts->{order} && $opts->{order}->sum_discount > 0 ) {
225 $discount = $opts->{order}->sum_discount / $opts->{order}->sum;
226 }
227 693 ahitrov foreach my $bi ( @{$opts->{basket}} ) {
228 my $item = $bi->{item};
229 next unless ref $item;
230 703 ahitrov my $price = int(($bi->{item}->price - $discount * $bi->{item}->price) * 100);
231 693 ahitrov my $pos = {
232 name => $bi->name,
233 type => 'COUNTABLE',
234 quantity => $bi->number,
235 price => $price,
236 priceSum => $price * $bi->number,
237 tax => $self->{tax_nds},
238 taxSum => ($price * $bi->number) * $TAX_NDS{$self->{tax_nds}},
239 };
240 push @$positions, $pos;
241 }
242 unless ( @$positions ) {
243 703 ahitrov $self->{result}{error} = 'Cписок товарных позиций в заказе неверный. Возможно, в состав корзины не включены товары';
244 693 ahitrov return $self;
245 }
246 703 ahitrov if ( ref $opts->{order} && $opts->{order}->sum_delivery > 0 ) {
247 my $price = int($opts->{order}->sum_delivery * 100);
248 my $pos = {
249 name => 'Доставка',
250 type => 'COUNTABLE',
251 quantity => 1,
252 price => $price,
253 priceSum => $price,
254 tax => $self->{tax_nds},
255 taxSum => $price * $TAX_NDS{$self->{tax_nds}},
256 };
257 push @$positions, $pos;
258 }
259 693 ahitrov $data->{positions} = $positions;
260 } elsif ( exists $opts->{positions} && ref $opts->{positions} eq 'ARRAY' && @{$opts->{positions}} ) {
261 $data->{positions} = $opts->{positions};
262 }
263
264 # Заполняем атрибуты плательщика
265 if ( exists $opts->{profile} && ref $opts->{profile} eq $state->{users}->profile_document_class ) {
266 my $profile = $opts->{profile};
267 my $email = $profile->email;
268 my $attributes = { email => "$email" };
269 $data->{attributes} = $attributes;
270 } elsif ( exists $opts->{attributes} ) {
271 $data->{attributes} = $data->{attributes};
272 } elsif ( exists $opts->{email} || $opts->{phone} ) {
273 my $attributes = {};
274 if ( exists $opts->{email} && $opts->{email} ) {
275 if ( ref $opts->{email} ) {
276 694 ahitrov $attributes->{email} = $opts->{email}->name;
277 693 ahitrov } else {
278 694 ahitrov $attributes->{email} = $opts->{email};
279 693 ahitrov }
280 }
281 if ( exists $opts->{phone} && $opts->{phone} ) {
282 if ( ref $opts->{phone} ) {
283 694 ahitrov $attributes->{phone} = $opts->{phone}->name;
284 693 ahitrov } else {
285 694 ahitrov $attributes->{phone} = $opts->{phone};
286 693 ahitrov }
287 }
288 $data->{attributes} = $attributes;
289 715 ahitrov } elsif ( exists $opts->{order} && exists $keeper->{users} ) {
290 my $profile = $keeper->{users}->get_profile( id => $opts->{order}->uid );
291 if ( ref $profile ) {
292 my $email = $profile->email;
293 my $attributes = { email => "$email" };
294 $data->{attributes} = $attributes;
295 }
296 693 ahitrov }
297
298 # Заполняем параметры оплаты: total и payments
299 if ( exists $opts->{order} && ref $opts->{order} eq 'webshop::Order' ) {
300 703 ahitrov $data->{total}{priceSum} = int($opts->{order}->sum_total * 100);
301 693 ahitrov } else {
302 if ( $opts->{total} ) {
303 $data->{total}{priceSum} = $opts->{total};
304 }
305 }
306 unless ( $data->{total}{priceSum} ) {
307 $self->{result}{error} = 'Не указана итоговая сумма. Необходимо передать параметр total или order';
308 return $self;
309 }
310 703 ahitrov $MR->sum( sprintf("%.2f", $data->{total}{priceSum} / 100) );
311 715 ahitrov
312 if ( exists $opts->{payments} && ref $opts->{payments} eq 'ARRAY' ) {
313 $data->{payments} = $opts->{payments};
314 } else {
315 my $payment = {sum => $data->{total}{priceSum}};
316 if ( exists $opts->{payment_type} ) {
317 $payment->{type} = $opts->{payment_type};
318 } else {
319 $payment->{type} = 'CASHLESS';
320 }
321 $data->{payments} = [$payment];
322 693 ahitrov }
323
324 694 ahitrov my $api_url = 'receipts';
325 693 ahitrov
326 715 ahitrov warn "DREAMKAS receipt data: ".Data::Dumper::Dumper( $data ) if $DEBUG;
327 693 ahitrov $self->_MakeRequest( $api_url, 'post', $data );
328 703 ahitrov warn Data::Dumper::Dumper( $self->{result} ) if $DEBUG;
329 695 ahitrov if ( $self->{result}{code} == 202 || $self->{result}{code} == 200 ) {
330 701 ahitrov $MR->success( $OP_STATUS{$self->{result}{content}{status}} );
331 $MR->session_id( $self->{result}{content}{id} );
332 $MR->store;
333 $self->{result}{receipt} = $MR;
334 693 ahitrov } else {
335 $self->{result}{error} = $self->{result}{status};
336 }
337
338 return $self;
339 }
340
341
342 698 ahitrov =for rem CHECK
343 693 ahitrov # Информация о статусе операции
344
345 704 ahitrov $mm->check({ operation_id => $operation_id });
346 $mm->check({ receipt => $receipt });
347 693 ahitrov
348 Передается ID, полученное на этапе запроса на фискализацию чека
349
350 732 ahitrov Дополнительные параметры:
351
352 dryrun => undef - проверка API без сохранения результата
353
354 693 ahitrov Результат:
355
356 {
357 "id": "5956889136fdd7733f19cfe6",
358 "createdAt": "2017-06-20 12:01:47.990Z",
359 "status": "ERROR",
360 "completedAt": "2017-06-20 12:03:12.440Z",
361 "data": {
362 "error": {
363 "code": "NeedUpdateCash",
364 "message": "Требуется обновление кассы"
365 }
366 }
367 }
368
369 status:
370
371 PENDING - В обработке
372 IN_PROGRESS - Задача принята в обработку (например, устройство приняло чек на фискализацию)
373 SUCCESS - Завершено успешно
374 ERROR - Завершено с ошибкой
375
376 =cut
377 ##########################################################
378 sub check {
379 my $self = shift;
380 my $opts = shift // {};
381 if ( exists $self->{result} && exists $self->{result}{error} ) {
382 return $self;
383 }
384
385 701 ahitrov my $MR;
386 if ( exists $self->{result}{receipt} ) {
387 $MR = $self->{result}{receipt};
388 } elsif ( exists $opts->{receipt} ) {
389 $MR = $opts->{receipt};
390 693 ahitrov } elsif ( $opts->{operation_id} ) {
391 701 ahitrov ($MR) = $self->_GetReceiptByOperationId( $opts->{operation_id} );
392 693 ahitrov }
393 701 ahitrov unless ( ref $MR ) {
394 693 ahitrov $self->{result}{error} = 'Не найден объект "движение денежных средств". Проверьте входные параметры';
395 return $self;
396 }
397
398 701 ahitrov my $api_url = 'operations/'.$MR->session_id;
399 693 ahitrov
400 $self->_MakeRequest( $api_url, 'get' );
401 703 ahitrov warn Data::Dumper::Dumper( $self->{result} ) if $DEBUG;
402 693 ahitrov if ( $self->{result}{code} == 200 ) {
403 if ( $self->{result}{content}{status} eq 'ERROR' ) {
404 705 ahitrov $self->{result}{error} = Encode::encode('utf-8', $self->{result}{content}{data}{error}{message});
405 693 ahitrov $self->{result}{code} = $self->{result}{content}{data}{error}{code};
406 718 ahitrov $MR->comment( $self->{result}{error} );
407 693 ahitrov }
408 718 ahitrov $MR->success( $OP_STATUS{$self->{result}{content}{status}} );
409 732 ahitrov unless ( $opts->{dryrun} ) {
410 $MR->store;
411 }
412 718 ahitrov $self->{result}{receipt} = $MR;
413 693 ahitrov } else {
414 $self->{result}{error} = $self->{result}{status};
415 }
416
417 return $self;
418 }
419
420
421 sub _MakeRequest {
422 my ($self, $url, $type, $body) = @_;
423 $type ||= 'post';
424
425 my $ua = LWP::UserAgent->new;
426 $ua->timeout( 10 );
427 $ua->agent('Mozilla/5.0');
428
429 695 ahitrov if ( exists $self->{token} && $self->{token} ) {
430 715 ahitrov warn "Auth by token: ".$self->{token}."\n" if $DEBUG;
431 695 ahitrov $ua->default_header( 'Authorization' => "Bearer ".$self->{token} );
432 } elsif ( $self->{app_id} && $self->{secret} ) {
433 715 ahitrov warn "Auth by app id: ".$self->{app_id}."|".$self->{secret}."\n" if $DEBUG;
434 695 ahitrov my $auth = encode_base64($self->{app_id}.':'.$self->{secret});
435 715 ahitrov warn "base64: $auth\n" if $DEBUG;
436 695 ahitrov $ua->default_header( 'Authorization' => "Application: {$auth}" );
437 }
438 693 ahitrov $ua->default_header( 'Content-Type' => 'application/json' );
439
440 if ( ref $body ) {
441 $body = encode_json( $body );
442 }
443
444 715 ahitrov my $uri = URI->new( $self->{base_url}.($url =~ /^\// ? '' : '/').$url );
445
446 693 ahitrov my $res;
447 if ( $type eq 'post' ) {
448 715 ahitrov my $req = HTTP::Request->new(POST => $uri);
449 $req->content_type('application/json');
450 $req->content($body);
451 warn "DREAMKAS post JSON: $body\n" if $DEBUG;
452 $res = $ua->request($req);
453 693 ahitrov } elsif ( $type eq 'delete' ) {
454 715 ahitrov $res = $ua->delete( $uri );
455 693 ahitrov } else {
456 715 ahitrov $res = $ua->get( $uri );
457 693 ahitrov }
458 $self->{result} = {
459 code => $res->code,
460 status => $res->status_line,
461 715 ahitrov content => Data::Recursive::Encode->encode_utf8( JSON::XS->new->utf8->decode( $res->decoded_content ) ),
462 694 ahitrov };
463 693 ahitrov return $self;
464 }
465
466 701 ahitrov sub _GetLastMoneyReceipt {
467 693 ahitrov my $self = shift;
468 my $order_id = shift;
469 my ($mm) = $keeper->get_documents(
470 701 ahitrov class => 'money::Receipt',
471 693 ahitrov limit => 1,
472 694 ahitrov provider => $self->{prefix},
473 order_id => $order_id,
474 693 ahitrov order_by => 'id desc',
475 );
476 return $mm;
477 }
478
479 701 ahitrov sub _GetReceiptByOperationId {
480 693 ahitrov my $self = shift;
481 my $op_id = shift;
482 my ($mm) = $keeper->get_documents(
483 701 ahitrov class => 'money::Receipt',
484 693 ahitrov limit => 1,
485 694 ahitrov provider => $self->{prefix},
486 693 ahitrov session_id => $op_id,
487 );
488 return $mm;
489 }
490
491 1;