Revision 695 (by ahitrov, 2018/08/13 17:52:19) Token auth

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->{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 "Неверная мнемоника типа налоговой системы\n";
	return undef;
    }
    $self->{tax_nds} =		$state->{money}{$prefix."_tax_nds"};
    unless ( exists $TAX_NDS{$self->{tax_nds}} ) {
	warn "Неверная мнемоника типа НДС\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 $MM;
    if ( exists $opts->{order} ) {
	$MM = $self->_GetLastMoneyCheck( $opts->{order}->id );
    }
    if ( ref $MM && $MM->session_id && $MM->name eq $opts->{type} ) {
	$self->{result}{money_movement} = $MM;
	return $self;
    }
    unless ( $MM ) {
	$MM = money::Check->new( $keeper );
	$MM->name( $opts->{type} );
	$MM->provider( $self->{prefix} );
	$MM->status( $self->{test_mode} );
	$MM->success( 0 );
	if ( ref $opts->{order} ) {
		$MM->order_id( $opts->{order}->id );
	}
	$MM->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 = [];
	foreach my $bi ( @{$opts->{basket}} ) {
		my $item = $bi->{item};
		next	unless ref $item;
		my $price = int($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;
	}
	$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 * 10);
    } 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};
    $MM->sum( $data->{total}{priceSum} );
    if ( exists $opts->{payment_type} && $opts->{payment_type} eq 'CASH' ) {
	$data->{payments}{type} = 'CASH';
    }

    my $api_url = 'receipts';

    $self->_MakeRequest( $api_url, 'post', $data );
    if ( $self->{result}{code} == 202 || $self->{result}{code} == 200 ) {
	$MM->success( $OP_STATUS{$self->{result}{content}{status}} );
	$MM->session_id( $self->{result}{content}{id} );
	$MM->store;
	$self->{result}{money_movement} = $MM;
    } else {
	$self->{result}{error} = $self->{result}{status};
    }

    return $self;
}


=for rem RECEIPT
# Информация о статусе операции

$mm->check( $operation_id );

Передается 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 $MM;
    if ( exists $self->{result}{money_movement} ) {
	$MM = $self->{result}{money_movement};
    } elsif ( exists $opts->{money_movement} ) {
	$MM = $opts->{money_movement};
    } elsif ( $opts->{operation_id} ) {
	($MM) = $self->_GetCheckByOperationId( $opts->{operation_id} );
    }
    unless ( ref $MM ) {
	$self->{result}{error} = 'Не найден объект "движение денежных средств". Проверьте входные параметры';
	return $self;
    }

    my $api_url = 'operations/'.$MM->session_id;

    $self->_MakeRequest( $api_url, 'get' );
    if ( $self->{result}{code} == 200 ) {
	$MM->success( $OP_STATUS{$self->{result}{content}{status}} );
	$MM->store;
	$self->{result}{money_movement} = $MM;
	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 _GetLastMoneyCheck {
    my $self = shift;
    my $order_id = shift;
    my ($mm) = $keeper->get_documents(
		class	=> 'money::Check',
		limit	=> 1,
		provider	=> $self->{prefix},
		order_id	=> $order_id,
		order_by	=> 'id desc',
	);
    return $mm;
}

sub _GetCheckByOperationId {
    my $self = shift;
    my $op_id = shift;
    my ($mm) = $keeper->get_documents(
		class	=> 'money::Check',
		limit	=> 1,
		provider	=> $self->{prefix},
		session_id	=> $op_id,
	);
    return $mm;
}

1;