/// ROKI Connect - Dart client. /// /// Generated from openapi.yaml v2.0.0. Do not edit by hand: change the /// spec and run `node sdk/generate.mjs`. /// /// Dart 3, no packages. `dart:io` does the HTTP; the webhook verifier carries /// its own SHA-256 because Dart's core libraries ship no hashing primitive at /// all. If you would rather not have that code here, `package:crypto` computes /// the same 32 bytes; and if you need a transport `dart:io` cannot give you, /// pass a [RokiTransport] built on `package:http`. /// /// ```dart /// final roki = RokiConnect(Platform.environment['ROKI_SECRET_KEY']!); /// final payment = await roki.createPayment({ /// 'amount': 150.00, /// 'external_reference': order.id.toString(), /// 'name': 'Order #${order.id}', /// }); /// // Send the customer to payment['checkout_url']. /// ``` /// /// **This is a backend client.** An `sk_` key must never be compiled into a /// Flutter app: strings are readable straight out of an .apk or an .ipa, and /// whoever has that key can charge, refund and list every payment the merchant /// ever took. A Flutter app calls *your* server; your server calls ROKI. The /// only credential that belongs on a device is the publishable `pk_` key, which /// this client rejects on purpose. (`dart:io` does not exist on Flutter Web /// either, so this file cannot be compiled into a browser bundle at all - that /// is the guardrail working, not a gap.) /// /// The environment is the key: sk_test_ is sandbox, sk_live_ is production. /// Same routes. library; import 'dart:convert'; import 'dart:io'; import 'dart:math'; import 'dart:typed_data'; /// Version of `openapi.yaml` this client was generated from. const String rokiSpecVersion = '2.0.0'; /// Version of the generated client itself. Plain semver, so pub and any range /// check can order it. /// /// The build fingerprint is kept out of it on purpose: glued to the version it /// reads as an unknown prerelease to a semver comparator - lower than the bare /// version - which is how the PHP twin of this file started reporting itself as /// out of date while it was current. const String rokiSdkVersion = '2.0.0'; /// Fingerprint of the contract, the alias map and the generator templates this /// file came out of. /// /// Not a version and never compared as one: it only tells two copies apart. It /// rides along in the `User-Agent`, which is free text nobody sorts. const String rokiSdkBuild = '12e0e82'; /// Root of the main API. The embedded-components endpoint lives elsewhere and /// the client already knows where. const String rokiDefaultBaseUrl = 'https://aura.roki.systems/api/connect/v1'; /// Called whenever the API reports it ignored a field it did not recognise. /// /// With [RokiConnect.strict] on, this firing means the spec this client was /// generated from is behind the live API - regenerate it. typedef RokiWarningHandler = void Function( List warnings, String method, String path, ); /// How a request actually reaches the network. /// /// The default is `dart:io`'s [HttpClient]. Supply your own to run the client /// somewhere `dart:io` cannot go, or to answer from a fixture in a test without /// mocking HTTP at all: /// /// ```dart /// // package:http, for a transport dart:io does not cover. /// Future<({int status, Uint8List body})> viaHttp( /// String method, Uri url, Map headers, /// Uint8List? payload, Duration timeout, /// ) async { /// final request = http.Request(method, url)..headers.addAll(headers); /// if (payload != null) request.bodyBytes = payload; /// final response = await http.Response.fromStream( /// await request.send().timeout(timeout), /// ); /// return (status: response.statusCode, body: response.bodyBytes); /// } /// ``` typedef RokiTransport = Future<({int status, Uint8List body})> Function( String method, Uri url, Map headers, Uint8List? payload, Duration timeout, ); /// Every failure this client raises. /// /// Implements [Exception] rather than extending [Error]: a declined card, an /// expired link and a dropped connection are conditions a payment flow is /// expected to handle, not bugs in the calling program. class RokiConnectException implements Exception { RokiConnectException(this.message, {this.statusCode = 0, this.response}); /// What went wrong, already phrased for a human reading a log. final String message; /// HTTP status, or `0` when the request never got an answer. final int statusCode; /// The decoded error body, when the API sent one. final Map? response; /// Field-level validation errors, when the API sent them. /// /// The keys are stable and safe to branch on; the messages are localised by /// `Accept-Language` and are not. Map> get validationErrors { final errors = response?['errors']; if (errors is! Map) return const >{}; return >{ for (final entry in errors.entries) '${entry.key}': entry.value is List ? [for (final message in entry.value as List) '$message'] : ['${entry.value}'], }; } @override String toString() => 'RokiConnectException: $message'; } /// Field names from other gateways, mapped to what ROKI actually calls them. const Map rokiFieldAliases = { 'amount': 'In Stripe this is "amount"; in ROKI it is "amount". Same name, different unit: ROKI\'s amount is decimal units (1500.00 = L 1,500.00, minimum 0.01), so divide Stripe\'s integer by 100 before sending, and enforce your own ceiling because ROKI enforces no maximum and would silently create a...', 'amount_cents': 'In Stripe this is "amount_cents"; in ROKI it is "amount". ROKI has no cents field anywhere: convert to decimal units and send amount; this is one of the few wrong names that fails loudly rather than silently, returning 422 \'El campo amount es obligatorio.\' because amount is required.', 'currency': 'In Stripe this is "currency"; in ROKI it is "currency_code". POST /payments wants the ISO 4217 *numeric* code as a string (\'340\' = HNL), not the alphabetic one; the alphabetic form (\'HNL\') is accepted only on /payments/token-charge, /payment-methods/{id}/charge and /confirm, and the response echoes...', 'payment_method_types': 'Stripe has "payment_method_types", ROKI has no equivalent. ROKI is card-only and the integration mode is chosen by which endpoint you call - POST /payments for hosted checkout, POST /confirm for embedded card fields, POST /payment-methods/{id}/charge for a saved card - so drop the field entirely,...', 'success_url': 'In Stripe this is "success_url"; in ROKI it is "success_url". Identical name on POST /payments, but the embedded-components endpoint POST /confirm calls it success_redirect_url; only http/https is accepted (custom app schemes like myapp:// return 422), and a customer landing there is never proof of...', 'cancel_url': 'In Stripe this is "cancel_url"; in ROKI it is "cancel_url". Same name on POST /payments; the embedded-components equivalent is failed_redirect_url on POST /confirm, which means \'the charge failed\', not \'the customer backed out\', so the two are not interchangeable.', 'customer_email': 'In Stripe this is "customer_email"; in ROKI it is "customer.email". In ROKI it lives inside the optional customer prefill object (customer.email, alongside name, phone, identity_number), it creates no customer record, it stays editable unless you also send lock_customer_fields: true, and it is echoed back...', 'metadata': 'In Stripe and Mercado Pago this is "metadata"; in ROKI it is "metadata". Same name and same purpose - returned untouched on retrieval and in every webhook, never shown to the customer - but it must be a JSON object or you get 422 (\'El campo metadata debe ser un array.\'), and when you send none ROKI may return...', 'client_reference_id': 'In Stripe this is "client_reference_id"; in ROKI it is "external_reference". ROKI\'s external_reference is required (max 191) and, despite the name, is NOT unique - two creates with the same value produce two separately payable links - so duplicate protection has to come from Idempotency-Key; you can filter by it...', 'capture_method': 'Stripe has "capture_method", ROKI has no equivalent. ROKI has no authorize/capture split and no capture endpoint - every approved charge is captured immediately - so express reversal instead: try POST /payments/{transaction_id}/void (full amount only, before settlement) and fall back to POST...', 'receipt_email': 'Stripe has "receipt_email", ROKI has no equivalent. ROKI never emails a receipt from the API: fetch GET /payments/{transaction_id}/receipt for the public receipt_url (or /receipt/download for the PDF) and deliver it yourself - and be aware customer.email only prefills the checkout form, it...', 'setup_future_usage': 'Stripe has "setup_future_usage", ROKI has no equivalent. You cannot request card saving from the ROKI API at all - the customer must tick \'save my card\' on ROKI\'s own checkout, after which the payment_method.saved webhook delivers an opaque pm_* that you charge later via POST...', 'payment_intent': 'In Stripe this is "payment_intent"; in ROKI it is "transaction_id". ROKI splits this into two handles: the numeric id for GET /payments/{id}, and the UUID transaction_id - null until the payment is actually charged - which is what void, refund and receipts require; passing the numeric id to those routes...', 'customer': 'Stripe has "customer", ROKI has no equivalent. ROKI has no Customer resource and no customer ids, so there is nothing to pass here - identify people by the data itself: send the inline customer prefill object (name, email, phone, identity_number) on POST /payments, and look up saved...', 'description': 'In Stripe and Mercado Pago this is "description"; in ROKI it is "description". Same name but capped at 120 characters in ROKI, and it is not the label the customer sees at checkout - that is the required name field (max 191), which Stripe has no equivalent for, so a straight port leaves your checkout page unlabelled.', 'statement_descriptor': 'Stripe and Mercado Pago have "statement_descriptor", ROKI has no equivalent. ROKI exposes no per-payment statement descriptor - it comes from the merchant\'s terminal configuration - so remove the field rather than sending it, because it would be silently ignored with a 201; name and description only affect what the...', 'line_items': 'Stripe has "line_items", ROKI has no equivalent. ROKI takes one pre-computed total: sum the cart in your own backend and send amount in decimal units plus a single name label, keep the per-line breakdown in metadata for reconciliation, and use sales_tax_type/sales_tax_value when you need...', 'mode': 'Stripe has "mode", ROKI has no equivalent. ROKI has no mode parameter and no subscription engine - POST /payments is always a one-time hosted checkout - so drive recurring billing from your own scheduler by calling POST /payments/token-charge with a saved payment_token each cycle,...', 'url': 'In Stripe this is "url"; in ROKI it is "checkout_url". ROKI returns checkout_url on the 201 from POST /payments - redirect to the exact string returned and never hardcode, allow-list or pattern-match the domain or slug, because the live value (aura.roki.systems/pay/link/{12-char lowercase...', 'amount_total': 'In Stripe this is "amount_total"; in ROKI it is "total". ROKI\'s total is authoritative and in decimal units (subtotal + sales_tax_amount + service_fee_amount) - read it back from the response instead of recomputing it, and remember a customer-selected tip is chosen at checkout, so the amount...', 'expires_at': 'In Stripe this is "expires_at"; in ROKI it is "expires_at". Same name, completely different format: ROKI wants a string like \'2026-08-13 18:00:00\' (or ISO 8601) in Honduras local time UTC-6 with no offset marker and it must be in the future or you get 422 - passing a Unix timestamp, or parsing the...', 'payment_method': 'In Stripe this is "payment_method"; in ROKI it is "payment_token". ROKI\'s saved cards carry the same pm_ prefix, which makes this the most convincing wrong guess in the migration: the field is payment_token in the body of POST /payments/token-charge (or the {payment_method_id} path segment of POST...', 'application_fee_amount': 'Stripe has "application_fee_amount", ROKI has no equivalent. ROKI has no platform or marketplace fee field; its only fee flag is the boolean service_fee_enabled, which means something different - it passes ROKI\'s own processing costs to the customer by reverse calculation so the merchant nets amount...', 'locale': 'In Stripe this is "locale"; in ROKI it is "Accept-Language". ROKI has no body field for this - send the Accept-Language: es|en header instead (Spanish is the default for a missing, empty or unsupported value) - but note its narrower reach: it localizes API and validation messages only, routing...', 'items': 'Mercado Pago has "items", ROKI has no equivalent. ROKI has no line items at all - sum the cart in your own backend and send one amount, and if you need the breakdown preserved put it in metadata, which ROKI returns untouched on retrieval and in every webhook.', 'unit_price': 'Mercado Pago has "unit_price", ROKI has no equivalent. Nothing per-unit exists in ROKI - multiply by quantity, sum every item, and send the result as amount (decimal units, minimum 0.01, no maximum enforced by the API, so cap it on your side).', 'quantity': 'Mercado Pago has "quantity", ROKI has no equivalent. Fold quantity into the total before calling ROKI, and never leave the key in the body as a hopeful extra: ROKI silently ignores unknown fields and still returns 201, so the mistake is invisible.', 'transaction_amount': 'In Mercado Pago this is "transaction_amount"; in ROKI it is "amount". The closest 1:1 match in the whole migration - also decimal units and not cents (150.50 = L 150.50), also required, minimum 0.01 - but ROKI may add sales tax, tip and service fee on top, so the authoritative charged figure is total in the...', 'payer': 'In Mercado Pago this is "payer"; in ROKI it is "customer". ROKI customer accepts only name, email, phone and identity_number (no surname, no address), and it is only a PREFILL of the hosted checkout that the customer can still edit unless you also send lock_customer_fields: true.', 'external_reference': 'In Mercado Pago this is "external_reference"; in ROKI it is "external_reference". Identical name but REQUIRED in ROKI (max 191) and explicitly NOT unique - sending it twice without an Idempotency-Key creates two separate payable payments - and it is filterable via GET /payments?external_reference=.', 'notification_url': 'Mercado Pago has "notification_url", ROKI has no equivalent. ROKI webhooks are not per payment - register the endpoint URL and copy its signing secret once per environment at https://aura.roki.systems/merchant/connect/webhooks, then verify the ROKI-Signature HMAC-SHA256 over timestamp + "." +...', 'back_urls': 'Mercado Pago has "back_urls", ROKI has no equivalent. There is no nested object in ROKI - the two flat top-level fields success_url and cancel_url replace it, and both accept only http/https (a custom app scheme like myapp://done returns 422, so mobile return trips need Universal Links / App...', 'auto_return': 'Mercado Pago has "auto_return", ROKI has no equivalent. There is no switch to set - the ROKI hosted checkout always offers the return to success_url - and as in Mercado Pago the redirect is never proof of a charge, so treat it purely as UX and confirm server-side.', 'init_point': 'In Mercado Pago this is "init_point"; in ROKI it is "checkout_url". The clean equivalent and the heart of the migration - ROKI returns it straight from POST /payments with no preference step, there is exactly one URL for both environments, and the slug is a bare 12-character lowercase string with no plink_...', 'sandbox_init_point': 'Mercado Pago has "sandbox_init_point", ROKI has no equivalent. ROKI has no second URL - the environment is decided solely by the API key prefix (sk_test_ = sandbox, sk_live_ = production) over identical routes, so use checkout_url in both and just swap the key.', 'preference_id': 'In Mercado Pago this is "preference_id"; in ROKI it is "id". ROKI has no preference object - POST /payments creates the payment itself - and the returned numeric id serves only GET /payments/{id}, so persist it, because the only other way back to a payment is paging GET /payments.', 'payment_method_id': 'Mercado Pago has "payment_method_id", ROKI has no equivalent. The most dangerous false friend in this table - ROKI does have a payment_method_id, but it is a URL PATH PARAMETER carrying an opaque saved-card reference (pm_7k2n9xqf31ab), not a brand selector, and ROKI Connect offers no method selection...', 'token': 'In Mercado Pago this is "token"; in ROKI it is "payment_token". Same architecture, but one ROKI name covers two non-interchangeable values: mode 2 sends the iframe SDK\'s tok_* to POST https://aura.roki.systems/api/connect/embed/confirm alongside publishable_key (note the base is /api/connect/embed, not...', 'installments': 'Mercado Pago has "installments", ROKI has no equivalent. ROKI Connect exposes no installment plans whatsoever and there is no workaround - and because unknown fields are silently dropped, leaving installments in the body returns 201 while the customer is charged in full, so strip it out...', 'currency_id': 'In Mercado Pago this is "currency_id"; in ROKI it is "currency_code". Top-level rather than per-item, and inconsistent inside ROKI itself: POST /payments requires the NUMERIC ISO 4217 code ("340" = HNL) while POST /payments/token-charge and the embed confirm accept the alphabetic "HNL" - and only currencies...', 'expires': 'Mercado Pago has "expires", ROKI has no equivalent. ROKI has no on/off switch - simply send expires_at to set an expiry, or omit it entirely for a link with no expiry.', 'expiration_date_to': 'In Mercado Pago this is "expiration_date_to"; in ROKI it is "expires_at". Must be in the future and is interpreted in Honduras time (UTC-6) with NO offset in the string in either direction, so parsing expires_at, created_at or paid_at as UTC shifts every value by six hours - enough to make a live link look...', 'capture': 'Mercado Pago has "capture", ROKI has no equivalent. ROKI has no auth/capture split - every approved charge captures - so the reversal path replaces it: try POST /payments/{transaction_id}/void (full amount only, pre-settlement) and fall back to POST /payments/{transaction_id}/refund (full...', 'binary_mode': 'Mercado Pago has "binary_mode", ROKI has no equivalent. Not configurable in ROKI - a payment is pending until it is charged, and in mode 2 a 3-D Secure challenge returns status: "pending" with an authentication_url the customer must complete, the final outcome arriving by webhook.', 'status_detail': 'Mercado Pago has "status_detail", ROKI has no equivalent. ROKI returns only status (pending, paid, partially_refunded, refunded, voided, expired, disabled); decline detail arrives in a different shape entirely, as processor-passthrough IsoResponseCode plus a capitalized Errors[] array of {Code,...', }; /// A ROKI Connect API client. /// /// One instance per process is the intended shape: it holds an [HttpClient] /// that pools connections, so building one per request throws away every TLS /// handshake. Call [close] when you are done - an idle keep-alive connection /// keeps the Dart VM alive, which is why a script that forgets it appears to /// hang after `main` returns. class RokiConnect { RokiConnect( String secretKey, { String baseUrl = rokiDefaultBaseUrl, this.timeout = const Duration(seconds: 30), this.strict = true, this.language = 'en', RokiWarningHandler? onWarnings, RokiTransport? transport, }) : _secretKey = secretKey, baseUrl = _withoutTrailingSlash(baseUrl), _onWarnings = onWarnings ?? _writeWarningsToStderr { if (secretKey.isEmpty) { throw RokiConnectException( 'Missing secret key. Read it from the environment, never hard-code it.', ); } if (secretKey.startsWith('pk_')) { throw RokiConnectException( 'That is a publishable key. Server calls need the sk_ secret key.', ); } _transport = transport ?? _defaultTransport; } final String _secretKey; /// Root of the API, without a trailing slash. final String baseUrl; /// Applies to connecting, to the response headers and to reading the body. final Duration timeout; /// Reject unknown fields before the request leaves. /// /// Leave this on. The API answers 201 to a misspelled field and ignores it, /// so a typo becomes a payment created without the feature you asked for - /// silent in production instead of loud in development. final bool strict; /// `es` or `en`, sent as `Accept-Language`. Localises API messages only. final String language; final RokiWarningHandler _onWarnings; late final RokiTransport _transport; HttpClient? _httpClient; /// True when this client is talking to the sandbox. bool get isSandbox => _secretKey.startsWith('sk_test_'); /// Which merchant does this key belong to Future> getMerchant() async { return _requestJson( method: 'GET', path: '/me', ); } /// List payments Future> listPayments({ int? perPage, int? page, String? status, String? externalReference, String? from, String? to, }) async { return _requestJson( method: 'GET', path: '/payments', query: { 'per_page': perPage, 'page': page, 'status': status, 'external_reference': externalReference, 'from': from, 'to': to, }, ); } /// Create a payment /// /// Amounts are decimal units, never cents: `amount: 150.00` is L 150.00. /// There is no cents field anywhere in this API, so a Stripe integer of /// `15000` charges a hundred times too much - and dividing by 100 out of the /// same reflex charges a hundredth. Send the figure a human would read off /// the invoice, and take the response as authoritative for what moved. /// /// Pass the same [idempotencyKey] when you retry and a timeout cannot turn /// into two payments. Omitted, the client mints a fresh one per call, which /// protects nothing on a retry - derive it from the order's contents and /// store it next to the order. Future> createPayment( Map body, { String? idempotencyKey, }) async { _guard( body, const [ 'amount', 'external_reference', 'name', 'currency_code', 'description', 'metadata', 'success_url', 'cancel_url', 'expires_at', 'customer', 'lock_customer_fields', 'reusable', 'sales_tax_type', 'sales_tax_value', 'tip_enabled', 'tip_type', 'tip_value', 'tip_customer_selectable', 'tip_preset_percentages', 'tip_allow_custom', 'tip_min_amount', 'tip_max_amount', 'service_fee_enabled' ], const ['amount', 'external_reference', 'name'], 'createPayment', ); return _requestJson( method: 'POST', path: '/payments', body: body, idempotencyKey: idempotencyKey ?? newIdempotencyKey(), ); } /// Retrieve a payment /// /// [id] is the numeric payment `id` returned at creation, not the /// `transaction_id` UUID that void, refund and receipts take. Future> getPayment(int id) async { return _requestJson( method: 'GET', path: '/payments/${Uri.encodeComponent(id.toString())}', ); } /// Void a transaction /// /// [transactionId] is the `transaction_id` UUID from the payment - `null` /// until the payment is actually charged - and not the numeric payment `id`, /// which answers 404 here. The signature enforces it: the UUID is a String /// and the payment id an int, so confusing the two is a compile error instead /// of a 404 in production. Future> voidTransaction( String transactionId, { Map body = const {}, }) async { _guard(body, const ['reason'], const [], 'voidTransaction'); return _requestJson( method: 'POST', path: '/payments/${Uri.encodeComponent(transactionId)}/void', body: body, ); } /// Refund a transaction /// /// Amounts are decimal units, never cents: `amount: 150.00` is L 150.00. /// There is no cents field anywhere in this API, so a Stripe integer of /// `15000` charges a hundred times too much - and dividing by 100 out of the /// same reflex charges a hundredth. Send the figure a human would read off /// the invoice, and take the response as authoritative for what moved. /// /// [transactionId] is the `transaction_id` UUID from the payment - `null` /// until the payment is actually charged - and not the numeric payment `id`, /// which answers 404 here. The signature enforces it: the UUID is a String /// and the payment id an int, so confusing the two is a compile error instead /// of a 404 in production. Future> refundTransaction( String transactionId, Map body, ) async { _guard( body, const ['amount', 'reason'], const ['amount'], 'refundTransaction', ); return _requestJson( method: 'POST', path: '/payments/${Uri.encodeComponent(transactionId)}/refund', body: body, ); } /// Get receipt metadata /// /// [transactionId] is the `transaction_id` UUID from the payment - `null` /// until the payment is actually charged - and not the numeric payment `id`, /// which answers 404 here. The signature enforces it: the UUID is a String /// and the payment id an int, so confusing the two is a compile error instead /// of a 404 in production. Future> getReceipt(String transactionId) async { return _requestJson( method: 'GET', path: '/payments/${Uri.encodeComponent(transactionId)}/receipt', ); } /// Download the receipt PDF /// /// [transactionId] is the `transaction_id` UUID from the payment - `null` /// until the payment is actually charged - and not the numeric payment `id`, /// which answers 404 here. The signature enforces it: the UUID is a String /// and the payment id an int, so confusing the two is a compile error instead /// of a 404 in production. /// /// Returns the application/pdf bytes themselves, not JSON - this is the only /// route in the API that answers with something other than JSON. Write them /// to a File, or hand them straight to a PDF widget. The human-readable /// receipt page is `receipt_url` from the metadata call. Future downloadReceipt(String transactionId) async { final response = await _request( method: 'GET', path: '/payments/${Uri.encodeComponent(transactionId)}/receipt/download', ); return response.body; } /// List a customer's saved cards Future> listPaymentMethods({ String? customerIdentityNumber, String? customerEmail, }) async { return _requestJson( method: 'GET', path: '/payment-methods', query: { 'customer[identity_number]': customerIdentityNumber, 'customer[email]': customerEmail, }, ); } /// Revoke a saved card Future> revokePaymentMethod( String paymentMethodId, ) async { return _requestJson( method: 'DELETE', path: '/payment-methods/${Uri.encodeComponent(paymentMethodId)}', ); } /// Charge a saved card /// /// Amounts are decimal units, never cents: `amount: 150.00` is L 150.00. /// There is no cents field anywhere in this API, so a Stripe integer of /// `15000` charges a hundred times too much - and dividing by 100 out of the /// same reflex charges a hundredth. Send the figure a human would read off /// the invoice, and take the response as authoritative for what moved. /// /// The API requires `Idempotency-Key` here and answers 422 without it, so /// omitting [idempotencyKey] makes the client mint one. That is right for a /// first attempt and wrong for a retry: hold on to the key you used and pass /// the same one again, otherwise the retry is a second charge. Future> chargeSavedCard( String paymentMethodId, Map body, { String? idempotencyKey, }) async { _guard( body, const ['amount', 'currency_code', 'external_reference', 'metadata'], const ['amount'], 'chargeSavedCard', ); return _requestJson( method: 'POST', path: '/payment-methods/${Uri.encodeComponent(paymentMethodId)}/charge', body: body, idempotencyKey: idempotencyKey ?? newIdempotencyKey(), ); } /// Charge a saved card (alias) /// /// Amounts are decimal units, never cents: `amount: 150.00` is L 150.00. /// There is no cents field anywhere in this API, so a Stripe integer of /// `15000` charges a hundred times too much - and dividing by 100 out of the /// same reflex charges a hundredth. Send the figure a human would read off /// the invoice, and take the response as authoritative for what moved. /// /// The API requires `Idempotency-Key` here and answers 422 without it, so /// omitting [idempotencyKey] makes the client mint one. That is right for a /// first attempt and wrong for a retry: hold on to the key you used and pass /// the same one again, otherwise the retry is a second charge. Future> tokenCharge( Map body, { String? idempotencyKey, }) async { _guard( body, const [ 'payment_token', 'amount', 'currency_code', 'external_reference', 'metadata' ], const ['payment_token', 'amount'], 'tokenCharge', ); return _requestJson( method: 'POST', path: '/payments/token-charge', body: body, idempotencyKey: idempotencyKey ?? newIdempotencyKey(), ); } /// Confirm an embedded-components payment /// /// Amounts are decimal units, never cents: `amount: 150.00` is L 150.00. /// There is no cents field anywhere in this API, so a Stripe integer of /// `15000` charges a hundred times too much - and dividing by 100 out of the /// same reflex charges a hundredth. Send the figure a human would read off /// the invoice, and take the response as authoritative for what moved. /// /// This one endpoint does not live on the main API base. It is sent to /// https://aura.roki.systems/api/connect/embed, which the client handles for /// you - do not point [baseUrl] at it. Future> confirmEmbeddedPayment( Map body, ) async { _guard( body, const [ 'amount', 'currency_code', 'payment_token', 'publishable_key', 'external_reference', 'success_redirect_url', 'failed_redirect_url', 'description', 'metadata' ], const ['amount', 'currency_code', 'payment_token', 'publishable_key'], 'confirmEmbeddedPayment', ); return _requestJson( method: 'POST', path: '/confirm', body: body, baseOverride: 'https://aura.roki.systems/api/connect/embed', ); } /// Verify a webhook signature. /// /// [rawBody] is the exact bytes that arrived. Re-encoding the parsed JSON /// changes key order, spacing and number formatting, and the HMAC will never /// match again - so read the body once, verify it, and only then decode it: /// /// ```dart /// final raw = await request.fold>([], (a, b) => a..addAll(b)); /// if (!RokiConnect.verifyWebhook( /// rawBody: raw, /// signatureHeader: request.headers.value('ROKI-Signature') ?? '', /// signingSecret: secret, /// )) { /// // Answer 400 and stop. Never trust the payload. /// } /// final event = jsonDecode(utf8.decode(raw)) as Map; /// ``` /// /// Every argument is named and required on purpose. The equivalent in most /// SDKs is positional, where swapping the header for the secret compiles, /// runs, and quietly rejects every genuine event forever. /// /// Header: `ROKI-Signature: t={timestamp},v1={hex}` static bool verifyWebhook({ required List rawBody, required String signatureHeader, required String signingSecret, Duration tolerance = const Duration(seconds: 300), DateTime? now, }) { final match = _signaturePattern.firstMatch(signatureHeader); if (match == null) return false; final timestamp = match.group(1)!; final received = match.group(2)!.toLowerCase(); if (tolerance > Duration.zero) { final signedAt = int.tryParse(timestamp); if (signedAt == null) return false; final seconds = (now ?? DateTime.now()).millisecondsSinceEpoch ~/ 1000; if ((seconds - signedAt).abs() > tolerance.inSeconds) { return false; // replay of an old, genuinely signed event } } final signed = [...utf8.encode('$timestamp.'), ...rawBody]; final expected = _hex(_hmacSha256(utf8.encode(signingSecret), signed)); // Constant time, not `==`: Dart's string equality returns as soon as two // bytes differ, and how long that took tells an attacker how much of a // forged signature was right - enough, over many tries, to build a valid // one byte by byte. Dart ships no constant-time compare, so here it is. return _constantTimeEquals(utf8.encode(expected), utf8.encode(received)); } /// A key stable enough that a retry is the same payment, unique enough that /// two are not. /// /// Good for a first attempt. A retry has to reuse the key of the attempt it /// is retrying, so store it with the order rather than calling this again. String newIdempotencyKey() { final random = Random.secure(); return List.generate( 16, (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), ).join(); } /// Releases the pooled connections. /// /// Requests already in flight finish unless [force] is set. void close({bool force = false}) { _httpClient?.close(force: force); _httpClient = null; } // ---------------------------------------------------------------- internals void _guard( Map body, List known, List required, String operation, ) { final missing = required.where((field) => !body.containsKey(field)).toList(); if (missing.isNotEmpty) { throw RokiConnectException( '$operation: missing required field(s): ${missing.join(', ')}', ); } if (!strict || known.isEmpty) return; for (final field in body.keys) { if (known.contains(field)) continue; final message = StringBuffer( '$operation: "$field" is not a field of this API, and ROKI would ' 'accept the request and ignore it.', ); final alias = rokiFieldAliases[field]; final hint = _closest(field, known); if (alias != null) { message.write(' Coming from another gateway? $alias'); } else if (hint != null) { message.write(' Did you mean "$hint"?'); } throw RokiConnectException(message.toString()); } } Future> _requestJson({ required String method, required String path, Map? body, Map query = const {}, String? idempotencyKey, String? baseOverride, }) async { final response = await _request( method: method, path: path, body: body, query: query, idempotencyKey: idempotencyKey, baseOverride: baseOverride, ); final decoded = _decodeJson(response.body); if (decoded == null) { throw RokiConnectException( 'Non-JSON response from $method $path (HTTP ${response.status})', statusCode: response.status, ); } // The API names the fields it ignored. The key is absent altogether when // the request was clean, so a quiet log is the proof your field names are // right. The text is localised by Accept-Language: surface it, never // branch on it. final warnings = decoded['warnings']; if (warnings is List && warnings.isNotEmpty) { _onWarnings( [for (final warning in warnings) '$warning'], method, path, ); } return decoded; } Future<({int status, Uint8List body})> _request({ required String method, required String path, Map? body, Map query = const {}, String? idempotencyKey, String? baseOverride, }) async { final parameters = {}; query.forEach((key, value) { if (value == null) return; final text = value is String ? value : '$value'; if (text.isEmpty) return; parameters[key] = text; }); final root = baseOverride == null ? baseUrl : _withoutTrailingSlash(baseOverride); final url = Uri.parse('$root$path').replace( queryParameters: parameters.isEmpty ? null : parameters, ); final headers = { 'Authorization': 'Bearer $_secretKey', 'Accept': 'application/json', 'Accept-Language': language, 'User-Agent': 'roki-connect-dart/$rokiSdkVersion+build.$rokiSdkBuild', }; if (idempotencyKey != null) headers['Idempotency-Key'] = idempotencyKey; Uint8List? payload; if (body != null) { headers['Content-Type'] = 'application/json; charset=utf-8'; payload = Uint8List.fromList(utf8.encode(jsonEncode(body))); } late final ({int status, Uint8List body}) response; try { response = await _transport(method, url, headers, payload, timeout); } on RokiConnectException { rethrow; } catch (error) { // A timeout is not a failed payment: the payment may well exist. Retry // with the SAME Idempotency-Key, or look it up by external_reference // before creating another one. throw RokiConnectException( 'Network error calling $method $path: $error', ); } if (response.status >= 400) { throw _errorFor( response.status, _decodeJson(response.body), method, path, ); } return response; } /// The `dart:io` transport, kept on the instance so connections are pooled. Future<({int status, Uint8List body})> _defaultTransport( String method, Uri url, Map headers, Uint8List? payload, Duration timeout, ) async { final client = _httpClient ??= (HttpClient()..connectionTimeout = timeout); final request = await client.openUrl(method, url); for (final header in headers.entries) { request.headers.set(header.key, header.value); } if (payload != null) request.add(payload); final response = await request.close().timeout(timeout); final bytes = BytesBuilder(copy: false); await for (final chunk in response.timeout(timeout)) { bytes.add(chunk); } return (status: response.statusCode, body: bytes.takeBytes()); } } RokiConnectException _errorFor( int status, Map? payload, String method, String path, ) { final message = StringBuffer( payload?['message']?.toString() ?? payload?['error']?.toString() ?? 'request failed', ); final errors = payload?['errors']; if (status == 422 && errors is Map) { final detail = errors.entries.map((entry) { final value = entry.value; return '${entry.key}: ${value is List ? value.join(' ') : value}'; }); message.write(' (${detail.join('; ')})'); } if (status == 404) { message.write( '. A 404 here usually means the identifier is of the wrong kind: void, ' 'refund and receipts take the transaction_id UUID, not the numeric ' 'payment id.', ); } if (status == 401) { message.write( '. Check the key prefix: sk_test_ only works against sandbox data, ' 'sk_live_ only against production.', ); } return RokiConnectException( 'ROKI $method $path failed with $status: $message', statusCode: status, response: payload, ); } void _writeWarningsToStderr( List warnings, String method, String path, ) { stderr.writeln( 'ROKI $method $path ignored fields: ${warnings.join(' | ')}', ); } Map? _decodeJson(Uint8List bytes) { try { final decoded = jsonDecode(utf8.decode(bytes)); return decoded is Map ? decoded : null; } catch (_) { return null; } } String _withoutTrailingSlash(String value) => value.replaceFirst(RegExp(r'/+$'), ''); final RegExp _signaturePattern = RegExp( r't=(\d+)\s*,\s*v1=([a-f0-9]{64})', caseSensitive: false, ); /// The closest real field name. /// /// Edit distance alone is not enough here: `service_fee` is 8 edits away from /// `service_fee_enabled` and is by far the most common mistake against this /// API. A shared prefix outranks distance. String? _closest(String needle, List candidates) { for (final candidate in candidates) { if (candidate.startsWith(needle) || needle.startsWith(candidate)) { return candidate; } } String? best; var bestScore = 1 << 30; for (final candidate in candidates) { final distance = _levenshtein(needle, candidate); if (distance < bestScore) { bestScore = distance; best = candidate; } } return bestScore <= max(2, needle.length ~/ 3) ? best : null; } int _levenshtein(String a, String b) { final previous = List.generate(b.length + 1, (i) => i); for (var i = 1; i <= a.length; i++) { var last = previous[0]; previous[0] = i; for (var j = 1; j <= b.length; j++) { final same = a.codeUnitAt(i - 1) == b.codeUnitAt(j - 1); final replaced = last + (same ? 0 : 1); last = previous[j]; previous[j] = min(min(previous[j] + 1, previous[j - 1] + 1), replaced); } } return previous[b.length]; } bool _constantTimeEquals(List a, List b) { if (a.length != b.length) return false; var difference = 0; for (var i = 0; i < a.length; i++) { difference |= a[i] ^ b[i]; } return difference == 0; } // --------------------------------------------------------------------------- // HMAC-SHA256. // // Dart's core libraries have no hashing primitive at all, and a payments client // that drags a package in for one 90-line function is a client that eventually // collides with a version the merchant's app already pinned. package:crypto - // from the Dart team - computes exactly the same bytes if you would rather use // it: Hmac(sha256, utf8.encode(secret)).convert(signed).bytes. // // FIPS 180-4 and RFC 2104, verified against the RFC 4231 test vectors. // --------------------------------------------------------------------------- Uint8List _hmacSha256(List key, List message) { const blockSize = 64; var block = Uint8List(blockSize); final normalised = key.length > blockSize ? _sha256(key) : key; block.setRange(0, normalised.length, normalised); final inner = Uint8List(blockSize); final outer = Uint8List(blockSize); for (var i = 0; i < blockSize; i++) { inner[i] = block[i] ^ 0x36; outer[i] = block[i] ^ 0x5c; } block = Uint8List(0); final innerDigest = _sha256([...inner, ...message]); return _sha256([...outer, ...innerDigest]); } const List _sha256Constants = [ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 ]; int _rotateRight(int value, int bits) => ((value >> bits) | (value << (32 - bits))) & 0xffffffff; Uint8List _sha256(List message) { final state = Uint32List.fromList([ 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 ]); // Pad to a multiple of 64 bytes: 0x80, zeroes, then the length in bits as a // big-endian 64-bit integer. Written byte by byte rather than with // ByteData.setUint64, which is unsupported where ints are doubles. final bitLength = message.length * 8; final padded = BytesBuilder(copy: false) ..add(message) ..addByte(0x80); while (padded.length % 64 != 56) { padded.addByte(0); } for (var shift = 56; shift >= 0; shift -= 8) { padded.addByte((bitLength >> shift) & 0xff); } final data = padded.takeBytes(); final schedule = Uint32List(64); for (var offset = 0; offset < data.length; offset += 64) { for (var i = 0; i < 16; i++) { final at = offset + i * 4; schedule[i] = (data[at] << 24) | (data[at + 1] << 16) | (data[at + 2] << 8) | data[at + 3]; } for (var i = 16; i < 64; i++) { final w15 = schedule[i - 15]; final w2 = schedule[i - 2]; final s0 = _rotateRight(w15, 7) ^ _rotateRight(w15, 18) ^ (w15 >> 3); final s1 = _rotateRight(w2, 17) ^ _rotateRight(w2, 19) ^ (w2 >> 10); // Storing into a Uint32List is the mod 2^32 the algorithm asks for. schedule[i] = schedule[i - 16] + s0 + schedule[i - 7] + s1; } var a = state[0]; var b = state[1]; var c = state[2]; var d = state[3]; var e = state[4]; var f = state[5]; var g = state[6]; var h = state[7]; for (var i = 0; i < 64; i++) { final s1 = _rotateRight(e, 6) ^ _rotateRight(e, 11) ^ _rotateRight(e, 25); final ch = (e & f) ^ (~e & g); final temp1 = (h + s1 + ch + _sha256Constants[i] + schedule[i]) & 0xffffffff; final s0 = _rotateRight(a, 2) ^ _rotateRight(a, 13) ^ _rotateRight(a, 22); final maj = (a & b) ^ (a & c) ^ (b & c); final temp2 = (s0 + maj) & 0xffffffff; h = g; g = f; f = e; e = (d + temp1) & 0xffffffff; d = c; c = b; b = a; a = (temp1 + temp2) & 0xffffffff; } state[0] += a; state[1] += b; state[2] += c; state[3] += d; state[4] += e; state[5] += f; state[6] += g; state[7] += h; } final digest = Uint8List(32); for (var i = 0; i < 8; i++) { digest[i * 4] = (state[i] >> 24) & 0xff; digest[i * 4 + 1] = (state[i] >> 16) & 0xff; digest[i * 4 + 2] = (state[i] >> 8) & 0xff; digest[i * 4 + 3] = state[i] & 0xff; } return digest; } String _hex(List bytes) => bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();