From 658b41f4dd76833ba17ab75dae0c9b0df644e70f Mon Sep 17 00:00:00 2001 From: Rastislav Date: Wed, 16 Sep 2026 21:37:27 +0200 Subject: [PATCH] update --- README.md | 88 ++++++++++ docs/PAYQR.md | 128 ++++++++++++++ lib/flutter_paytorl.dart | 4 + lib/src/emv_mpm.dart | 70 ++++++++ lib/src/models/payto_json.dart | 42 +++++ lib/src/payqr.dart | 303 +++++++++++++++++++++++++++++++++ lib/src/payto.dart | 153 ++++++++++++++++- pubspec.yaml | 2 +- test/emv_mpm_test.dart | 41 +++++ test/fixtures/payqr.json | 175 +++++++++++++++++++ test/fixtures/qris-emv.json | 49 ++++++ test/formats_test.dart | 65 +++++++ test/payqr_contract.dart | 209 +++++++++++++++++++++++ test/payqr_test.dart | 8 + 14 files changed, 1331 insertions(+), 6 deletions(-) create mode 100644 docs/PAYQR.md create mode 100644 lib/src/emv_mpm.dart create mode 100644 lib/src/payqr.dart create mode 100644 test/emv_mpm_test.dart create mode 100644 test/fixtures/payqr.json create mode 100644 test/fixtures/qris-emv.json create mode 100644 test/formats_test.dart create mode 100644 test/payqr_contract.dart create mode 100644 test/payqr_test.dart diff --git a/README.md b/README.md index 8ce4884..41a9687 100644 --- a/README.md +++ b/README.md @@ -298,3 +298,91 @@ If you find this project useful, please consider supporting it: - [Litecoin](https://www.blockchain.com/explorer/addresses/ltc/ltc1ql8dvx0wv0nh2vncpt9j3zqefaehsd25cwp7pfx) List of sponsors: [![GitHub Sponsors](https://img.shields.io/github/sponsors/bchainhub?label=Sponsors&logo=githubsponsors&color=EA4AAA)](https://github.com/sponsors/bchainhub) + +## PayQR national/interoperable QR payments + +Use `payto://qr/{country}/{identifier}` with lowercase ISO country codes. PayQR is global; **Pay QR** is the website's label. Current mappings are BN tarusQR, KH KHQR, ID QRIS, LA LaoQR, MY DuitNow QR, MM MMQR, PH QR Ph, SG PayNow/SGQR, TH PromptPay/Thai QR and VN VietQR. + +See [PayQR URI API, field coverage and validation limits](docs/PAYQR.md). A PayTo URI, national payment payload, QR image and payment-network connection are separate things. Generating a syntactically valid QR does not imply participation in or authorization to acquire transactions from a payment network. Unsupported national profiles fail closed. + +PayQR support encodes/decodes URI and structured payment data only. National QR payload generation and rendering are owned by the PayTo website. + +### Philippine QR Ph payment data + +Canonical destination: `payto://qr/ph/{identifier}`; network `qr`, country `ph`, scheme `qrph`. +The identifier is an opaque issuer-provided value, not a verified bank account, mobile number or merchant ID. +The existing generic target stores optional `payment-mode=p2p|p2m` and `qr-type=static|dynamic` in its parameters. +`Payto.paymentMode` and `Payto.qrType` expose validated getters/setters and JSON object properties. +`payment-mode` is a PayTo extension; `mode` remains the existing pass presentation property. + +Example (illustrative URI, not an official payment destination or native test vector): + +```text +payto://qr/ph/Demo%40Issuer?amount=PHP%3A15.25&payment-mode=p2m&qr-type=dynamic&reference=Bill%201 +``` + +PHP amounts use positive decimal strings with at most two decimal places and the existing 13-character amount limit. No floating-point conversion is used by the QR URI codec. Dynamic requests require an amount in this supported PayTo profile. Static data does not imply a recurring debit mandate. We preserve generic receiver-name/reference fields without claiming a verified native tag mapping. We do not impose participant-specific transaction limits or infer recipient requirements from another country. + +**Native QR Ph payload encoding and decoding are not implemented:** researched public sources did not establish the Philippine account templates, routing identifiers and official conformance vectors. A PayTo URI is not a QR Ph payload accepted by banking apps. No generic EMV payload is classified as QR Ph merely from PH/PHP. + +Architecture, when a verified native profile becomes available: + +```text +PayTo URI → structured payment data → native encoder → payload string +scanned payload string → native decoder → structured data → PayTo URI +``` + +The native arrows above are currently unavailable for QR Ph. Libraries stop at data, never render QR images, scan cameras, enroll merchants, connect to InstaPay, transfer funds or check payment status. QR URI input is capped at 8192 characters; duplicate query keys, controls, malformed escaping and invalid amounts are rejected. + +Sources: [BSP QR Ph](https://www.bsp.gov.ph/SitePages/MediaAndResearch/Multimedia_QRPh.aspx), [BSP P2P FAQ](https://www.bsp.gov.ph/Media_and_Research/Primers%20Faqs/QR_Ph_P2P_FAQs.pdf), [BSP P2M FAQ](https://www.bsp.gov.ph/Media_and_Research/Primers%20Faqs/QR_Ph_P2M_FAQs.pdf), [PayMongo MPM API](https://docs.paymongo.com/reference/generate-mpm-qr), [PayMongo QR Ph acceptance](https://docs.paymongo.com/docs/payment-acceptance-qr-ph). Provider API fields are evidence for supported payment concepts, not the national TLV layout. + +### Indonesian QRIS payment data + +Canonical URI: `payto://qr/id/{identifier}`. Network `qr`, country `id`, scheme `qris`, domestic currency `IDR`. +The path remains an opaque acquirer-issued identifier (`issuer` type). It is **not** relabeled NMID or Merchant PAN: the reviewed public material does not establish sufficient routing semantics for that mapping. Do not invent these provider-issued values. + +```text +payto://qr/id/Demo?qr-type=static +payto://qr/id/Demo?amount=IDR%3A50000&qr-type=dynamic&reference=Bill%201 +``` + +These illustrative links are not native QRIS payloads. Optional `qr-type=static|dynamic` uses the existing `qrType` property. Explicit static requests reject amount; dynamic requests require it. Unspecified type preserves a portable suggested amount without generating a native transaction. Amounts are positive IDR decimal strings, at most two fractional digits, capped at IDR 10,000,000 using integer minor-unit comparison. Decimal precision is the existing portable profile, not a claim that every provider accepts fractional rupiah. Generic `receiver-name` and `reference` remain portable metadata without a verified QRIS tag mapping. No static-to-dynamic payload conversion is implemented. + +`inspectEmvMpm(payload)` provides bounded **raw ASCII EMV envelope inspection** in both libraries. It checks TLV boundaries, duplicate fields, nested template boundaries and CRC, then returns `fields` and `templates`. Its JSON shape matches across TypeScript and Dart (Dart uses `.toJson()`). It does not return a national scheme, NMID, Merchant PAN or PayTo target and is not a QRIS compliance validator. It rejects non-ASCII input and payloads over 4096 characters; nested unknown subfields stay raw. The official Midtrans example is a fixture with published CRC `A623`. + +**Native QRIS encoding and semantic decoding remain unsupported.** Full authoritative routing/template requirements were not available in the reviewed public material. ASPI documents a specification request process. Neither a correct CRC nor ID/360 proves QRIS validity. No guessed national tags, transaction identifiers or provider credentials are generated. The website uses PayTo for this country and hides the native-format switch. + +```text +PayTo URI ↔ structured portable QR data +provider payload → raw EMV inspection (no payment destination inference) +structured QRIS → native encoder → payload → website renderer [not implemented] +``` + +Libraries render no images, perform no scanning or network calls, register no merchants, issue no identifiers and perform no transfers, status checks or settlement. Static QR does not authorize recurring debits. CPM, QRIS TAP, Tuntas, cross-border routing/FX and provider connectivity are outside this implementation. + +Sources: [Bank Indonesia QRIS](https://www.bi.go.id/id/fungsi-utama/sistem-pembayaran/ritel/kanal-layanan/QRIS/default.aspx), [ASPI QRIS modes](https://aspi-indonesia.or.id/standar-dan-layanan/qris/), [ASPI specification-request process, annual report p. 46](https://aspi-indonesia.or.id/files/2024/12/AR%20ASPI%202023-FA_all_rev.pdf), [Midtrans official dynamic example](https://docs.midtrans.com/docs/gopay-qris-pos-integration), [EMVCo QR specifications](https://www.emvco.com/emv-technologies/qr-codes/), [BI cross-border QRIS](https://www.bi.go.id/id/fungsi-utama/sistem-pembayaran/ritel/kanal-layanan/QRIS/QRIS-Antarnegara/default.aspx). + + +### Presentation formats and URI extensions + +`Payto.formats` returns `['payto', 'epc']` for IBAN, or `['payto', '']` +for supported Pay QR countries: `khqr`, `laoqr`, `duitnow`, `mmqr`, `paynow`, +`promptpay`, and `vietqr`. PayTo-only methods (including Brunei, QR Ph, and QRIS) +omit `formats` from object JSON; the getter returns `null`. +PayTo is always first and is the default. Capability metadata does not validate +whether the supplied fields are sufficient for a native payment format. + +`reference`, `purpose`, and `information` are readable/writable PayTo query +extensions, also exposed in object JSON. Assign `null` to remove them. Unknown +query extensions remain preserved by the URI codec. `formats` is derived metadata, +not a query parameter; reading it never changes the PayTo link. + +These libraries encode and decode **PayTo links only**. They do not generate or +parse EPC or national payment payloads, or render barcodes. Applications implement +those formats and validate their requirements separately. + + +**Validation coverage is partial:** all current Pay QR fields round-trip through +the parameter map, but dedicated field accessors/top-level JSON properties and +country-specific validation are not complete. See [field coverage and known +validation gaps](docs/PAYQR.md#field-coverage-and-validation-boundaries). diff --git a/docs/PAYQR.md b/docs/PAYQR.md new file mode 100644 index 0000000..f401ce5 --- /dev/null +++ b/docs/PAYQR.md @@ -0,0 +1,128 @@ +# PayQR data support + +This library only encodes/decodes PayTo URIs and structured data, with validation and normalization. QR payload generation and rendering belong exclusively to the PayTo website. + +## Portable URI contract + +```text +payto://qr/{country}/{identifier} +``` + +The country is a lowercase ISO 3166-1 alpha-2 code and selects the default scheme. Do not add a scheme path segment, `scheme` query parameter, or an alternate national-scheme authority. Country and authority input are case-insensitive; serialization produces lowercase. Identifiers retain their exact case, leading zeros and Unicode; they are never trimmed, numerically converted or Unicode-normalized. + +```text +payto://qr/la/ABC123 +payto://qr/kh/john_smith%40devb?amount=USD%3A12.50 +payto://qr/sg/%2B6581234567 +payto://qr/th/0066812345678?amount=THB%3A12.50&receiver-name=Test +``` + +These are syntax examples, not verified destinations. + +The supported URI profile has exactly two nonempty path components, no credentials, port or fragment. Reject extra segments, URLs as identifiers, control/whitespace characters, malformed percent encoding/UTF-8, duplicate query keys and dot segments before URL normalization. Unknown countries (including `br`, `hk` and `in` until registered) are rejected. The global design permits adding those countries without changing this contract. + +Identifiers without a verified national format use a deliberately limited **issuer** envelope: 1–128 Unicode letters/digits or `_.@+-`. This is a portable opaque identifier, **not national account validation**. It may exclude legitimate issuer formats not yet researched. The UI discloses this distinction. Do not treat an accepted issuer identifier as a payable account. + +Query values are percent-encoded; keys are sorted for deterministic serialization. Default `identifier-type` is omitted; nondefault types use `identifier-type`. Common properties are `amount=ISO4217:decimal`, `receiver-name`, `reference`, `message`, and the existing library's `org`. `currency` is not a separate query property. Amounts use strings, not floating-point conversion: positive, no exponent/sign/grouping, no leading zeros except `0.x`, at most two decimal digits and 13 value characters. KHR and VND use whole units in this profile. These are conservative supported-profile constraints, not a claim that every scheme shares every monetary limit. Empty currency metadata means unverified; amounts are rejected, not unrestricted. Unknown extension queries are preserved, subject to key syntax, a 512 UTF-16-unit value bound and no controls. Adapters reject extensions they cannot encode. + +LaoQR uses `application-id` for the institution-supplied 16-character AID and `acquirer-id` for its six-digit IIN. The receiver ID alone cannot produce a routable LaoQR. The constructor applies the encoder’s ASCII receiver-ID restriction; the portable URI envelope continues accepting Unicode identifiers. + +National-specific optional query fields currently validated include `acquirer-id`, `merchant-city`, `mcc`, and `qr-type`. KHQR uses `qr-currency=KHR|USD` for a reusable static QR with no amount (default KHR); if an amount is supplied, its currency must agree. MMQR uses the acquirer-supplied `scheme-id` reverse domain (maximum 32 ASCII characters) and `local-name` (1–25 Myanmar characters). VietQR uses `acquirer-id` for the six-digit receiving bank BIN. A dynamic request in the supported profile requires an amount. This rule does not assert that every national dynamic scheme requires an amount. + + +## API and extension points + +TypeScript exports `PayQrCountry`, `PayQrScheme`, `PayQrTarget`, `payQrSchemes`, `parsePayQr`, `serializePayQr`, `validatePayQrIdentifier`, `validatePayQrParameters` from the existing package entry point. Existing `Payto` exposes `payQr`, `country`, `scheme`, `identifier`, `identifierType`, `reference`; setters exist for country, identifier, identifierType and reference. `address` aliases the decoded PayQR identifier. `toJSON()` remains a URI string; `toJSONObject()` adds the new fields. Common PayQR parameter setters are validated atomically. Low-level mutable URL/searchParams APIs are revalidated when reading a PayQR target or serializing. + +```ts +import Payto, { parsePayQr, serializePayQr } from 'payto-rl'; +const payment = new Payto('payto://qr/th/0066812345678'); +payment.amount = 'THB:12.50'; +payment.receiverName = 'Test'; +const target = parsePayQr(payment.toString()); +const canonicalUri = serializePayQr(target); +``` + +Dart exports `PayQrScheme`, `payQrSchemes` and immutable `PayQrTarget` through `flutter_paytorl.dart`. Use `PayQrTarget.parse(uri)`, the named-argument constructor, `toString()` and `toJson()`. Existing `Payto` has equivalent properties/setters; `toJson()` remains a string and `toJsonObject()` adds country/scheme/identifier/type/reference. No existing required JSON constructor arguments were changed. + +```dart +final payment = Payto('payto://qr/kh/john_smith%40devb'); +payment.amount = 'USD:12.50'; +final target = PayQrTarget.parse(payment.toString()); +final canonicalUri = target.toString(); +final details = payment.toJsonObject().toJson(); +``` + +To extend globally: add country/scheme metadata and a documented identifier/currency profile in each language; add a website country schema and the desired presentation grouping; implement and register national adapters in the website only; add matching contract fixtures and scheme vectors. No authority or URI redesign is needed. The same checked-in JSON fixtures in both libraries guard parity without imposing a cross-repository dependency on Dart tests. + + +## Field coverage and validation boundaries + +All current Pay QR constructor fields can round-trip through the libraries' URI +parameter maps. This does **not** mean every field has a dedicated `Payto` +getter/setter, a top-level JSON property, or complete country-specific validation. + +| Data | URI representation | +| --- | --- | +| Country, identifier | `payto://qr/{country}/{identifier}` path | +| Scheme | Derived from country; not a separate URI parameter | +| Identifier type | `identifier-type` (default type omitted) | +| Amount and currency | `amount=CURRENCY:value`; KH static currency uses `qr-currency` | +| Common details | `receiver-name`, `reference`, `message`, `org` | +| Routing and merchant identity | `acquirer-id`, `application-id`, `scheme-id`, `recipient-type`, `merchant-id`, `acquiring-bank`, `account-information` | +| Merchant details | `merchant-city`, `mcc`, `local-name`, `merchant-mobile`, `postal-code` | +| Additional details | `bill-number`, `store-label`, `terminal-label` | +| Request settings | `payment-mode`, `qr-type`, `amount-editable`, `expiry-date`, `creation-timestamp`, `expiration-timestamp` | + +Use `Payto.payQr.parameters` or the parsed target's `parameters` for fields without +dedicated accessors. TypeScript updates can use `serializePayQr` with a copied +parameter map; Dart uses a new `PayQrTarget` with a copied map. Object JSON from +`Payto` does not flatten every parameter; keep the URI or target parameter map +when complete field preservation is needed. + +Both libraries validate URI structure/escaping, supported identifiers, monetary +syntax/currencies, selected enums, field lengths and some country-specific rules. +Unknown extensions are preserved under the generic query constraints. Validation +is **not yet fully aligned** with the website: examples of additional website +checks include KH expiry after creation, Lao receiver-ID ASCII/length limits, +printable-ASCII merchant fields, and some field/country and static/amount +combinations. The two library implementations also have remaining differences +(for example, Lao merchant-city length handling). Native-generation prerequisites +remain application responsibilities; a parsed PayTo link does not prove that a +national barcode can be generated or that an account is registered. + +## Format metadata and IBAN extensions + +`Payto.formats` describes presentation capabilities, not implemented library +encoders and not validation of the current payment data. + +| Payment method | `formats` in object JSON | +| --- | --- | +| IBAN | `['payto', 'epc']` | +| Cambodia | `['payto', 'khqr']` | +| Laos | `['payto', 'laoqr']` | +| Malaysia | `['payto', 'duitnow']` | +| Myanmar | `['payto', 'mmqr']` | +| Singapore | `['payto', 'paynow']` | +| Thailand | `['payto', 'promptpay']` | +| Vietnam | `['payto', 'vietqr']` | +| Brunei, Philippines, Indonesia, other PayTo-only methods | Omitted | + +PayTo is first and is the application default. A PayTo-only getter returns +`undefined` in TypeScript or `null` in Dart, never `['payto']`. The metadata appears +in `toJSONObject()` (TypeScript) and `toJsonObject().toJson()` (Dart), not in URI +serialization and not automatically on the lower-level Pay QR target. + +`reference`, `purpose` and `information` are readable/writable PayTo query +extensions and object JSON properties. Setting them to `null` removes them. +The libraries preserve IBAN extension values without applying the application's +EPC RF checksum, purpose-code or payload-length rules. EPC encoding/decoding and +national payload encoding/decoding are not library capabilities. The existing +`inspectEmvMpm` helper only inspects a raw EMV envelope; it does not decode a +national payment into a PayTo target. + +The website's `/pass` endpoint accepts `design.qrFormat` with `payto`, `epc` or the +matching supported country scheme name. It defaults to PayTo when the format or +entire design object is omitted, for both JSON and form requests and both wallets. +This is an application API, not a library encoder. Payment links omit `format`; +shared PayPass presentation links use it only for a nondefault format. diff --git a/lib/flutter_paytorl.dart b/lib/flutter_paytorl.dart index 3802aab..ce8e2a5 100644 --- a/lib/flutter_paytorl.dart +++ b/lib/flutter_paytorl.dart @@ -3,3 +3,7 @@ library flutter_paytorl; export 'src/payto.dart'; export 'src/models/payto_json.dart'; export 'src/exceptions/payto_exception.dart'; + +export 'src/payqr.dart'; + +export 'src/emv_mpm.dart'; diff --git a/lib/src/emv_mpm.dart b/lib/src/emv_mpm.dart new file mode 100644 index 0000000..f5d104d --- /dev/null +++ b/lib/src/emv_mpm.dart @@ -0,0 +1,70 @@ +/// Raw ASCII EMV MPM envelope inspection, not national-scheme validation. +/// QRIS fixture source: docs.midtrans.com/docs/gopay-qris-pos-integration. +class EmvMpmInspection { + final Map fields; + final Map> templates; + EmvMpmInspection._( + Map fields, + Map> templates, + ) : fields = Map.unmodifiable(fields), + templates = Map.unmodifiable( + templates.map( + (key, value) => + MapEntry(key, Map.unmodifiable(value)), + ), + ); + Map toJson() => {'fields': fields, 'templates': templates}; +} + +Map _readTlv(String input) { + final fields = {}; + var offset = 0; + while (offset < input.length) { + if (offset + 4 > input.length) + throw FormatException('Malformed EMV TLV header'); + final header = input.substring(offset, offset + 4); + if (!RegExp(r'^[0-9]{4}$').hasMatch(header)) + throw FormatException('Malformed EMV TLV header'); + final tag = header.substring(0, 2); + final length = int.parse(header.substring(2)); + if (length == 0 || + offset + 4 + length > input.length || + fields.containsKey(tag)) + throw FormatException('Truncated, empty or duplicate EMV TLV field'); + fields[tag] = input.substring(offset + 4, offset + 4 + length); + offset += 4 + length; + } + return fields; +} + +String _checksum(String input) { + var crc = 0xffff; + for (final byte in input.codeUnits) { + crc ^= byte << 8; + for (var bit = 0; bit < 8; bit++) { + crc = ((crc & 0x8000) != 0 ? (crc << 1) ^ 0x1021 : crc << 1) & 0xffff; + } + } + return crc.toRadixString(16).toUpperCase().padLeft(4, '0'); +} + +/// Inspects TLV/CRC only. Does not identify QRIS or derive payment destinations. +EmvMpmInspection inspectEmvMpm(String input) { + if (input.length > 4096 || !RegExp(r'^[\x20-\x7e]+$').hasMatch(input)) + throw FormatException( + 'Unsupported EMV input: expected at most 4096 printable ASCII characters', + ); + final fields = _readTlv(input); + if (!input.startsWith('000201') || + !RegExp(r'6304[0-9A-F]{4}$').hasMatch(input)) + throw FormatException('Unsupported EMV format or CRC placement'); + if (fields['63'] != _checksum(input.substring(0, input.length - 4))) + throw FormatException('Invalid EMV CRC'); + final templates = >{}; + for (final entry in fields.entries) { + final id = int.parse(entry.key); + if ((id >= 26 && id <= 51) || id == 62 || id == 64 || id >= 80) + templates[entry.key] = _readTlv(entry.value); + } + return EmvMpmInspection._(fields, templates); +} diff --git a/lib/src/models/payto_json.dart b/lib/src/models/payto_json.dart index 1a53e79..8145d15 100644 --- a/lib/src/models/payto_json.dart +++ b/lib/src/models/payto_json.dart @@ -1,5 +1,17 @@ /// A class representing the JSON structure of a Payto Resource Locator class PaytoJson { + /// Available presentation formats; absent for PayTo-only methods. + final List? formats; + final String? purpose; + final String? information; + final String? country; + final String? scheme; + final String? identifier; + final String? identifierType; + final String? reference; + final String? paymentMode; + final String? qrType; + /// Email address for UPI/PIX payments (case-insensitive) final String? accountAlias; @@ -122,6 +134,16 @@ class PaytoJson { /// Creates a new PaytoJson instance PaytoJson({ + this.formats, + this.purpose, + this.information, + this.country, + this.scheme, + this.identifier, + this.identifierType, + this.reference, + this.paymentMode, + this.qrType, required this.accountAlias, required this.accountId, required this.accountNumber, @@ -166,6 +188,16 @@ class PaytoJson { /// Creates a PaytoJson instance from a JSON map factory PaytoJson.fromJson(Map json) => PaytoJson( + formats: (json['formats'] as List?)?.cast(), + purpose: json['purpose'] as String?, + information: json['information'] as String?, + country: json['country'] as String?, + scheme: json['scheme'] as String?, + identifier: json['identifier'] as String?, + identifierType: json['identifierType'] as String?, + reference: json['reference'] as String?, + paymentMode: json['paymentMode'] as String?, + qrType: json['qrType'] as String?, accountAlias: json['accountAlias'] as String?, accountId: json['accountId'] as String?, accountNumber: json['accountNumber'], @@ -210,6 +242,16 @@ class PaytoJson { /// Converts the PaytoJson instance to a JSON map Map toJson() => { + if (formats != null && formats!.length > 1) 'formats': formats, + if (purpose != null) 'purpose': purpose, + if (information != null) 'information': information, + if (country != null) 'country': country, + if (scheme != null) 'scheme': scheme, + if (identifier != null) 'identifier': identifier, + if (identifierType != null) 'identifierType': identifierType, + if (reference != null) 'reference': reference, + if (paymentMode != null) 'paymentMode': paymentMode, + if (qrType != null) 'qrType': qrType, if (accountAlias != null) 'accountAlias': accountAlias, if (accountId != null) 'accountId': accountId, if (accountNumber != null) 'accountNumber': accountNumber, diff --git a/lib/src/payqr.dart b/lib/src/payqr.dart new file mode 100644 index 0000000..0c1725b --- /dev/null +++ b/lib/src/payqr.dart @@ -0,0 +1,303 @@ +/// Global PayQR metadata. An empty currency list means not verified, not unrestricted. +const payQrSchemes = { + 'bn': PayQrScheme('tarusqr', 'tarusQR', [], ['issuer']), + 'kh': PayQrScheme('khqr', 'KHQR', ['KHR', 'USD'], ['bakong']), + 'id': PayQrScheme('qris', 'QRIS', ['IDR'], ['issuer']), + 'la': PayQrScheme('laoqr', 'LaoQR', ['LAK'], ['issuer']), + 'my': PayQrScheme('duitnow', 'DuitNow QR', ['MYR'], ['merchant']), + 'mm': PayQrScheme('mmqr', 'MMQR / MyanmarPay', ['MMK'], ['merchant']), + 'ph': PayQrScheme('qrph', 'QR Ph', ['PHP'], ['issuer']), + 'sg': PayQrScheme('paynow', 'SGQR / PayNow', ['SGD'], ['mobile', 'uen']), + 'th': PayQrScheme( + 'promptpay', + 'Thai QR / PromptPay QR', + ['THB'], + ['mobile', 'national-id', 'ewallet'], + ), + 'vn': PayQrScheme('vietqr', 'VietQR', ['VND'], ['account']), +}; + +class PayQrScheme { + final String id; + final String name; + final List currencies; + final List identifierTypes; + const PayQrScheme(this.id, this.name, this.currencies, this.identifierTypes); +} + +/// Portable destination only. This is not a national QR payload or network connection. +class PayQrTarget { + final String country; + final String identifier; + final String identifierType; + final Map parameters; + String get scheme => payQrSchemes[country]!.id; + + PayQrTarget._( + this.country, + this.identifier, + this.identifierType, + Map parameters, + ) : parameters = Map.unmodifiable(parameters); + + factory PayQrTarget.parse(String input) { + if (input.length > 8192) + throw FormatException('PayQR URI exceeds 8192 characters'); + if (RegExp(r'[\x00-\x20\x7f]|%(?![0-9a-fA-F]{2})').hasMatch(input)) { + throw FormatException('Malformed PayQR URI'); + } + final match = RegExp( + r'^payto://qr/([A-Za-z]{2})/([^/?#]+)(?:\?([^#]*))?$', + caseSensitive: false, + ).firstMatch(input); + if (match == null) + throw FormatException('Expected payto://qr/{country}/{identifier}'); + final country = match[1]!.toLowerCase(); + final metadata = payQrSchemes[country]; + if (metadata == null) throw FormatException('Unsupported PayQR country'); + var identifier = Uri.decodeComponent(match[2]!); + final parameters = {}; + final query = match[3]; + if (query != null && query.isNotEmpty) { + for (final pair in query.split('&')) { + final index = pair.indexOf('='); + if (index < 0) + throw FormatException('PayQR query parameters require values'); + final key = Uri.decodeQueryComponent(pair.substring(0, index)); + final value = Uri.decodeQueryComponent(pair.substring(index + 1)); + if (parameters.containsKey(key)) + throw FormatException('Duplicate PayQR query parameter'); + if (!RegExp(r'^[a-z][a-z0-9-]*$').hasMatch(key) || + value.length > 512 || + RegExp(r'[\x00-\x1f\x7f]').hasMatch(value)) + throw FormatException('Invalid PayQR query parameter'); + parameters[key] = value; + } + } + if (country == 'id' && + parameters['qr-type'] == 'static' && + parameters.containsKey('amount')) + throw FormatException('Static QRIS requests must not include an amount'); + if (parameters.containsKey('payment-mode') && + (country != 'ph' || + !['p2p', 'p2m'].contains(parameters['payment-mode']))) + throw FormatException('Invalid payment-mode; use p2p or p2m for QR Ph'); + if (parameters.containsKey('qr-type') && + !['static', 'dynamic'].contains(parameters['qr-type'])) + throw FormatException('Invalid QR type'); + if (parameters['qr-type'] == 'dynamic' && !parameters.containsKey('amount')) + throw FormatException( + 'Dynamic PayQR requests require an amount in this profile', + ); + if (['kh', 'my', 'mm', 'sg', 'th', 'la'].contains(country)) { + for (final entry in {'receiver-name': 25, 'merchant-city': 15}.entries) { + final value = parameters[entry.key]; + if (value != null && + (value.isEmpty || value.runes.length > entry.value)) + throw FormatException('Invalid ${entry.key} length'); + } + } + if (parameters.containsKey('mcc') && + !RegExp(r'^[0-9]{4}$').hasMatch(parameters['mcc']!)) + throw FormatException('Invalid merchant category code'); + if (['my', 'vn', 'la'].contains(country) && + parameters.containsKey('acquirer-id') && + !RegExp(r'^[0-9]{6}$').hasMatch(parameters['acquirer-id']!)) + throw FormatException('Invalid acquirer ID'); + final applicationId = parameters['application-id']; + if (applicationId != null && + (country != 'la' || + !RegExp(r'^[A-Za-z0-9]{16}$').hasMatch(applicationId))) + throw FormatException('Invalid application-id'); + final qrCurrency = parameters['qr-currency']; + if (qrCurrency != null && + (country != 'kh' || + !['KHR', 'USD'].contains(qrCurrency) || + (parameters['amount'] != null && + !parameters['amount']!.startsWith('$qrCurrency:')))) + throw FormatException('Invalid qr-currency'); + final schemeId = parameters['scheme-id']; + if (schemeId != null && + (country != 'mm' || + schemeId.length > 32 || + !RegExp(r'^[A-Za-z0-9]+(?:\.[A-Za-z0-9-]+)+$').hasMatch(schemeId))) + throw FormatException('Invalid scheme-id'); + final localName = parameters['local-name']; + if (localName != null && + (country != 'mm' || + !RegExp( + r'^[\u1000-\u109f\uAA60-\uAA7F\uA9E0-\uA9FF 0-9]{1,25}$', + unicode: true, + ).hasMatch(localName) || + !RegExp(r'\p{Script=Myanmar}', unicode: true).hasMatch(localName))) + throw FormatException('Invalid local-name'); + final asciiLimits = { + 'bill-number': 25, + 'store-label': 25, + 'terminal-label': 25, + 'mobile-number': 25, + 'merchant-mobile': 15, + 'merchant-id': 32, + 'acquiring-bank': 32, + 'account-information': 32, + }; + for (final entry in asciiLimits.entries) { + if (parameters[entry.key] != null && + !RegExp( + '^[\\x20-\\x7e]{1,${entry.value}}\$', + ).hasMatch(parameters[entry.key]!)) + throw FormatException('Invalid ${entry.key}'); + } + if (parameters['postal-code'] != null && + (country != 'my' || + !RegExp(r'^[0-9]{5}$').hasMatch(parameters['postal-code']!))) + throw FormatException('Invalid postal-code'); + if (parameters['recipient-type'] != null && + (country != 'kh' || + !['individual', 'merchant'].contains(parameters['recipient-type']))) + throw FormatException('Invalid recipient-type'); + if (parameters['amount-editable'] != null && + (country != 'sg' || + !['0', '1'].contains(parameters['amount-editable']))) + throw FormatException('Invalid amount-editable'); + if (parameters['expiry-date'] != null) { + final date = parameters['expiry-date']!; + if (country != 'sg' || !RegExp(r'^[0-9]{8}$').hasMatch(date)) + throw FormatException('Invalid expiry-date'); + final iso = + '${date.substring(0, 4)}-${date.substring(4, 6)}-${date.substring(6, 8)}'; + final parsed = DateTime.tryParse('${iso}T00:00:00Z'); + if (parsed == null || parsed.toIso8601String().substring(0, 10) != iso) + throw FormatException('Invalid expiry-date'); + } + for (final key in ['creation-timestamp', 'expiration-timestamp']) { + if (parameters[key] != null && + (country != 'kh' || + !RegExp(r'^[0-9]{13}$').hasMatch(parameters[key]!))) + throw FormatException('Invalid $key'); + } + if (parameters.containsKey('scheme') || parameters.containsKey('currency')) + throw FormatException('Use country for scheme and amount=CURRENCY:value'); + final type = + parameters['identifier-type'] ?? metadata.identifierTypes.first; + identifier = normalizePayQrIdentifier(country, identifier, type); + if (!metadata.identifierTypes.contains(type)) + throw FormatException('Unsupported PayQR identifier type'); + if (!RegExp( + r'^[\p{L}\p{N}_.@+\-]{1,128}$', + unicode: true, + ).hasMatch(identifier) || + identifier == '.' || + identifier == '..') + throw FormatException('Invalid PayQR identifier'); + if (RegExp(r'[\x00-\x20\x7f]').hasMatch(identifier)) + throw FormatException('Invalid PayQR identifier'); + final rules = >{ + 'kh': {'bakong': r'^[A-Za-z0-9_.\-]+@[A-Za-z0-9_\-]+$'}, + 'vn': {'account': r'^[A-Za-z0-9]{1,19}$'}, + 'my': {'merchant': r'^[A-Za-z0-9]{1,28}$'}, + 'mm': {'merchant': r'^[0-9]{15}$'}, + 'sg': {'mobile': r'^\+65[89][0-9]{7}$', 'uen': r'^[A-Z0-9]{10}$'}, + 'th': { + 'mobile': r'^0066[0-9]{9}$', + 'national-id': r'^[0-9]{13}$', + 'ewallet': r'^[0-9]{15}$', + }, + }; + final rule = rules[country]?[type]; + if ((country == 'kh' && identifier.length > 32) || + (rule != null && !RegExp(rule).hasMatch(identifier))) + throw FormatException('Invalid ${metadata.name} $type identifier'); + final amount = parameters['amount']; + if (amount != null) { + final parts = RegExp( + r'^([A-Z]{3}):((?:0|[1-9][0-9]*)(?:\.[0-9]{1,2})?)$', + ).firstMatch(amount); + if (parts == null || + !metadata.currencies.contains(parts[1]) || + parts[2]!.length > 13 || + !RegExp('[1-9]').hasMatch(parts[2]!) || + ((country == 'vn' || (country == 'kh' && parts[1] == 'KHR')) && + parts[2]!.contains('.'))) + throw FormatException('Invalid PayQR amount or unsupported currency'); + // Bank Indonesia domestic QRIS transaction limit; decimal-safe comparison. + if (country == 'id') { + final decimal = parts[2]!.split('.'); + final minor = + BigInt.parse(decimal[0]) * BigInt.from(100) + + BigInt.parse( + (decimal.length == 2 ? decimal[1] : '').padRight(2, '0'), + ); + if (minor > BigInt.from(1000000000)) + throw FormatException('QRIS amount exceeds IDR 10000000'); + } + } + return PayQrTarget._(country, identifier, type, parameters); + } + + factory PayQrTarget({ + required String country, + required String identifier, + String? identifierType, + Map parameters = const {}, + }) { + identifier = normalizePayQrIdentifier( + country, + identifier, + identifierType ?? + parameters['identifier-type'] ?? + payQrSchemes[country]?.identifierTypes.first ?? + '', + ); + final params = {...parameters}; + if (identifierType != null) params['identifier-type'] = identifierType; + final keys = params.keys.toList()..sort(); + final query = keys + .map( + (key) => + '${Uri.encodeComponent(key)}=${Uri.encodeComponent(params[key]!)}', + ) + .join('&'); + return PayQrTarget.parse( + 'payto://qr/$country/${Uri.encodeComponent(identifier)}${query.isEmpty ? '' : '?$query'}', + ); + } + + @override + String toString() { + final params = {...parameters}; + if (params['identifier-type'] == + payQrSchemes[country]!.identifierTypes.first) + params.remove('identifier-type'); + final keys = params.keys.toList()..sort(); + final query = keys + .map( + (key) => + '${Uri.encodeComponent(key)}=${Uri.encodeComponent(params[key]!)}', + ) + .join('&'); + return 'payto://qr/$country/${Uri.encodeComponent(identifier)}${query.isEmpty ? '' : '?$query'}'; + } + + Map toJson() => { + 'country': country, + 'scheme': scheme, + 'identifier': identifier, + 'identifierType': identifierType, + 'parameters': parameters, + }; +} + +String normalizePayQrIdentifier( + String country, + String identifier, + String type, +) { + if (country == 'th' && type == 'mobile') { + if (RegExp(r'^0[0-9]{9}$').hasMatch(identifier)) + return '0066${identifier.substring(1)}'; + if (RegExp(r'^\+66[0-9]{9}$').hasMatch(identifier)) + return '0066${identifier.substring(3)}'; + } + return identifier; +} diff --git a/lib/src/payto.dart b/lib/src/payto.dart index 5e8d0ef..782c821 100644 --- a/lib/src/payto.dart +++ b/lib/src/payto.dart @@ -1,4 +1,5 @@ import 'dart:core'; +import 'payqr.dart'; import 'constants/regex_patterns.dart'; import 'exceptions/payto_exception.dart'; @@ -11,11 +12,121 @@ class Payto { /// Creates a new Payto instance from a payto URL string Payto(String paytoString) : _uri = Uri.parse(paytoString) { + if (_uri.host.toLowerCase() == 'qr') + _uri = Uri.parse(PayQrTarget.parse(paytoString).toString()); if (_uri.scheme != 'payto') { throw PaytoException('Invalid protocol, must be payto:'); } } + /// Presentation capabilities, not native encoders or data validation. + /// Null when only PayTo is available; omitted from JSON in that case. + List? get formats { + if (network == 'iban') return const ['payto', 'epc']; + if (network != 'qr') return null; + final target = payQr!; + if (!['kh', 'la', 'my', 'mm', 'sg', 'th', 'vn'].contains(target.country)) + return null; + return List.unmodifiable(['payto', target.scheme]); + } + + /// Optional PayTo URI extensions; these do not encode EPC data. + String? get purpose => _uri.queryParameters['purpose']; + set purpose(String? value) => _setUriExtension('purpose', value); + String? get information => _uri.queryParameters['information']; + set information(String? value) => _setUriExtension('information', value); + + void _setUriExtension(String key, String? value) { + if (network == 'qr') { + _setPayQrParameter(key, value); + return; + } + final params = {..._uri.queryParameters}; + if (value == null) { + params.remove(key); + } else { + params[key] = value; + } + _uri = _uri.replace(queryParameters: params); + } + + PayQrTarget? get payQr => + network == 'qr' ? PayQrTarget.parse(_uri.toString()) : null; + String? get country => payQr?.country; + String? get scheme => payQr?.scheme; + String? get identifier => payQr?.identifier; + String? get identifierType => payQr?.identifierType; + set country(String? value) { + if (value == null) throw PaytoException('PayQR country is required'); + _updatePayQr(country: value); + } + + set identifier(String? value) { + if (value == null) throw PaytoException('PayQR identifier is required'); + _updatePayQr(identifier: value); + } + + set identifierType(String? value) { + if (value == null) throw PaytoException('PayQR identifierType is required'); + _updatePayQr(identifierType: value); + } + + /// QR Ph use case, distinct from pass presentation mode. + String? get paymentMode => payQr?.parameters['payment-mode']; + set paymentMode(String? value) => _setPayQrParameter('payment-mode', value); + String? get qrType => payQr?.parameters['qr-type']; + set qrType(String? value) => _setPayQrParameter('qr-type', value); + + String? get reference => _uri.queryParameters['reference']; + set reference(String? value) { + if (network == 'qr') { + _setPayQrParameter('reference', value); + return; + } + final params = {..._uri.queryParameters}; + if (value == null) { + params.remove('reference'); + } else { + params['reference'] = value; + } + _uri = _uri.replace(queryParameters: params); + } + + void _setPayQrParameter(String key, String? value) { + final target = payQr; + if (target == null) throw PaytoException('Expected qr network'); + final parameters = {...target.parameters}; + if (value == null || value.isEmpty) { + parameters.remove(key); + } else { + parameters[key] = value; + } + _uri = Uri.parse( + PayQrTarget( + country: target.country, + identifier: target.identifier, + parameters: parameters, + ).toString(), + ); + } + + void _updatePayQr({ + String? country, + String? identifier, + String? identifierType, + }) { + final target = payQr; + if (target == null) throw PaytoException('Expected qr network'); + _uri = Uri.parse( + PayQrTarget( + country: country ?? target.country, + identifier: identifier ?? target.identifier, + identifierType: identifierType ?? target.identifierType, + parameters: target.parameters, + ).toString(), + ); + } + /// Extracts parts from combined hostname and pathname String? _getHostpathParts({String? type, int position = 1}) { final hostnamePlusPath = '${_uri.host}${_uri.path}'; @@ -147,16 +258,26 @@ class Payto { } /// Gets payment address - String? get address => _getPathParts(); + String? get address => network == 'qr' ? identifier : _getPathParts(); /// Sets payment address - set address(String? value) => _setPathParts(value); + set address(String? value) { + if (network == 'qr') { + identifier = value; + return; + } + _setPathParts(value); + } /// Gets payment amount String? get amount => _uri.queryParameters['amount']; /// Sets payment amount set amount(String? value) { + if (network == 'qr') { + _setPayQrParameter('amount', value); + return; + } if (value != null) { _uri = Uri( scheme: _uri.scheme, @@ -392,7 +513,7 @@ class Payto { String get hostname => _uri.host.toLowerCase(); /// Gets complete URL string - String get href => _uri.toString(); + String get href => network == 'qr' ? payQr!.toString() : _uri.toString(); /// Gets IBAN from path String? get iban { @@ -499,6 +620,10 @@ class Payto { /// Sets payment message set message(String? value) { + if (network == 'qr') { + _setPayQrParameter('message', value); + return; + } if (value != null) { _uri = _uri.replace( queryParameters: {..._uri.queryParameters, 'message': value}, @@ -518,6 +643,10 @@ class Payto { /// Sets organization name set organization(String? value) { + if (network == 'qr') { + _setPayQrParameter('org', value); + return; + } if (value != null && value.length <= 25) { _uri = _uri.replace( queryParameters: {..._uri.queryParameters, 'org': value}, @@ -546,6 +675,10 @@ class Payto { /// Sets receiver name set receiverName(String? value) { + if (network == 'qr') { + _setPayQrParameter('receiver-name', value); + return; + } if (value != null) { _uri = _uri.replace( queryParameters: {..._uri.queryParameters, 'receiver-name': value}, @@ -823,13 +956,23 @@ class Payto { /// Converts to URI string @override - String toString() => _uri.toString(); + String toString() => href; /// Converts to JSON string - String toJson() => _uri.toString(); + String toJson() => href; /// Converts to PaytoJSON object with all properties PaytoJson toJsonObject() => PaytoJson( + formats: formats, + purpose: purpose, + information: information, + country: country, + scheme: scheme, + identifier: identifier, + identifierType: identifierType, + reference: reference, + paymentMode: paymentMode, + qrType: qrType, accountAlias: accountAlias, accountId: accountId, accountNumber: accountNumber, diff --git a/pubspec.yaml b/pubspec.yaml index 53b8362..5ed4681 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_paytorl description: A Flutter library for handling Payto Resource Locators (PRLs). Based on the URL API and provides additional functionality for managing PRLs. -version: 0.1.11 +version: 0.1.12 homepage: https://github.com/bchainhub/flutter_paytorl repository: https://github.com/bchainhub/flutter_paytorl documentation: https://github.com/bchainhub/flutter_paytorl#readme diff --git a/test/emv_mpm_test.dart b/test/emv_mpm_test.dart new file mode 100644 index 0000000..ea86024 --- /dev/null +++ b/test/emv_mpm_test.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_paytorl/flutter_paytorl.dart'; + +void main() { + final fixture = + jsonDecode(File('test/fixtures/qris-emv.json').readAsStringSync()) + as Map; + test( + 'official participant envelope inspection does not claim QRIS validity', + () { + final result = inspectEmvMpm(fixture['payload'] as String).toJson(); + expect(result, { + 'fields': fixture['fields'], + 'templates': fixture['templates'], + }); + expect(result.containsKey('scheme'), false); + expect(result.containsKey('identifier'), false); + }, + ); + for (var i = 0; i < (fixture['invalid'] as List).length; i++) { + test( + 'reject malformed envelope $i', + () => expect( + () => inspectEmvMpm(fixture['invalid'][i] as String), + throwsFormatException, + ), + ); + } + test('bounded ASCII subset', () { + for (final value in [ + '', + 'x' * 4097, + (fixture['payload'] as String).replaceFirst('Test', 'Tést'), + 'payto://qr/id/Demo', + ]) { + expect(() => inspectEmvMpm(value), throwsFormatException); + } + }); +} diff --git a/test/fixtures/payqr.json b/test/fixtures/payqr.json new file mode 100644 index 0000000..d969ead --- /dev/null +++ b/test/fixtures/payqr.json @@ -0,0 +1,175 @@ +{ + "valid": [ + { + "country": "bn", + "scheme": "tarusqr", + "identifier": "ABC123", + "uri": "payto://qr/bn/ABC123" + }, + { + "country": "kh", + "scheme": "khqr", + "identifier": "john_smith@devb", + "uri": "payto://qr/kh/john_smith%40devb" + }, + { + "country": "id", + "scheme": "qris", + "identifier": "ID123456", + "uri": "payto://qr/id/ID123456" + }, + { + "country": "la", + "scheme": "laoqr", + "identifier": "ABC123", + "uri": "payto://qr/la/ABC123" + }, + { + "country": "my", + "scheme": "duitnow", + "identifier": "MERCHANT01", + "uri": "payto://qr/my/MERCHANT01" + }, + { + "country": "mm", + "scheme": "mmqr", + "identifier": "123456789012345", + "uri": "payto://qr/mm/123456789012345" + }, + { + "country": "ph", + "scheme": "qrph", + "identifier": "ABC123", + "uri": "payto://qr/ph/ABC123" + }, + { + "country": "sg", + "scheme": "paynow", + "identifier": "+6581234567", + "uri": "payto://qr/sg/%2B6581234567" + }, + { + "country": "th", + "scheme": "promptpay", + "identifier": "0066812345678", + "uri": "payto://qr/th/0066812345678" + }, + { + "country": "vn", + "scheme": "vietqr", + "identifier": "ABC123", + "uri": "payto://qr/vn/ABC123" + }, + { + "country": "ph", + "scheme": "qrph", + "identifier": "Demo@Issuer", + "uri": "payto://qr/ph/Demo%40Issuer?payment-mode=p2p&qr-type=static" + }, + { + "country": "ph", + "scheme": "qrph", + "identifier": "Demo@Issuer", + "uri": "payto://qr/ph/Demo%40Issuer?amount=PHP%3A15.25&payment-mode=p2p&qr-type=dynamic" + }, + { + "country": "ph", + "scheme": "qrph", + "identifier": "Demo@Issuer", + "uri": "payto://qr/ph/Demo%40Issuer?payment-mode=p2m&qr-type=static" + }, + { + "country": "ph", + "scheme": "qrph", + "identifier": "Demo@Issuer", + "uri": "payto://qr/ph/Demo%40Issuer?amount=PHP%3A15.25&payment-mode=p2m&qr-type=dynamic" + }, + { + "country": "id", + "scheme": "qris", + "identifier": "Demo@Issuer", + "uri": "payto://qr/id/Demo%40Issuer?qr-type=static" + }, + { + "country": "id", + "scheme": "qris", + "identifier": "Demo", + "uri": "payto://qr/id/Demo?amount=IDR%3A10000000.00&qr-type=dynamic" + }, + { + "country": "id", + "scheme": "qris", + "identifier": "Demo", + "uri": "payto://qr/id/Demo?amount=IDR%3A0.01" + } + ], + "invalid": [ + "payto://qr", + "payto://qr/", + "payto://qr/la", + "payto://qr/la/", + "payto://qr/zz/ABC", + "payto://qr/br/ABC", + "payto://qr/LA/../ABC", + "payto://qr/la/%2E%2E", + "payto://qr/la/https%3A%2F%2Fevil.example", + "payto://qr/la/ABC/DEF", + "payto://qr/la/A%2FB", + "payto://qr/la/%00", + "payto://qr/la/%ZZ", + "payto://qr/la/%C0%AF", + "payto://qr/la/%ED%A0%80", + "payto://qr/la/ABC#x", + "payto://user@qr/la/ABC", + "payto://qr:90/la/ABC", + "payto://qr/la/ABC?message=x&message=y", + "payto://qr/la/ABC?message=%0A", + "payto://qr/la/ABC?message=%FF", + "payto://qr/la/ABC?message", + "payto://qr/la/ABC?scheme=laoqr", + "payto://qr/la/ABC?amount=USD:1", + "payto://qr/my/ABC?amount=MYR:-1", + "payto://qr/my/ABC?amount=MYR:0", + "payto://qr/my/ABC?amount=MYR:01", + "payto://qr/my/ABC?amount=MYR:1e3", + "payto://qr/my/ABC?amount=USD:1", + "payto://qr/my/ABC?amount=MYR:1.234", + "payto://qr/kh/name@bank?amount=KHR:1.23", + "payto://qr/th/123", + "payto://qr/sg/%2B60123456789", + "payto://qr/kh/name", + "payto://qr/mm/123", + "payto://qr/my/a-b", + "payto://qr/la/ABC?identifier-type=mobile", + "payto://qr/la/ABC?qr-type=bad", + "payto://qr/la/ABC%0A", + "payto://qr/la/ABC%0D", + "payto://qr/la/ABC%20", + "payto://qr/ph/", + "payto://qr/ph/a%2Fb", + "payto://qr/ph/%FF", + "payto://qr/ph/a?payment-mode=merchant", + "payto://qr/ph/a?payment-mode=p2p&payment-mode=p2m", + "payto://qr/ph/a?qr-type=dynamic", + "payto://qr/ph/a?receiver-name=%00", + "payto://qr/ph/a?identifier-type=mobile", + "payto://qr/ph/a?amount=USD:1", + "payto://qr/ph/a?amount=PHP:0", + "payto://qr/ph/a?amount=PHP:-1", + "payto://qr/ph/a?amount=PHP:01", + "payto://qr/ph/a?amount=PHP:1e3", + "payto://qr/ph/a?amount=PHP:1.234", + "payto://qr/ph/a?amount=PHP:NaN", + "payto://qr/id/Demo?amount=IDR:10000000.01", + "payto://qr/id/Demo?amount=IDR:99999999999", + "payto://qr/id/Demo?amount=IDR:1&qr-type=static", + "payto://qr/id/Demo?qr-type=dynamic", + "payto://qr/id/Demo?amount=USD:1", + "payto://qr/id/Demo?amount=IDR:1.001", + "payto://qr/id/Demo?amount=IDR:0", + "payto://qr/id/Demo?amount=IDR:-1", + "payto://qr/id/Demo?amount=IDR:1e3", + "payto://qr/id/Demo?qr-type=cpm", + "payto://qr/id/Demo?identifier-type=nmid" + ] +} diff --git a/test/fixtures/qris-emv.json b/test/fixtures/qris-emv.json new file mode 100644 index 0000000..4c17665 --- /dev/null +++ b/test/fixtures/qris-emv.json @@ -0,0 +1,49 @@ +{ + "source": "https://docs.midtrans.com/docs/gopay-qris-pos-integration", + "description": "Official participant dynamic QRIS example, not a national conformance vector. Raw envelope inspection only.", + "payload": "00020101021226620014COM.GO-JEK.WWW011993600914349052630340210G4905263030303UKE51440014ID.CO.QRIS.WWW0215AID0607336128660303UKE5204341453033605802ID5904Test6007BANDUNG6105402845409789000.0062475036c032f87c-f773-4619-aefa-675e1f06f9210703A016304A623", + "fields": { + "00": "01", + "01": "12", + "26": "0014COM.GO-JEK.WWW011993600914349052630340210G4905263030303UKE", + "51": "0014ID.CO.QRIS.WWW0215AID0607336128660303UKE", + "52": "3414", + "53": "360", + "58": "ID", + "59": "Test", + "60": "BANDUNG", + "61": "40284", + "54": "789000.00", + "62": "5036c032f87c-f773-4619-aefa-675e1f06f9210703A01", + "63": "A623" + }, + "templates": { + "26": { + "00": "COM.GO-JEK.WWW", + "01": "9360091434905263034", + "02": "G490526303", + "03": "UKE" + }, + "51": { + "00": "ID.CO.QRIS.WWW", + "02": "AID060733612866", + "03": "UKE" + }, + "62": { + "50": "c032f87c-f773-4619-aefa-675e1f06f921", + "07": "A01" + } + }, + "invalid": [ + "00020101021226620014COM.GO-JEK.WWW011993600914349052630340210G4905263030303UKE51440014ID.CO.QRIS.WWW0215AID0607336128660303UKE5204341453033605802ID5904Test6007BANDUNG6105402845409789000.0062475036c032f87c-f773-4619-aefa-675e1f06f9210703A016304A620", + "00020101021226620014COM.GO-JEK.WWW011993600914349052630340210G4905263030303UKE51440014ID.CO.QRIS.WWW0215AID0607336128660303UKE5204341453033605802ID5904Test6007BANDUNG6105402845409789000.0062475036c032f87c-f773-4619-aefa-675e1f06f9210703A016304A62", + "00020101021226620014COM.GO-JEK.WWW011993600914349052630340210G4905263030303UKE51440014ID.CO.QRIS.WWW0215AID0607336128660303UKE5204341453033605802ID5904Test6007BANDUNG6105402845409789000.0062475036c032f87c-f773-4619-aefa-675e1f06f9210703A016304A62300", + "00020101021226990014COM.GO-JEK.WWW011993600914349052630340210G4905263030303UKE51440014ID.CO.QRIS.WWW0215AID0607336128660303UKE5204341453033605802ID5904Test6007BANDUNG6105402845409789000.0062475036c032f87c-f773-4619-aefa-675e1f06f9210703A016304A623", + "00020126070003ab6304AC1C", + "00020100020163040389", + "00020126120001A0001B630474FF", + "000201260063048B47", + "0002016304abcd", + "0002015901\u00006304ABCD" + ] +} diff --git a/test/formats_test.dart b/test/formats_test.dart new file mode 100644 index 0000000..44850df --- /dev/null +++ b/test/formats_test.dart @@ -0,0 +1,65 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_paytorl/flutter_paytorl.dart'; + +void main() { + final fixture = jsonDecode( + File('test/fixtures/payqr.json').readAsStringSync(), + ); + for (final sample in fixture['valid']) { + test('formats for ${sample['uri']}', () { + final p = Payto(sample['uri']); + final formats = ['bn', 'ph', 'id'].contains(sample['country']) + ? null + : ['payto', sample['scheme']]; + expect(p.formats, formats); + final json = p.toJsonObject().toJson(); + expect(json['formats'], formats); + expect(json.containsKey('formats'), formats != null); + expect(PaytoJson.fromJson(json).toJson(), json); + expect(p.toString(), sample['uri']); + expect(p.toJson(), sample['uri']); + }); + } + test('IBAN extensions roundtrip as PayTo data only', () { + final p = Payto('payto://iban/FR1420041010050500013M02606'); + expect(p.formats, ['payto', 'epc']); + expect(() => p.formats!.add('khqr'), throwsUnsupportedError); + p.reference = 'RF18539007547034'; + p.purpose = 'GDDS'; + p.information = 'Invoice / café & 1'; + final parsed = Payto(p.toString()); + expect(parsed.reference, p.reference); + expect(parsed.purpose, 'GDDS'); + expect(parsed.information, p.information); + final json = parsed.toJsonObject().toJson(); + expect(json['formats'], ['payto', 'epc']); + expect(json['purpose'], 'GDDS'); + expect(json['information'], p.information); + expect(PaytoJson.fromJson(json).toJson(), json); + expect(p.toString(), startsWith('payto://iban/')); + expect( + Uri.parse(p.toString()).queryParameters.containsKey('formats'), + false, + ); + parsed.purpose = null; + parsed.information = null; + expect( + Uri.parse(parsed.toString()).queryParameters.containsKey('purpose'), + false, + ); + expect(parsed.toJsonObject().toJson().containsKey('information'), false); + }); + test('PayTo-only methods omit formats', () { + final p = Payto('payto://xcb/CB123'); + expect(p.formats, isNull); + expect(p.toJsonObject().toJson().containsKey('formats'), false); + expect( + PaytoJson.fromJson({ + 'formats': ['payto'], + }).toJson().containsKey('formats'), + false, + ); + }); +} diff --git a/test/payqr_contract.dart b/test/payqr_contract.dart new file mode 100644 index 0000000..8560503 --- /dev/null +++ b/test/payqr_contract.dart @@ -0,0 +1,209 @@ +import 'dart:convert'; +import 'dart:io'; +import '../lib/flutter_paytorl.dart'; + +void check(bool condition) { + if (!condition) throw StateError('PayQR contract assertion failed'); +} + +void rejects(void Function() operation) { + try { + operation(); + } catch (_) { + return; + } + throw StateError('Expected invalid PayQR input to be rejected'); +} + +Map payQrContract() { + final fixture = + jsonDecode(File('test/fixtures/payqr.json').readAsStringSync()) + as Map; + final tests = {}; + for (final sample in fixture['valid'] as List) { + tests['${sample['uri']}: roundtrips, properties, JSON, queries, setters'] = + () { + final p = Payto(sample['uri']); + check( + p.network == 'qr' && + p.country == sample['country'] && + p.scheme == sample['scheme'], + ); + check( + p.identifier == sample['identifier'] && + p.address == sample['identifier'], + ); + check( + p.identifierType == payQrSchemes[p.country]!.identifierTypes.first, + ); + check(p.toString() == sample['uri'] && p.toJson() == sample['uri']); + check(p.toJsonObject().country == sample['country']); + check( + PaytoJson.fromJson(p.toJsonObject().toJson()).scheme == + sample['scheme'], + ); + p.country = (sample['country'] as String).toUpperCase(); + p.identifier = sample['identifier']; + p.identifierType = p.identifierType; + p.reference = 'invoice/1 & 2'; + p.message = 'ຂອບໃຈ + thanks'; + p.receiverName = 'José'; + final roundtrip = Payto(p.toString()); + check( + roundtrip.reference == p.reference && + roundtrip.message == p.message && + roundtrip.receiverName == p.receiverName, + ); + check( + PayQrTarget.parse( + PayQrTarget.parse(p.toString()).toString(), + ).toString() == + p.toString(), + ); + rejects(() { + p.identifier = 'https://bad.example'; + }); + check(p.identifier == sample['identifier']); + rejects(() { + p.identifier = null; + }); + }; + } + var index = 0; + for (final uri in fixture['invalid'] as List) { + tests['reject malformed URI ${index++}'] = () { + rejects(() => PayQrTarget.parse(uri)); + rejects(() => Payto(uri)); + }; + } + tests['Unicode identifiers, percent encoding and exact query preservation'] = + () { + final p = PayQrTarget( + country: 'la', + identifier: 'ລາວ_É', + parameters: {'reference': '#1', 'message': 'A&B /?'}, + ); + check(PayQrTarget.parse(p.toString()).identifier == 'ລາວ_É'); + check(p.toString().contains('message=A%26B%20%2F%3F&reference=%231')); + }; + tests['Amounts and supported currencies'] = () { + for (final values in [ + ['kh', 'name@bank', 'USD'], + ['id', 'ABC', 'IDR'], + ['my', 'ABC', 'MYR'], + ['mm', '123456789012345', 'MMK'], + ['sg', '%2B6581234567', 'SGD'], + ['th', '0066812345678', 'THB'], + ]) { + final p = Payto( + 'payto://qr/${values[0]}/${values[1]}?amount=${values[2]}:12.34', + ); + check( + p.amount == '${values[2]}:12.34' && + p.currency.first == values[2] && + p.value == 12.34, + ); + } + }; + tests['Non-PayQR networks retain their existing URI behavior'] = () { + for (final uri in [ + 'payto://ach/110000000/000123456789', + 'payto://upi/name@bank', + 'payto://pix/name@example.com', + 'payto://iban/DE89370400440532013000', + 'payto://bic/DEUTDEFF/ABC', + 'payto://intra/BANK/001', + 'payto://void/geo?loc=1,2', + ]) { + final p = Payto(uri); + check(p.toString() == uri && p.country == null && p.scheme == null); + } + }; + tests['Validated parameter setters are atomic'] = () { + final p = Payto('payto://qr/my/MERCHANT01?amount=MYR:1'); + rejects(() { + p.amount = 'MYR:-1'; + }); + check(p.amount == 'MYR:1'); + p.amount = 'MYR:2.50'; + check(Payto(p.toString()).amount == 'MYR:2.50'); + rejects(() { + p.message = 'bad\nmessage'; + }); + check(p.message == null); + p.address = 'MERCHANT02'; + check(p.identifier == 'MERCHANT02'); + }; + tests['New national profile query fields preserve URI parity'] = () { + for (final uri in [ + 'payto://qr/kh/name%40bank?merchant-city=Phnom%20Penh&qr-currency=USD&receiver-name=Test', + 'payto://qr/sg/%2B6581234567?receiver-name=Test', + 'payto://qr/vn/0011009950446?acquirer-id=970468', + 'payto://qr/mm/123456789012345?local-name=%E1%80%86%E1%80%AD%E1%80%AF%E1%80%84%E1%80%BA&scheme-id=MM.COM.MMQR', + ]) { + check(Payto(uri).toString() == uri); + } + for (final uri in [ + 'payto://qr/vn/123?acquirer-id=12', + 'payto://qr/vn/123?amount=VND:1.50', + 'payto://qr/kh/name%40bank?qr-currency=MYR', + 'payto://qr/kh/name%40bank?qr-currency=USD&amount=KHR:1', + 'payto://qr/mm/123456789012345?scheme-id=https%3A%2F%2Fexample.com', + 'payto://qr/mm/123456789012345?local-name=English', + ]) { + rejects(() => Payto(uri)); + } + }; + tests['LaoQR routing fields and LAK validation'] = () { + final uri = + 'payto://qr/la/0005555555?application-id=A000000677012111&acquirer-id=621354&receiver-name=Test'; + final target = PayQrTarget.parse(uri); + check(target.identifier == '0005555555'); + check( + PayQrTarget.parse(target.toString()).parameters['application-id'] == + 'A000000677012111', + ); + check( + PayQrTarget.parse('$uri&amount=LAK:1').parameters['amount'] == 'LAK:1', + ); + rejects(() => PayQrTarget.parse(uri.replaceFirst('621354', '123'))); + rejects( + () => PayQrTarget.parse(uri.replaceFirst('A000000677012111', 'bad')), + ); + }; + tests['Thai input normalizes into canonical URI'] = () { + for (final identifier in ['0812345678', '+66812345678', '0066812345678']) { + check( + PayQrTarget(country: 'th', identifier: identifier).identifier == + '0066812345678', + ); + } + }; + tests['QR Ph properties, atomic setters and input limit'] = () { + final p = Payto( + 'payto://qr/ph/Demo?amount=PHP:15.25&payment-mode=p2p&qr-type=dynamic', + ); + check(p.paymentMode == 'p2p' && p.qrType == 'dynamic'); + check(p.toJsonObject().paymentMode == 'p2p'); + p.paymentMode = 'p2m'; + check(PaytoJson.fromJson(p.toJsonObject().toJson()).paymentMode == 'p2m'); + final before = p.toString(); + rejects(() { + p.paymentMode = 'unknown'; + }); + check(p.toString() == before); + p.qrType = null; + check(p.qrType == null); + rejects(() => PayQrTarget.parse('payto://qr/ph/a?' + 'x' * 8192)); + }; + return tests; +} + +// Also runnable directly using the bundled Dart VM when Flutter's launcher is unavailable. +void main() { + final tests = payQrContract(); + for (final test in tests.values) { + test(); + } + stdout.writeln('PayQR: ${tests.length} contract tests passed'); +} diff --git a/test/payqr_test.dart b/test/payqr_test.dart new file mode 100644 index 0000000..529a65f --- /dev/null +++ b/test/payqr_test.dart @@ -0,0 +1,8 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'payqr_contract.dart'; + +void main() { + for (final entry in payQrContract().entries) { + test(entry.key, entry.value); + } +}