* SZEP card poller implementation added

* new tags for sms parsing
This commit is contained in:
Danyi Dávid 2018-03-17 18:39:30 +01:00
parent d73a24b1d6
commit 8b22f1d663
14 changed files with 1429 additions and 776 deletions

View File

@ -13,6 +13,7 @@
"php": "^7.1",
"dasprid/container-interop-doctrine": "^1.0",
"guzzlehttp/guzzle": "^6.3",
"http-interop/http-middleware": "^0.4.1",
"roave/security-advisories": "dev-master",
"zendframework/zend-component-installer": "^1.0",
"zendframework/zend-config-aggregator": "^1.0",

988
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -5,11 +5,13 @@ return [
'invokables' => [],
'factories' => [
App\Command\KoinImportCommand::class => App\Command\KoinImportCommandFactory::class,
App\Command\PeriodicSZEPCommand::class => App\Command\PeriodicSZEPCommandFactory::class,
],
],
'console' => [
'commands' => [
App\Command\KoinImportCommand::class,
App\Command\PeriodicSZEPCommand::class,
],
],
];

View File

@ -10,4 +10,7 @@
return [
'koin.user' => '',
'koin.pass' => '',
'szep.card' => '',
'szep.key' => '',
];

1
data/.gitignore vendored
View File

@ -1,2 +1,3 @@
*
!soap-xmls/*
!.gitignore

View File

@ -0,0 +1,36 @@
<?php
namespace App\Command;
use App\Service\SZEPManagerService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class PeriodicSZEPCommand extends Command
{
/**
* @var SZEPManagerService
*/
private $szepManager;
public function __construct(SZEPManagerService $szepManager)
{
$this->szepManager = $szepManager;
parent::__construct();
}
protected function configure()
{
$this->setName('szep:cron')
->setDescription('Parse SZEP card');
}
protected function execute(InputInterface $input, OutputInterface $output)
{
$result = $this->szepManager->pollRecent();
$output->writeln($result);
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Command;
use App\Service\SZEPManagerService;
use Interop\Container\ContainerInterface;
class PeriodicSZEPCommandFactory
{
/**
* @param ContainerInterface $container
* @return PeriodicSZEPCommand
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function __invoke(ContainerInterface $container)
{
/** @var SZEPManagerService $szepManager */
$szepManager = $container->get(SZEPManagerService::class);
return new PeriodicSZEPCommand($szepManager);
}
}

View File

@ -45,6 +45,7 @@ class ConfigProvider
Service\SmsStoreService::class => Service\SmsStoreServiceFactory::class,
Service\KoinService::class => Service\KoinServiceFactory::class,
Service\SZEPManagerService::class => Service\SZEPManagerServiceFactory::class,
],
];
}

View File

@ -0,0 +1,159 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Entity
* @ORM\Table(
* name="szep_card_entries",
* indexes={
* @ORM\Index(name="amount", columns={"amount"}),
* @ORM\Index(name="merchant", columns={"merchant"}),
* @ORM\Index(name="pocket", columns={"pocket"}),
* @ORM\Index(name="entry_date", columns={"entry_date"})
* }
* )
*/
class SZEPCardEntry implements \JsonSerializable
{
/**
* @ORM\Id
* @ORM\Column(name="hash", type="string", length=100, nullable=false)
* @var string
*/
private $hash;
/**
* @ORM\Column(name="amount", type="integer", length=200, nullable=false)
* @var int
*/
private $amount;
/**
* @ORM\Column(name="merchant", type="string", length=250, nullable=false)
* @var string
*/
private $merchant;
/**
* @ORM\Column(name="pocket", type="string", length=50, nullable=true)
* @var string
*/
private $pocket;
/**
* @ORM\Column(name="entry_date", type="date_immutable", nullable=false)
* @var \DateTimeImmutable
*/
private $date;
/**
* @return string
*/
public function getHash(): string
{
return $this->hash;
}
/**
* @param string $hash
* @return SZEPCardEntry
*/
public function setHash(string $hash): SZEPCardEntry
{
$this->hash = $hash;
return $this;
}
/**
* @return int
*/
public function getAmount(): int
{
return $this->amount;
}
/**
* @param int $amount
* @return SZEPCardEntry
*/
public function setAmount(int $amount): SZEPCardEntry
{
$this->amount = $amount;
return $this;
}
/**
* @return string
*/
public function getMerchant(): string
{
return $this->merchant;
}
/**
* @param string $merchant
* @return SZEPCardEntry
*/
public function setMerchant(string $merchant): SZEPCardEntry
{
$this->merchant = $merchant;
return $this;
}
/**
* @return string
*/
public function getPocket(): string
{
return $this->pocket;
}
/**
* @param string $pocket
* @return SZEPCardEntry
*/
public function setPocket(string $pocket): SZEPCardEntry
{
$this->pocket = $pocket;
return $this;
}
/**
* @return \DateTimeImmutable
*/
public function getDate(): \DateTimeImmutable
{
return $this->date;
}
/**
* @param \DateTimeImmutable $date
* @return SZEPCardEntry
*/
public function setDate(\DateTimeImmutable $date): SZEPCardEntry
{
$this->date = $date;
return $this;
}
/**
* Specify data which should be serialized to JSON
* @link http://php.net/manual/en/jsonserializable.jsonserialize.php
* @return mixed data which can be serialized by <b>json_encode</b>,
* which is a value of any type other than a resource.
* @since 5.4.0
*/
public function jsonSerialize()
{
return [
'hash' => $this->getHash(),
'amount' => $this->getAmount(),
'merchant' => $this->getMerchant(),
'pocket' => $this->getPocket(),
'date' => $this->getDate()->format("Y.m.d"),
];
}
}

View File

@ -1,6 +1,5 @@
<?php
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
@ -20,7 +19,6 @@ use Doctrine\ORM\Mapping as ORM;
*/
class Sms implements \JsonSerializable
{
const DIRECTION_SENT = 0;
const DIRECTION_RECEIVED = 1;

View File

@ -128,7 +128,8 @@ class User implements \JsonSerializable
return $this;
}
public function removeSmsMessage(Sms $sms): User{
public function removeSmsMessage(Sms $sms): User
{
if ($this->smsMessages->contains($sms)) {
$this->smsMessages->removeElement($sms);
}

View File

@ -15,6 +15,10 @@ class KoinService
const BASE_URI = 'https://www.koin.hu';
const DEFAULT_EXPENSE = 'Egyéb kiadás';
const CATEGORY_PAYMENT = 'Fizetés';
const CATEGORY_FOOD = 'Étel';
const CATEGORY_SPORT = '';
/**
* 1 - date
* 2 - time
@ -92,6 +96,18 @@ class KoinService
*/
private function getExpenseCategory(string $posInfo): string
{
if (false !== strpos($posInfo, "ERICSSON HàZ ÉTTEREM")) {
return 'Étel';
}
if (false !== strpos($posInfo, "ERICSSON HàZ BÜFÉ")) {
return 'Kávé';
}
if (false !== strpos($posInfo, "SCIENCE PARK ÉTTEREM")) {
return 'Étel';
}
if (false !== strpos($posInfo, "STOCZEK ETTEREM")) {
return 'Étel';
}
@ -104,7 +120,7 @@ class KoinService
return 'Bevásárlás';
}
if (false !== strpos($posInfo, "SPAR MAGYARORSZAG")) {
if (false !== strpos($posInfo, "SPAR")) {
return 'Étel';
}
@ -116,7 +132,11 @@ class KoinService
return 'Étel';
}
if (false !== strpos($posInfo, "OSMANI DÖNER KEBAB")) {
if (false !== strpos($posInfo, "MCDONALDSETTE")) {
return 'Étel';
}
if (false !== strpos($posInfo, "DÖNER KEBAB")) {
return 'Étel';
}
@ -144,6 +164,17 @@ class KoinService
return 'Szórakozás';
}
if (false !== strpos($posInfo, "DECATHLON")) {
return 'Sport';
}
if (false !== strpos($posInfo, "MOL TÖLTÖàLL")) {
return 'Autó';
}
if (false !== strpos($posInfo, "AMAZON SERVICES-KINDLE")) {
return 'Szórakozás';
}
return self::DEFAULT_EXPENSE;
}
@ -156,66 +187,60 @@ class KoinService
{
$tags = [];
if (false !== strpos($posInfo, "SCIENCE PARK ÉTTEREM")) {
if (false !== strpos($posInfo, "ERICSSON HàZ ÉTTEREM")) {
$tags[] = 'Ericsson Ház';
$tags[] = 'Menza';
} elseif (false !== strpos($posInfo, "ERICSSON HàZ BÜFÉ")) {
$tags[] = 'Ericsson Ház';
$tags[] = 'Büfé';
} elseif (false !== strpos($posInfo, "SCIENCE PARK ÉTTEREM")) {
$tags[] = 'Science Park';
$tags[] = 'Menza';
}
if (false !== strpos($posInfo, "STOCZEK ETTEREM")) {
} elseif (false !== strpos($posInfo, "STOCZEK ETTEREM")) {
$tags[] = "Stoczek";
$tags[] = "Menza";
}
if (false !== strpos($posInfo, "BKK AUTOMATA")) {
} elseif (false !== strpos($posInfo, "BKK AUTOMATA")) {
$tags[] = "Bérlet";
$tags[] = "BKV";
}
if (false !== strpos($posInfo, "SPAR MAGYARORSZAG")) {
} elseif (false !== strpos($posInfo, "SPAR")) {
$tags[] = 'Spar';
}
if (false !== strpos($posInfo, "TESCO")) {
} elseif (false !== strpos($posInfo, "TESCO")) {
$tags[] = 'Tesco';
}
if (false !== strpos($posInfo, "SZLOVàK ABC")) {
} elseif (false !== strpos($posInfo, "SZLOVàK ABC")) {
$tags[] = 'Szlovák ABC';
}
if (false !== strpos($posInfo, "MCDHU")) {
} elseif (false !== strpos($posInfo, "BURGER KING")) {
$tags[] = 'Burger King';
$tags[] = 'Junk food';
}
if (false !== strpos($posInfo, "OSMANI DÖNER KEBAB")) {
} elseif (false !== strpos($posInfo, "MCDHU")) {
$tags[] = 'McDonald\'s';
$tags[] = 'Junk food';
} elseif (false !== strpos($posInfo, "MCDONALDSETTE")) {
$tags[] = 'McDonald\'s';
$tags[] = 'Junk food';
} elseif (false !== strpos($posInfo, "DÖNER KEBAB")) {
$tags[] = 'Kebab';
$tags[] = 'Junk food';
}
if (false !== strpos($posInfo, "Princess Bakery")) {
$tags[] = 'Pricess Bakery';
} elseif (false !== strpos($posInfo, "Princess Bakery")) {
$tags[] = 'Princess Bakery';
$tags[] = 'Junk food';
}
if (false !== strpos($posInfo, "PIROG-DA")) {
} elseif (false !== strpos($posInfo, "PIROG-DA")) {
$tags[] = 'Pirog-da';
$tags[] = 'Junk food';
}
if (false !== strpos($posInfo, "GYOGYSZER")) {
} elseif (false !== strpos($posInfo, "GYOGYSZER")) {
$tags[] = 'Gyógyszer';
}
if (false !== strpos($posInfo, "GOOGLE *Google Music")) {
} elseif (false !== strpos($posInfo, "GOOGLE *Google Music")) {
$tags[] = 'Google play';
}
if (false !== strpos($posInfo, "DIGI ")) {
} elseif (false !== strpos($posInfo, "DIGI ")) {
$tags[] = 'Digi';
}
if (false !== strpos($posInfo, "Aqua Electromax")) {
} elseif (false !== strpos($posInfo, "Aqua Electromax")) {
$tags[] = 'Aqua';
} elseif (false !== strpos($posInfo, "DECATHLON")) {
$tags[] = 'Decathlon';
} elseif (false !== strpos($posInfo, "MOL TÖLTÖàLL")) {
$tags[] = 'MOL';
} elseif (false !== strpos($posInfo, "AMAZON SERVICES-KINDLE")) {
$tags[] = 'Kindle';
$tags[] = 'Amazon';
}
$tags[] = 'Auto';

View File

@ -0,0 +1,255 @@
<?php
namespace App\Service;
use App\Entity\SZEPCardEntry;
use Doctrine\ORM\EntityManager;
use GuzzleHttp\Client;
class SZEPManagerService
{
const TEMPLATE_WORKFLOW_START = "data/soap-xmls/SZEP_startWorkflow.xml";
const TEMPLATE_WORKFLOW_STATE = "data/soap-xmls/SZEP_getWorkflowState.xml";
const TEMPLATE_QUERY_CARD = "data/soap-xmls/SZEP_queryCard.xml";
const CERTIFICATE_WEB_PATH = "https://www.otpbankdirekt.hu/homebank/mobilalkalmazas/certificate";
const CERTIFICATE_CACHED_PATH = "data/cache/SZEP_cert.pem";
const SOAP_ENDPOINT = "https://www.otpbankdirekt.hu/mwaccesspublic/mwaccess";
const POCKET_FOOD = 'Vendéglátás';
const POCKET_SPORT = 'Szabadidő';
const TAG_POCKET_FOOD = 'SZÉP kártya';
const TAG_POCKET_SPORT = 'SZÉP kártya - szabadidő';
/** @var array */
private $config;
/** @var Client */
private $httpClient;
/** @var EntityManager */
private $em;
/** @var KoinService */
private $koinService;
public function __construct(Client $httpClient,
array $config,
EntityManager $em,
KoinService $koinService)
{
$this->httpClient = $httpClient;
$this->config = $config;
$this->em = $em;
$this->koinService = $koinService;
}
/**
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @throws \Doctrine\ORM\TransactionRequiredException
* @throws \Exception
*/
public function pollRecent()
{
$workflowResult = $this->startWorkflow();
$pollResult = $this->pollResult($workflowResult);
$this->parseResult($pollResult);
}
/**
* Init the soap workflow
* @return string
* @throws \Exception
*/
private function startWorkflow(): string
{
$certificate = $this->getCertificate();
openssl_public_encrypt($this->config['szep.card'], $cryptCardId, $certificate, OPENSSL_PKCS1_PADDING);
openssl_public_encrypt($this->config['szep.key'], $cryptCardKey, $certificate, OPENSSL_PKCS1_PADDING);
$endDate = new \DateTimeImmutable();
$startDate = $endDate->sub(new \DateInterval('P4D'));
$gueryTemplate = file_get_contents(self::TEMPLATE_QUERY_CARD);
$query = sprintf($gueryTemplate,
"X" . base64_encode($cryptCardId),
base64_encode($cryptCardKey),
$startDate->format("Y.m.d"),
$endDate->format("Y.m.d")
);
$soapXml = sprintf(file_get_contents(self::TEMPLATE_WORKFLOW_START), $query);
$soapResponseXml = $this->doSoapRequest($soapXml);
$domDocument = new \DOMDocument();
$domDocument->loadXML($soapResponseXml);
$documentXpath = new \DOMXPath($domDocument);
/** @var \DOMElement $returnElement */
$returnElement = $documentXpath->query('//return')->item(0);
return $returnElement->textContent;
}
/**
* Poll until the result is ready
* @param string $workflowResult
* @return string
*/
private function pollResult(string $workflowResult): string
{
$soapXml = sprintf(file_get_contents(self::TEMPLATE_WORKFLOW_STATE), $workflowResult);
do {
sleep(1);
$soapResponseXml = $this->doSoapRequest($soapXml);
$domDocument = new \DOMDocument();
$domDocument->loadXML($soapResponseXml);
$documentXpath = new \DOMXPath($domDocument);
/** @var \DOMElement $completedElement */
$completedElement = $documentXpath->query('//completed')->item(0);
} while ($completedElement->textContent != "true");
/** @var \DOMElement $resultElement */
$resultElement = $documentXpath->query('//result')->item(0);
return base64_decode($resultElement->textContent);
}
/**
* Parse the decoded payload
* @param string $resultXml
* @throws \Doctrine\ORM\ORMException
* @throws \Doctrine\ORM\OptimisticLockException
* @throws \Doctrine\ORM\TransactionRequiredException
*/
private function parseResult(string $resultXml)
{
$domDocument = new \DOMDocument();
$domDocument->loadXML($resultXml);
$documentXpath = new \DOMXPath($domDocument);
/** @var \DOMElement[] $returnElements */
$recordElements = $documentXpath->query('/answer/resultset/record');
$newRecords = [];
/** @var \DOMElement $element */
foreach ($recordElements as $element) {
$date = trim($documentXpath->query('./datum', $element)->item(0)->textContent);
$amount = trim($documentXpath->query('./osszeg', $element)->item(0)->textContent);
$merchant = trim($documentXpath->query('./ellenoldali_nev', $element)->item(0)->textContent);
$pocket = trim($documentXpath->query('./alszamla', $element)->item(0)->textContent);
$hash = md5("$date#$amount#$merchant#$pocket");
if (null != $this->em->find(SZEPCardEntry::class, $hash)) {
continue;
}
$szepCardEntity = new SZEPCardEntry();
$szepCardEntity->setHash($hash)
->setAmount($amount)
->setMerchant($merchant)
->setPocket($pocket)
->setDate(new \DateTimeImmutable(str_replace(".", "-", $date)));
$newRecords[] = $szepCardEntity;
}
/** @var SZEPCardEntry $newRecord */
foreach ($newRecords as $newRecord) {
$resultCode = $this->koinService->saveTransaction(
abs($newRecord->getAmount()),
'HUF',
$newRecord->getDate()->format("Y-m-d"),
$this->getCategory($newRecord),
$this->getTags($newRecord)
);
if ($resultCode > 199 && $resultCode < 300) {
$this->em->persist($newRecord);
}
}
$this->em->flush();
}
private function getCategory(SZEPCardEntry $SZEPCardEntry): string
{
return ($SZEPCardEntry->getAmount() > 0)
? KoinService::CATEGORY_PAYMENT
: (
$SZEPCardEntry->getPocket() == self::POCKET_FOOD
? KoinService::CATEGORY_FOOD
: KoinService::DEFAULT_EXPENSE
);
}
private function getTags(SZEPCardEntry $SZEPCardEntry): array
{
$tags = [];
if (false !== strpos($SZEPCardEntry->getMerchant(), "Sigma Technology")) {
$tags[] = 'Sigma';
$tags[] = 'Cafeteria';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Ericsson Ház Étterem")) {
$tags[] = 'Ericsson Ház';
$tags[] = 'Menza';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Planet Sushi Alle")) {
$tags[] = 'Planet Sushi';
$tags[] = 'Sushi';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Stoczek utcai Étterem")) {
$tags[] = 'Stoczek';
$tags[] = 'Street Food';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Stoczek")) {
$tags[] = 'Stoczek';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Science Park")) {
$tags[] = 'Science Park';
$tags[] = 'Menza';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Cafe Park")) {
$tags[] = 'Cafe Park';
$tags[] = 'Menza';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Wikinger Gyorsétterem")) {
$tags[] = 'Wikinger';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Wasabi Étterem")) {
$tags[] = 'Wasabi';
$tags[] = 'Sushi';
} elseif (false !== strpos($SZEPCardEntry->getMerchant(), "Szentmihályi Uszoda")) {
$tags[] = 'Tope';
}
switch ($SZEPCardEntry->getPocket()) {
case self::POCKET_FOOD: $tags[] = self::TAG_POCKET_FOOD; break;
case self::POCKET_SPORT: $tags[] = self::TAG_POCKET_SPORT; break;
}
$tags[] = 'Auto';
return $tags;
}
/**
* @param string $soapXml
* @return string
*/
private function doSoapRequest(string $soapXml): string
{
$response = $this->httpClient->post(self::SOAP_ENDPOINT, [
'body' => $soapXml,
]);
return $response->getBody()->getContents();
}
/**
* Returns the cached public certificate
* @return string
*/
private function getCertificate(): string
{
if (file_exists(self::CERTIFICATE_CACHED_PATH)) {
return file_get_contents(self::CERTIFICATE_CACHED_PATH);
}
$cert = file_get_contents(self::CERTIFICATE_WEB_PATH);
file_put_contents(self::CERTIFICATE_CACHED_PATH, $cert);
return $cert;
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Service;
use Doctrine\ORM\EntityManager;
use GuzzleHttp\Client;
use Interop\Container\ContainerInterface;
class SZEPManagerServiceFactory
{
/**
* @param ContainerInterface $container
* @return SZEPManagerService
* @throws \Psr\Container\ContainerExceptionInterface
* @throws \Psr\Container\NotFoundExceptionInterface
*/
public function __invoke(ContainerInterface $container)
{
$httpClient = new Client([
'cookies' => true,
'headers' => [
'Content-Type' => 'text/xml; charset=UTF-8',
]
]);
$config = $container->get('config');
/** @var EntityManager $em */
$em = $container->get('doctrine.entity_manager.orm_default');
/** @var KoinService $koinService */
$koinService = $container->get(KoinService::class);
return new SZEPManagerService($httpClient, $config, $em, $koinService);
}
}