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