/// Paynex merchant API client for Dart (server side: dart:io, shelf, Serverpod, Cloud Run…). /// /// ```dart /// final px = Paynex(baseUrl: 'https://pay.example.com', keyId: 'pk_…', secret: 'sk_…'); /// final p = await px.createPayment( /// amount: '1500.00', merchantRef: 'INV-1042', /// returnUrl: 'myapp://paynex/return', cancelUrl: 'myapp://paynex/cancel', /// idempotencyKey: 'INV-1042-1500.00'); /// // hand p.checkoutUrl to the app /// ``` /// /// Never embed keyId/secret in a Flutter app — the app only opens `checkout_url` and /// handles the deep link (see README.md for the Flutter side, which needs no key at all). library paynex; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'package:crypto/crypto.dart'; class PaynexException implements Exception { final int httpStatus; final String code; final String message; PaynexException(this.httpStatus, this.code, this.message); @override String toString() => 'PaynexException: $code ($httpStatus): $message'; } class Payment { final Map raw; Payment(this.raw); String get id => raw['id'] as String; String get status => raw['status'] as String; String get amount => raw['amount'].toString(); String get currency => raw['currency'] as String; String get merchantRef => (raw['merchant_ref'] ?? '') as String; String? get checkoutUrl => raw['checkout_url'] as String?; String? get checkoutToken => raw['checkout_token'] as String?; String? get providerTxnId => raw['provider_txn_id'] as String?; DateTime? get paidAt => raw['paid_at'] == null ? null : DateTime.parse(raw['paid_at'] as String); DateTime get expiresAt => DateTime.parse(raw['expires_at'] as String); bool get paid => status == 'paid' || status == 'refunded' || status == 'partially_refunded'; } class Refund { final Map raw; Refund(this.raw); String get id => raw['id'] as String; String get paymentId => raw['payment_id'] as String; String get amount => raw['amount'].toString(); String get status => raw['status'] as String; // succeeded | requested | failed } class WebhookEvent { final String event; final Map data; WebhookEvent(this.event, this.data); } class ReturnParams { final String paymentId; final String status; final DateTime at; ReturnParams(this.paymentId, this.status, this.at); } class Paynex { final String baseUrl; final String keyId; final String secret; final HttpClient _http; Paynex({required String baseUrl, required this.keyId, required this.secret, HttpClient? httpClient}) : baseUrl = baseUrl.replaceAll(RegExp(r'/+$'), ''), _http = httpClient ?? (HttpClient()..connectionTimeout = const Duration(seconds: 30)); /// hex(HMAC-SHA256(secret, ts\nnonce\nMETHOD\npath\nhex(sha256(body)))) static String signature(String secret, String ts, String nonce, String method, String path, String body) { final bodyHash = sha256.convert(utf8.encode(body)).toString(); final msg = [ts, nonce, method.toUpperCase(), path, bodyHash].join('\n'); return Hmac(sha256, utf8.encode(secret)).convert(utf8.encode(msg)).toString(); } Map sign(String method, String path, String body) { final ts = (DateTime.now().millisecondsSinceEpoch ~/ 1000).toString(); final rnd = Random.secure(); final nonce = List.generate(16, (_) => rnd.nextInt(256)).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); return { 'Authorization': 'Paynex-HMAC key_id=$keyId', 'X-PG-Timestamp': ts, 'X-PG-Nonce': nonce, 'X-PG-Signature': signature(secret, ts, nonce, method, path, body), }; } Future> request(String method, String pathWithQuery, {Object? payload, String? idempotencyKey}) async { final body = payload == null ? '' : jsonEncode(payload); final path = pathWithQuery.split('?').first; final req = await _http.openUrl(method, Uri.parse(baseUrl + pathWithQuery)); sign(method, path, body).forEach(req.headers.set); req.headers.set('Content-Type', 'application/json'); req.headers.set('Accept', 'application/json'); req.headers.set('User-Agent', 'paynex-dart/1.0'); if (idempotencyKey != null) req.headers.set('Idempotency-Key', idempotencyKey); if (body.isNotEmpty) req.write(body); final res = await req.close(); final text = await utf8.decoder.bind(res).join(); dynamic data; try { data = text.isEmpty ? null : jsonDecode(text); } catch (_) { data = null; } if (res.statusCode >= 300) { final e = (data is Map && data['error'] is Map) ? data['error'] as Map : const {}; throw PaynexException(res.statusCode, (e['code'] ?? 'http_${res.statusCode}') as String, (e['message'] ?? text.trim()) as String); } return (data as Map?)?.cast() ?? {}; } // ---- payments ---- Future createPayment({ required String amount, required String returnUrl, required String cancelUrl, required String idempotencyKey, String? currency, String? merchantRef, String? description, Map? customer, Object? metadata, int? expiresIn, }) async { final p = {'amount': amount, 'return_url': returnUrl, 'cancel_url': cancelUrl}; if (currency != null) p['currency'] = currency; if (merchantRef != null) p['merchant_ref'] = merchantRef; if (description != null) p['description'] = description; if (customer != null) p['customer'] = customer; if (metadata != null) p['metadata'] = metadata; if (expiresIn != null) p['expires_in'] = expiresIn; return Payment(await request('POST', '/v1/payments', payload: p, idempotencyKey: idempotencyKey)); } Future getPayment(String id) async => Payment(await request('GET', '/v1/payments/${Uri.encodeComponent(id)}')); Future<({List data, String nextCursor})> listPayments({String? status, String? merchantRef, int? limit, String? cursor}) async { final q = {}; if (status != null) q['status'] = status; if (merchantRef != null) q['merchant_ref'] = merchantRef; if (limit != null) q['limit'] = '$limit'; if (cursor != null) q['cursor'] = cursor; final qs = Uri(queryParameters: q.isEmpty ? null : q).query; final r = await request('GET', '/v1/payments${qs.isEmpty ? '' : '?$qs'}'); return (data: (r['data'] as List).map((e) => Payment((e as Map).cast())).toList(), nextCursor: (r['next_cursor'] ?? '') as String); } Future cancelPayment(String id) async => Payment(await request('POST', '/v1/payments/${Uri.encodeComponent(id)}/cancel')); Future verifyPayment(String id) async => Payment(await request('POST', '/v1/payments/${Uri.encodeComponent(id)}/verify')); // ---- refunds / store ---- Future createRefund({required String paymentId, String? amount, String? reason, String? idempotencyKey}) async { final p = {'payment_id': paymentId}; if (amount != null) p['amount'] = amount; if (reason != null) p['reason'] = reason; return Refund(await request('POST', '/v1/refunds', payload: p, idempotencyKey: idempotencyKey)); } Future getRefund(String id) async => Refund(await request('GET', '/v1/refunds/${Uri.encodeComponent(id)}')); Future> getStore() => request('GET', '/v1/store'); // ---- inbound ---- /// Verify `X-PG-Signature` ("t=…,v1=…") against the exact raw body bytes and decode the event. static WebhookEvent verifyWebhook(String webhookSecret, String? signatureHeader, List rawBody, {int toleranceSec = 300}) { String? t, v1; for (final part in (signatureHeader ?? '').split(',')) { final kv = part.trim().split('='); if (kv.length != 2) continue; if (kv[0] == 't') t = kv[1]; if (kv[0] == 'v1') v1 = kv[1]; } if (t == null || v1 == null) throw PaynexException(401, 'bad_signature', 'missing t/v1'); final ts = int.tryParse(t) ?? 0; if (toleranceSec > 0 && (DateTime.now().millisecondsSinceEpoch ~/ 1000 - ts).abs() > toleranceSec) { throw PaynexException(401, 'bad_signature', 'timestamp outside tolerance'); } final expected = Hmac(sha256, utf8.encode(webhookSecret)).convert([...utf8.encode('$t.'), ...rawBody]).toString(); if (!_constantTimeEquals(expected, v1.toLowerCase())) throw PaynexException(401, 'bad_signature', 'signature mismatch'); final m = (jsonDecode(utf8.decode(rawBody)) as Map).cast(); return WebhookEvent(m['event'] as String, (m['data'] as Map).cast()); } /// Verify the signed return redirect / deep link query (pg_ref, pg_status, pg_ts, pg_sig). /// UX only — confirm with [getPayment]. Returns null when the signature is invalid. static ReturnParams? verifyReturn(String webhookSecret, Map query, {int toleranceSec = 300}) { final ref = query['pg_ref'], st = query['pg_status'], ts = query['pg_ts'], sig = query['pg_sig']; if (ref == null || st == null || ts == null || sig == null) return null; final n = int.tryParse(ts) ?? 0; if (toleranceSec > 0 && (DateTime.now().millisecondsSinceEpoch ~/ 1000 - n).abs() > toleranceSec) return null; final expected = Hmac(sha256, utf8.encode(webhookSecret)).convert(utf8.encode('$ref.$st.$ts')).toString(); if (!_constantTimeEquals(expected, sig.toLowerCase())) return null; return ReturnParams(ref, st, DateTime.fromMillisecondsSinceEpoch(n * 1000)); } static bool _constantTimeEquals(String a, String b) { if (a.length != b.length) return false; var r = 0; for (var i = 0; i < a.length; i++) { r |= a.codeUnitAt(i) ^ b.codeUnitAt(i); } return r == 0; } }