diff --git a/android/src/main/java/jp/pokepay/pokepay_sdk/PokepaySdkPlugin.java b/android/src/main/java/jp/pokepay/pokepay_sdk/PokepaySdkPlugin.java index 7a4dd3e..ae246e0 100644 --- a/android/src/main/java/jp/pokepay/pokepay_sdk/PokepaySdkPlugin.java +++ b/android/src/main/java/jp/pokepay/pokepay_sdk/PokepaySdkPlugin.java @@ -244,6 +244,18 @@ private Date stringToDate(String s) { } } + // requestId は任意項目。Dart から null が来た場合はネイティブ SDK に + // そのまま null を渡す (pokepaylib は request_id を送らない)。 + // 形式チェックは Dart 側 (parameters/request_id.dart の validateRequestId) + // で行うため、ここでは null ガードのみ。想定外の値は + // IllegalArgumentException のまま ProcessingError として伝搬させる + // (null に落とすと iOS で問題になっている request_id の + // 黙った欠落を Android でも再現してしまう)。 + private UUID parseRequestId(String rawRequestId) { + if (rawRequestId == null) return null; + return UUID.fromString(rawRequestId); + } + private TaskResult invokeMethod() { try { switch (call.method) { @@ -383,7 +395,7 @@ private TaskResult invokeMethod() { TransactionStrategy txStrategy = parseTxStrategy(rawStrategy); Double amount = call.argument("amount"); String rawRequestId = call.argument("requestId"); - UUID requestId = UUID.fromString(rawRequestId); + UUID requestId = parseRequestId(rawRequestId); if (amount != null){ req = new CreateTransactionWithBill(billId, accountId, amount, couponId, txStrategy, requestId); }else{ @@ -402,7 +414,7 @@ private TaskResult invokeMethod() { String couponId = call.argument("couponId"); String rawStrategy = call.argument("tx_strategy"); String rawRequestId = call.argument("requestId"); - UUID requestId = UUID.fromString(rawRequestId); + UUID requestId = parseRequestId(rawRequestId); TransactionStrategy txStrategy = parseTxStrategy(rawStrategy); Integer topupQuotaId = call.argument("topupQuotaId"); CreateTransactionWithCashtray req = new CreateTransactionWithCashtray(cashtrayId, accountId, couponId,txStrategy,requestId).topupQuotaId(topupQuotaId); @@ -416,7 +428,7 @@ private TaskResult invokeMethod() { String checkId = call.argument("checkId"); String accountId = call.argument("accountId"); String rawRequestId = call.argument("requestId"); - UUID requestId = UUID.fromString(rawRequestId); + UUID requestId = parseRequestId(rawRequestId); Integer topupQuotaId = call.argument("topupQuotaId"); CreateTransactionWithCheck req = new CreateTransactionWithCheck(checkId, accountId, requestId).topupQuotaId(topupQuotaId); Pokepay.setEnv(env); @@ -433,7 +445,7 @@ private TaskResult invokeMethod() { final ObjectMapper mapper = JsonConverter.createObjectMapper(); Product[] products = mapper.readValue(productsString, Product[].class); String rawRequestId = call.argument("requestId"); - UUID requestId = UUID.fromString(rawRequestId); + UUID requestId = parseRequestId(rawRequestId); Integer topupQuotaId = call.argument("topupQuotaId"); CreateTransactionWithCpm req = new CreateTransactionWithCpm(cpmToken, accountId, amount, products, requestId).topupQuotaId(topupQuotaId); Pokepay.setEnv(env); @@ -810,7 +822,7 @@ private TaskResult invokeMethod() { TransactionStrategy txStrategy = parseTxStrategy(rawStrategy); String privateMoneyId = call.argument("privateMoneyId"); String rawRequestId = call.argument("requestId"); - UUID requestId = UUID.fromString(rawRequestId); + UUID requestId = parseRequestId(rawRequestId); Pokepay.Client client; UserTransaction userTransaction; diff --git a/lib/bank_api/transaction.dart b/lib/bank_api/transaction.dart index ff96f4b..b249caf 100644 --- a/lib/bank_api/transaction.dart +++ b/lib/bank_api/transaction.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import '../parameters/transaction_strategy.dart'; import '../parameters/product.dart'; +import '../parameters/request_id.dart'; import '../pokepay_sdk.dart'; import '../responses.dart'; @@ -25,7 +26,7 @@ extension TransactionAPI on PokepayAPI { 'amount': amount, 'couponId': couponId, 'tx_strategy': strategy.value, - 'requestId': requestId, + 'requestId': validateRequestId(requestId), }, ); } @@ -48,7 +49,7 @@ extension TransactionAPI on PokepayAPI { 'accountId': accountId, 'couponId': couponId, 'tx_strategy': strategy.value, - 'requestId': requestId, + 'requestId': validateRequestId(requestId), 'topupQuotaId': topupQuotaId, }, ); @@ -68,7 +69,7 @@ extension TransactionAPI on PokepayAPI { 'accessToken': this.accessToken, 'checkId': checkId, 'accountId': accountId, - 'requestId': requestId, + 'requestId': validateRequestId(requestId), 'topupQuotaId': topupQuotaId, }, ); @@ -92,7 +93,7 @@ extension TransactionAPI on PokepayAPI { 'accountId': accountId, 'amount': amount, 'products': jsonEncode(products), - 'requestId': requestId, + 'requestId': validateRequestId(requestId), 'topupQuotaId': topupQuotaId, }, ); @@ -201,7 +202,7 @@ extension TransactionAPI on PokepayAPI { { 'env': this.env.index, 'accessToken': this.accessToken, - 'requestId': requestId, + 'requestId': validateRequestId(requestId), }, ); } diff --git a/lib/parameters/request_id.dart b/lib/parameters/request_id.dart new file mode 100644 index 0000000..8fae818 --- /dev/null +++ b/lib/parameters/request_id.dart @@ -0,0 +1,42 @@ +import '../responses/error.dart'; + +/// A canonical RFC 4122 textual UUID in **lowercase**: 8-4-4-4-12 hex digits. +/// +/// Two independent constraints force this exact shape: +/// +/// * **Lowercase**, because the API only accepts lowercase `request_id`. +/// Both native SDKs already normalise on write -- Java's `UUID.toString()` +/// emits lowercase, and iOS has an explicit `pokepayRequestID` extension +/// (`uuidString.lowercased()`) because Foundation's `uuidString` is +/// uppercase. But `getTransactionByRequestId` sends the caller's string +/// through verbatim on both platforms, so an uppercase value would write +/// successfully and then be unreadable. Rejecting instead of normalising +/// keeps the caller's string byte-identical to what the server stores. +/// * **Strict 8-4-4-4-12**, because this is the intersection of the two +/// native parsers. Java's `UUID.fromString` is lenient and also accepts +/// e.g. "1-1-1-1-1"; iOS's `UUID(uuidString:)` requires the canonical +/// form. Validating to the intersection is what lets a single Dart-side +/// check keep both platforms in agreement. +final RegExp _requestIdPattern = RegExp( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'); + +/// Validates the optional `requestId` (idempotency key) and returns it +/// unchanged. `null` is allowed and means "no idempotency key". +/// +/// Validating here rather than in the native plugins makes both platforms +/// behave identically by construction. Android's `UUID.fromString` rejects a +/// malformed value loudly, while iOS's `UUID(uuidString:)` yields nil, which +/// makes the native SDK omit `request_id` from the request body entirely -- +/// the server then creates a NON-idempotent transaction, so a retry +/// double-charges the user with no error raised anywhere. +String? validateRequestId(String? requestId) { + if (requestId == null) return null; + if (!_requestIdPattern.hasMatch(requestId)) { + throw ProcessingError( + message: 'requestId must be a lowercase UUID in the 8-4-4-4-12 ' + 'hexadecimal form (e.g. "550e8400-e29b-41d4-a716-446655440000"), ' + 'but got "$requestId".', + ); + } + return requestId; +} diff --git a/lib/pokepay_sdk.dart b/lib/pokepay_sdk.dart index 4d29367..87a6cd9 100644 --- a/lib/pokepay_sdk.dart +++ b/lib/pokepay_sdk.dart @@ -5,6 +5,7 @@ import 'package:flutter/services.dart'; import 'package:logger/logger.dart'; import 'bank_api/private_money.dart'; import 'parameters/transaction_strategy.dart'; +import 'parameters/request_id.dart'; import 'bank_api/bill.dart'; import 'bank_api/check.dart'; @@ -331,7 +332,7 @@ class PokepayClient { 'couponId': couponId, 'tx_strategy': strategy.value, 'privateMoneyId': this.privateMoneyId, - 'requestId': requestId, + 'requestId': validateRequestId(requestId), }); return UserTransaction.fromJson(jsonDecode(json));