package money::Provider::Dreamkas; use strict; use warnings 'all'; use base 'money::Provider::Base'; use Contenido::Globals; use money::Keeper; use MIME::Base64; use URI; use URI::QueryParam; use JSON::XS; use Data::Dumper; use constant ( TAXMODE_DEFAULT => 'DEFAULT', ); our %TAX_MODES = ( 'DEFAULT' => 1, # Общая 'SIMPLE' => 1, # Упрощенная доход 'SIMPLE_WO' => 1, # Упрощенная доход минус расход 'ENVD' => 1, # Единый налог на вмененный доход 'AGRICULT' => 1, # Единый сельскохозяйственный 'PATENT' => 1, # Патентная система налогообложения ); our %TAX_NDS = ( 'NDS_NO_TAX' => 0, 'NDS_0' => 0, 'NDS_10' => 0.10, 'NDS_18' => 0.18, 'NDS_20' => 0.20, 'NDS_10_CALCULATED' => 0.10/110, 'NDS_18_CALCULATED' => 0.10/110, ); our %OP_STATUS = ( 'PENDING' => 0, 'IN_PROGRESS' => 1, 'SUCCESS' => 2, 'ERROR' => -1, ); sub new { my ($proto, %params) = @_; my $class = ref($proto) || $proto; my $self = {}; my $prefix = $class =~ /\:\:(\w+)$/ ? lc($1) : undef; return unless $prefix; $self->{prefix} = $prefix; $self->{provider} = $prefix; $self->{app_id} = $state->{money}{$prefix."_app_id"}; $self->{secret} = $state->{money}{$prefix."_app_secret"}; $self->{token} = $state->{money}{$prefix."_app_token"}; $self->{tax_mode} = $state->{money}{$prefix."_tax_mode"}; unless ( exists $TAX_MODES{$self->{tax_mode}} ) { warn "Неверная мнемоника типа налоговой системы [".$self->{tax_mode}."]\n"; return undef; } $self->{tax_nds} = $state->{money}{$prefix."_tax_nds"}; unless ( exists $TAX_NDS{$self->{tax_nds}} ) { warn "Неверная мнемоника типа НДС [".$self->{tax_nds}."]\n"; return undef; } $self->{device_id} = $state->{money}{$prefix."_device_id"}; unless ( $self->{device_id} ) { warn "Не указан или неверно указан ID кассового аппарата\n"; return undef; } $self->{test_mode} = exists $params{test_mode} ? $params{test_mode} : $state->{money}->{$prefix."_test_mode"}; $self->{return_url} = $params{return_url} || $state->{money}{$prefix."_return_url"}; $self->{fail_url} = $params{fail_url} || $state->{money}{$prefix."_fail_url"}; $self->{currency} = $state->{money}{$prefix."_currency_code"}; $self->{base_url} = 'https://'. ($self->{test_mode} ? 'private-anon-f6c2f7b545-kabinet.apiary-mock.com' : 'kabinet.dreamkas.ru').'/api'; $self->{result} = {}; bless $self, $class; return $self; } =for rem RECEIPT # Фискализация чека (только для Дримкас-Ф) $mm->receipt({ # обязательные: order => webshop::Order # или order_id => ID от webshop::Order total => общая сумма заказа, если не передан order profile => Профиль пользователя, объект # или attributes => Атрибуты, пример внизу # или email => E-mail phone => Phone в формате +79163332222 # необязательные: basket => Если есть order или order_id, можно не передавать # или positions => ARRAY_REF, если не передан order или basket type => SALE || REFUND || OUTFLOW || OUTFLOW_REFUND || SALE timeout => Таймаут фискализации в секундах (по умолчанию - 300 секунд). Если в течение этого времени не удастся произвести фискализацию, то операция будет отменена с ошибкой. payment_type => CASH || CASHLESS }); JSON тела вызова: { "deviceId": 1385, "type": "SALE", "timeout": 180, "taxMode": "DEFAULT", "positions": [ { "name": "Шоколад Сникерс", "type": "COUNTABLE", "quantity": 2, "price": 4500, "priceSum": 9000, "tax": "NDS_18", "taxSum": 1620 } ], "payments": [ { "sum": 9000, "type": "CASHLESS" } ], "attributes": { "email": "john.smith@example.com", "phone": "+71239994499" }, "total": { "priceSum": 9000 } } Результат: { "id": "5956889136fdd7733f19cfe6", "createdAt": "2017-06-20 12:01:47.990Z", "status": "PENDING" } status: PENDING - В обработке IN_PROGRESS - Задача принята в обработку (например, устройство приняло чек на фискализацию) SUCCESS - Завершено успешно ERROR - Завершено с ошибкой =cut ########################################################## sub receipt { my $self = shift; my $opts = shift // {}; my $type = delete $opts->{type}; if ( $type && $type =~ /^(SALE|REFUND|OUTFLOW|OUTFLOW_REFUND)$/ ) { $self->{result}{error} = 'Неверно указан тип операции'; return $self; } $opts->{type} ||= 'SALE'; my $data = { type => $type, deviceId=> $self->{device_id}, taxMode => $self->{tax_mode}, }; if ( exists $opts->{order_id} ) { $opts->{order} = $keeper->{webshop}->get_orders( id => $opts->{order_id} ); unless ( ref $opts->{order} eq 'webshop::Order' ) { $self->{result}{error} = 'Заказ не найден. Передан неверный order_id'; return $self; } } my $MR; if ( exists $opts->{order} ) { $MR = $self->_GetLastMoneyReceipt( $opts->{order}->id ); } if ( ref $MR && $MR->session_id && $MR->name eq $opts->{type} ) { $self->{result}{receipt} = $MR; return $self; } unless ( $MR ) { $MR = money::Receipt->new( $keeper ); $MR->name( $opts->{type} ); $MR->provider( $self->{prefix} ); $MR->status( $self->{test_mode} ); $MR->success( 0 ); if ( ref $opts->{order} ) { $MR->order_id( $opts->{order}->id ); } $MR->currency_code( $self->{currency} ); } if ( exists $opts->{order} && !exists $opts->{basket} ) { $opts->{basket} = $keeper->{webshop}->get_basket( order_id => $opts->{order}->id, with_products => 1 ); unless ( ref $opts->{basket} eq 'ARRAY' && @{$opts->{basket}} ) { $self->{result}{error} = 'Невозможно получить список товарных позиций в заказе'; return $self; } } if ( exists $opts->{basket} && ref $opts->{basket} eq 'ARRAY' ) { my $positions = []; my $discount = 0; if ( ref $opts->{order} && $opts->{order}->sum_discount > 0 ) { $discount = $opts->{order}->sum_discount / $opts->{order}->sum; } foreach my $bi ( @{$opts->{basket}} ) { my $item = $bi->{item}; next unless ref $item; my $price = int(($bi->{item}->price - $discount * $bi->{item}->price) * 100); my $pos = { name => $bi->name, type => 'COUNTABLE', quantity => $bi->number, price => $price, priceSum => $price * $bi->number, tax => $self->{tax_nds}, taxSum => ($price * $bi->number) * $TAX_NDS{$self->{tax_nds}}, }; push @$positions, $pos; } unless ( @$positions ) { $self->{result}{error} = 'Cписок товарных позиций в заказе неверный. Возможно, в состав корзины не включены товары'; return $self; } if ( ref $opts->{order} && $opts->{order}->sum_delivery > 0 ) { my $price = int($opts->{order}->sum_delivery * 100); my $pos = { name => 'Доставка', type => 'COUNTABLE', quantity => 1, price => $price, priceSum => $price, tax => $self->{tax_nds}, taxSum => $price * $TAX_NDS{$self->{tax_nds}}, }; push @$positions, $pos; } $data->{positions} = $positions; } elsif ( exists $opts->{positions} && ref $opts->{positions} eq 'ARRAY' && @{$opts->{positions}} ) { $data->{positions} = $opts->{positions}; } # Заполняем атрибуты плательщика if ( exists $opts->{profile} && ref $opts->{profile} eq $state->{users}->profile_document_class ) { my $profile = $opts->{profile}; my $email = $profile->email; my $attributes = { email => "$email" }; $data->{attributes} = $attributes; } elsif ( exists $opts->{attributes} ) { $data->{attributes} = $data->{attributes}; } elsif ( exists $opts->{email} || $opts->{phone} ) { my $attributes = {}; if ( exists $opts->{email} && $opts->{email} ) { if ( ref $opts->{email} ) { $attributes->{email} = $opts->{email}->name; } else { $attributes->{email} = $opts->{email}; } } if ( exists $opts->{phone} && $opts->{phone} ) { if ( ref $opts->{phone} ) { $attributes->{phone} = $opts->{phone}->name; } else { $attributes->{phone} = $opts->{phone}; } } $data->{attributes} = $attributes; } # Заполняем параметры оплаты: total и payments if ( exists $opts->{order} && ref $opts->{order} eq 'webshop::Order' ) { $data->{total}{priceSum} = int($opts->{order}->sum_total * 100); } else { if ( $opts->{total} ) { $data->{total}{priceSum} = $opts->{total}; } } unless ( $data->{total}{priceSum} ) { $self->{result}{error} = 'Не указана итоговая сумма. Необходимо передать параметр total или order'; return $self; } $data->{payments}{sum} = $data->{total}{priceSum}; $MR->sum( sprintf("%.2f", $data->{total}{priceSum} / 100) ); if ( exists $opts->{payment_type} && $opts->{payment_type} eq 'CASH' ) { $data->{payments}{type} = 'CASH'; } my $api_url = 'receipts'; $self->_MakeRequest( $api_url, 'post', $data ); warn Data::Dumper::Dumper( $self->{result} ) if $DEBUG; if ( $self->{result}{code} == 202 || $self->{result}{code} == 200 ) { $MR->success( $OP_STATUS{$self->{result}{content}{status}} ); $MR->session_id( $self->{result}{content}{id} ); $MR->store; $self->{result}{receipt} = $MR; } else { $self->{result}{error} = $self->{result}{status}; } return $self; } =for rem CHECK # Информация о статусе операции $mm->check({ operation_id => $operation_id }); $mm->check({ receipt => $receipt }); Передается ID, полученное на этапе запроса на фискализацию чека Результат: { "id": "5956889136fdd7733f19cfe6", "createdAt": "2017-06-20 12:01:47.990Z", "status": "ERROR", "completedAt": "2017-06-20 12:03:12.440Z", "data": { "error": { "code": "NeedUpdateCash", "message": "Требуется обновление кассы" } } } status: PENDING - В обработке IN_PROGRESS - Задача принята в обработку (например, устройство приняло чек на фискализацию) SUCCESS - Завершено успешно ERROR - Завершено с ошибкой =cut ########################################################## sub check { my $self = shift; my $opts = shift // {}; if ( exists $self->{result} && exists $self->{result}{error} ) { return $self; } my $MR; if ( exists $self->{result}{receipt} ) { $MR = $self->{result}{receipt}; } elsif ( exists $opts->{receipt} ) { $MR = $opts->{receipt}; } elsif ( $opts->{operation_id} ) { ($MR) = $self->_GetReceiptByOperationId( $opts->{operation_id} ); } unless ( ref $MR ) { $self->{result}{error} = 'Не найден объект "движение денежных средств". Проверьте входные параметры'; return $self; } my $api_url = 'operations/'.$MR->session_id; $self->_MakeRequest( $api_url, 'get' ); warn Data::Dumper::Dumper( $self->{result} ) if $DEBUG; if ( $self->{result}{code} == 200 ) { $MR->success( $OP_STATUS{$self->{result}{content}{status}} ); $MR->store; $self->{result}{receipt} = $MR; if ( $self->{result}{content}{status} eq 'ERROR' ) { $self->{result}{error} = $self->{result}{content}{data}{error}{message}; $self->{result}{code} = $self->{result}{content}{data}{error}{code}; } } else { $self->{result}{error} = $self->{result}{status}; } return $self; } sub _MakeRequest { my ($self, $url, $type, $body) = @_; $type ||= 'post'; my $ua = LWP::UserAgent->new; $ua->timeout( 10 ); $ua->agent('Mozilla/5.0'); if ( exists $self->{token} && $self->{token} ) { $ua->default_header( 'Authorization' => "Bearer ".$self->{token} ); } elsif ( $self->{app_id} && $self->{secret} ) { my $auth = encode_base64($self->{app_id}.':'.$self->{secret}); $ua->default_header( 'Authorization' => "Application: {$auth}" ); } $ua->default_header( 'Content-Type' => 'application/json' ); if ( ref $body ) { $body = encode_json( $body ); } my $req = URI->new( $self->{base_url}.($url =~ /^\// ? '' : '/').$url ); my $res; if ( $type eq 'post' ) { $res = $ua->post( $req, Content => $body ); } elsif ( $type eq 'delete' ) { $res = $ua->delete( $req ); } else { $res = $ua->get( $req ); } $self->{result} = { code => $res->code, status => $res->status_line, content => JSON::XS->new->decode( $res->decoded_content ), }; return $self; } sub _GetLastMoneyReceipt { my $self = shift; my $order_id = shift; my ($mm) = $keeper->get_documents( class => 'money::Receipt', limit => 1, provider => $self->{prefix}, order_id => $order_id, order_by => 'id desc', ); return $mm; } sub _GetReceiptByOperationId { my $self = shift; my $op_id = shift; my ($mm) = $keeper->get_documents( class => 'money::Receipt', limit => 1, provider => $self->{prefix}, session_id => $op_id, ); return $mm; } 1;