173 lines
5.7 KiB
PHP
173 lines
5.7 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace App\Http\Controllers\Helpers;
|
||
|
||
/**
|
||
* Decoder for QRPH / EMV QRCPS strings.
|
||
*
|
||
* QRPH follows the EMVCo Merchant-Presented QR Code Specification.
|
||
* Each field is encoded as: 2-char tag + 2-char length (decimal) + value.
|
||
* Nested (sub-field) structures use the same encoding inside a parent value.
|
||
*/
|
||
class QrphDecoder
|
||
{
|
||
/**
|
||
* Decode a QRPH string and return human-readable fields.
|
||
*/
|
||
public static function decode(string $qrString): array
|
||
{
|
||
$qrString = trim($qrString);
|
||
$fields = self::parseTlv($qrString);
|
||
|
||
return [
|
||
'raw' => $qrString,
|
||
'valid' => self::validateCrc($qrString, $fields),
|
||
'initiation_method'=> self::initiationLabel($fields['01'] ?? null),
|
||
'merchant_name' => $fields['59'] ?? null,
|
||
'merchant_city' => $fields['60'] ?? null,
|
||
'country_code' => $fields['58'] ?? null,
|
||
'currency' => $fields['53'] ?? null, // 608 = PHP
|
||
'amount' => isset($fields['54']) ? (float) $fields['54'] : null,
|
||
'merchant_account' => self::extractMerchantAccount($fields),
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Parse a flat TLV string into [ tag => value ] pairs.
|
||
*/
|
||
public static function parseTlv(string $data): array
|
||
{
|
||
$result = [];
|
||
$pos = 0;
|
||
$len = strlen($data);
|
||
|
||
while ($pos + 4 <= $len) {
|
||
$tag = substr($data, $pos, 2);
|
||
$length = (int) substr($data, $pos + 2, 2);
|
||
$value = substr($data, $pos + 4, $length);
|
||
|
||
$result[$tag] = $value;
|
||
$pos += 4 + $length;
|
||
|
||
if ($tag === '63') break; // CRC is always last
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* Extract primary account info from merchant account fields (tags 26–51).
|
||
* Returns the first one found with a recognisable GUID.
|
||
*/
|
||
private static function extractMerchantAccount(array $fields): ?array
|
||
{
|
||
for ($i = 26; $i <= 51; $i++) {
|
||
$tag = str_pad((string) $i, 2, '0', STR_PAD_LEFT);
|
||
if (!isset($fields[$tag])) continue;
|
||
|
||
$sub = self::parseTlv($fields[$tag]);
|
||
$guid = $sub['00'] ?? '';
|
||
|
||
return [
|
||
'guid' => $guid,
|
||
'network' => self::networkFromGuid($guid),
|
||
'account' => $sub['01'] ?? null,
|
||
'name' => $sub['02'] ?? null,
|
||
];
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private static function networkFromGuid(string $guid): string
|
||
{
|
||
$map = [
|
||
// Standard QRPH / InstaPay (BSP / PPMI)
|
||
'com.p2pqrph' => 'InstaPay (QRPH)',
|
||
'ph.ppmi' => 'InstaPay',
|
||
'ph.bsp.qrph' => 'InstaPay (BSP)',
|
||
// GoTyme Bank
|
||
'com.gotyme' => 'GoTyme',
|
||
'ph.gotyme' => 'GoTyme',
|
||
'gotyme' => 'GoTyme',
|
||
// GCash
|
||
'com.gcash' => 'GCash',
|
||
// Maya / PayMaya
|
||
'com.paymaya' => 'Maya',
|
||
'com.maya' => 'Maya',
|
||
// Major PH banks
|
||
'ph.bpi' => 'BPI',
|
||
'ph.bdo' => 'BDO',
|
||
'com.landbank' => 'Landbank',
|
||
'ph.landbank' => 'Landbank',
|
||
'ph.ubp' => 'UnionBank',
|
||
'ph.metrobank' => 'Metrobank',
|
||
'ph.rcbc' => 'RCBC',
|
||
'ph.pnb' => 'PNB',
|
||
'ph.securitybank' => 'Security Bank',
|
||
'ph.chinabank' => 'China Bank',
|
||
'ph.eastwest' => 'EastWest Bank',
|
||
'ph.psbank' => 'PSBank',
|
||
'ph.boc' => 'Bank of Commerce',
|
||
'com.seabank' => 'SeaBank',
|
||
'com.tonik' => 'Tonik',
|
||
'com.uno' => 'UNO Digital Bank',
|
||
'com.komo' => 'CIMB / Komo',
|
||
'com.ownbank' => 'OwnBank',
|
||
];
|
||
|
||
$lower = strtolower($guid);
|
||
foreach ($map as $key => $label) {
|
||
if (str_contains($lower, $key)) return $label;
|
||
}
|
||
|
||
// Fallback: return the raw GUID so it's still visible
|
||
return $guid ?: 'Unknown';
|
||
}
|
||
|
||
private static function initiationLabel(?string $code): string
|
||
{
|
||
return match ($code) {
|
||
'11' => 'Static',
|
||
'12' => 'Dynamic',
|
||
default => $code ?? 'Unknown',
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Validate the CRC-16/CCITT-FALSE checksum embedded in the QR string.
|
||
*/
|
||
public static function validateCrc(string $qrString, array $parsed = []): bool
|
||
{
|
||
if (empty($parsed['63'])) return false;
|
||
|
||
// CRC covers everything up to and including the "6304" tag+length prefix.
|
||
$crcPos = strrpos($qrString, '6304');
|
||
if ($crcPos === false) return false;
|
||
|
||
$payload = substr($qrString, 0, $crcPos + 4);
|
||
$expected = strtoupper($parsed['63']);
|
||
$computed = self::crc16($payload);
|
||
|
||
return $computed === $expected;
|
||
}
|
||
|
||
/**
|
||
* CRC-16/CCITT-FALSE (polynomial 0x1021, seed 0xFFFF).
|
||
*/
|
||
public static function crc16(string $data): string
|
||
{
|
||
$crc = 0xFFFF;
|
||
for ($i = 0, $l = strlen($data); $i < $l; $i++) {
|
||
$crc ^= (ord($data[$i]) << 8);
|
||
for ($j = 0; $j < 8; $j++) {
|
||
$crc = ($crc & 0x8000)
|
||
? (($crc << 1) ^ 0x1021) & 0xFFFF
|
||
: ($crc << 1) & 0xFFFF;
|
||
}
|
||
}
|
||
return sprintf('%04X', $crc);
|
||
}
|
||
}
|