Files
2026-06-06 18:43:00 +08:00

173 lines
5.7 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?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 2651).
* 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);
}
}