initial: bootstrap from BukidBountyApp base
This commit is contained in:
172
app/Http/Controllers/Helpers/QrphDecoder.php
Normal file
172
app/Http/Controllers/Helpers/QrphDecoder.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user