Skip to content

fix: send exact decimal amounts to Apple Pay and Google Pay - #32

Merged
jkasprzyk17 merged 1 commit into
mainfrom
fix/decimal-amounts
Oct 20, 2026
Merged

jkasprzyk17 merged 1 commit into
mainfrom
fix/decimal-amounts

Conversation

@jkasprzyk17

@jkasprzyk17 jkasprzyk17 commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Closes #18

Summary

  • Exact amounts on both platforms: PaymentItem.amount is now number | string (PaymentAmount, a Nitro variant). iOS parses amounts with NSDecimalNumber(string:locale:) instead of Decimal(Double), so 19.99 reaches PassKit as 19.99, not 19.98999999999999488. Android parses and sums them as BigDecimal (new PaymentAmounts.kt). Numbers are read through their shortest round-trip form on both platforms.
  • Validation in JS before native: new normalizePaymentRequest (also used by createPaymentRequest and usePaymentCheckout().startPayment()) rejects NaN/Infinity, malformed strings, more decimals than the currency allows (static ISO 4217 table: JPY 0, KWD 3, default 2), and a negative total. It then sends every amount as a decimal string scaled to the currency ('19.90', '1500'). Negative line items (discounts) stay allowed. The hook sets error and returns null without calling native code, the same way it handles an empty cart.
  • Same total in JS and native: calculateTotal (and the hook's total) sum exactly with BigInt and still return a number, so total.toFixed(2) keeps working. formatAmount(amount, currencyCode?) accepts strings and rounds half away from zero.
  • Native defense in depth: iOS returns an error result for an unparseable amount or a negative total; before, a missing merchant ID was the only failure build reported. Android rejects a negative totalPrice and a third decimal place instead of rounding, and sends whole-number strings (what normalizePaymentRequest produces for JPY) as "1500", not "1500.00". On iOS the check runs before any payment state is stored; on Android the error goes through the existing catch, which releases the in-progress slot, so a rejected request does not block the next one.
  • Docs: types.md (new PaymentAmount section), utils.md, use-payment-checkout.md, hybrid-payment-handler.md, manual-flow.md, dynamic-cart.md, quick-start.md, README, CHANGELOG, example app.

Test plan

  • cd package && bun run typecheck: passes
  • cd package && bunx eslint "src/**/*.{ts,tsx}" --max-warnings 0: passes
  • cd package && bun run test:ci: 89/89 pass. New: amounts.test.ts (validator cases, including NaN, -1, 1.005 USD, 0.1 + 0.2, JPY decimals, KWD 3 decimals), exact calculateTotal, formatAmount with strings and currencies, and hook tests asserting that invalid amounts never reach startPayment, that 19.99 and '19.99' both cross as '19.99' (with and without onAuthorize), and that a rejected cart does not block the next payment.
  • JUnit: 26/26 pass (./gradlew :gmisoftware_react-native-pay:testDebugUnitTest from example/android). New PaymentAmountsTest (4: number and string parsing, exact sums) and 5 new cases in GooglePayRequestBuilderTest (exact totalPrice, JPY formatting, a negative discount line, rejection of a negative total and of a third decimal place); its existing cases now build items with PaymentAmount.create(...).
  • Swift conversion: checked with a scratch swift script against a copy of the PaymentAmount enum. No XCTest target exists yet (Add native unit tests and native build jobs to CI #22). 19.99 → 19.99 (old: 19.98999999999999488), 4.99 + "0.50" → 5.49 (old: 5.490000000000001024); NaN, "12abc", " 1.00", "1,00", "1e3" → rejected.
  • iOS: xcodebuild -workspace example.xcworkspace -scheme example -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO build (after pod install): succeeds, as does the NitroPay scheme alone, with no warnings in the changed Swift files. The app was not launched (expo run:ios not run).
  • cd example && bunx expo run:android: not run. The Gradle unit test task compiles the module's Kotlin, but not the C++/JNI glue (the local CMake configure step fails for environment reasons unrelated to this change).
  • bun run docs:build: passes, no broken links or anchors
  • Manual: decrypt a sandbox Apple Pay token server-side and compare transactionAmount: not run, needs a merchant certificate

Risk

  • TypeScript break: PaymentItem.amount is now number | string, so consumer code that reads item.amount as a number (item.amount.toFixed(2)) no longer compiles. Use formatAmount(item.amount). The example and docs are updated.
  • Behavior change: carts that start payments with float arithmetic results (1.1 * 3 = 3.3000000000000003) or with amounts more precise than the currency now fail with an error instead of being rounded silently. createPaymentRequest now throws for invalid amounts and returns string amounts.
  • Direct HybridPaymentHandler.startPayment calls are not validated in JS unless the caller uses normalizePaymentRequest/createPaymentRequest. Native still rejects malformed amounts and negative totals, but on iOS a 1.005 USD amount passes through. getPaymentHandler() (fix: guard concurrent startPayment calls on Android and in the hook #29) returns the raw hybrid object, so validating direct callers automatically needs a JS wrapper; left as a follow-up.
  • Spec change: regenerated Nitro bindings (Variant PaymentAmount), so a native rebuild (pod install / Gradle sync) is needed.
  • Android C++ not compiled locally: the generated JNI glue (JPaymentAmount.cpp/.hpp, JPaymentItem.hpp, NitroPay+autolinking.cmake) is unmodified Nitrogen output but was not built here. The shared C++ PaymentItem.hpp variant is compiled by the iOS build.

Notes

  • No deprecation warning for number amounts (unlike the issue's suggestion): numbers are exact for any literal amount, and the precision check catches float arithmetic noise, so a warning would fire on every docs example without improving correctness.
  • Negative line items are allowed (Google allows a negative DisplayItem.price, and PassKit allows negative summary items); only a negative total is rejected. A single -1 item is therefore still rejected.
  • Errors are plain Errors with descriptive messages. Typed configuration errors are Typed payment status and error codes; stop treating cancellation as an error #15.
  • Google Pay accepts at most two decimal places, so three-decimal currencies fail on Android when the third decimal is not zero.
  • Integer minor units (amountMinor) are not added; decimal strings cover server-provided amounts. Possible follow-up.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

- `PaymentItem.amount` is now `PaymentAmount` (`number | string`). iOS
  parses amounts with `NSDecimalNumber(string:)` instead of
  `Decimal(Double)`, so 19.99 reaches PassKit as 19.99, not
  19.98999999999999488. Android parses and sums them as `BigDecimal`.
  Numbers are read through their shortest round-trip form.
- New `normalizePaymentRequest`, used by `createPaymentRequest` and the
  hook's `startPayment`, rejects NaN, malformed strings, more decimals
  than the currency allows (ISO 4217) and a negative total before any
  native call, then sends amounts as decimal strings. Negative line
  items (discounts) stay allowed.
- `calculateTotal`, the hook's `total` and `formatAmount` use exact
  decimal arithmetic; `formatAmount` accepts strings and a currency.
- Native code rejects unparseable amounts and a negative total; Android
  also rejects a third decimal place instead of rounding.
- Docs, README, CHANGELOG and the example app use `formatAmount`.

Closes #18
@coderabbitai

coderabbitai Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 8a1c38b0-ec2b-4211-bcf8-bf5c878d5090
📥 Commits

Reviewing files that changed from the base of the PR and between 93f1ade and f9333ca.

⛔ Files ignored due to path filters: 14
  • package/nitrogen/generated/android/NitroPay+autolinking.cmake is excluded by !**/generated/**
  • package/nitrogen/generated/android/c++/JHybridPaymentHandlerSpec.cpp is excluded by !**/generated/**
  • package/nitrogen/generated/android/c++/JPaymentAmount.cpp is excluded by !**/generated/**
  • package/nitrogen/generated/android/c++/JPaymentAmount.hpp is excluded by !**/generated/**
  • package/nitrogen/generated/android/c++/JPaymentItem.hpp is excluded by !**/generated/**
  • package/nitrogen/generated/android/c++/JPaymentRequest.hpp is excluded by !**/generated/**
  • package/nitrogen/generated/android/kotlin/com/margelo/nitro/pay/PaymentAmount.kt is excluded by !**/generated/**
  • package/nitrogen/generated/android/kotlin/com/margelo/nitro/pay/PaymentItem.kt is excluded by !**/generated/**
  • package/nitrogen/generated/ios/NitroPay-Swift-Cxx-Bridge.hpp is excluded by !**/generated/**
  • package/nitrogen/generated/ios/NitroPay-Swift-Cxx-Umbrella.hpp is excluded by !**/generated/**
  • package/nitrogen/generated/ios/c++/HybridPaymentHandlerSpecSwift.hpp is excluded by !**/generated/**
  • package/nitrogen/generated/ios/swift/PaymentAmount.swift is excluded by !**/generated/**
  • package/nitrogen/generated/ios/swift/PaymentItem.swift is excluded by !**/generated/**
  • package/nitrogen/generated/shared/c++/PaymentItem.hpp is excluded by !**/generated/**
📒 Files selected for processing: 24
  • CHANGELOG.md
  • README.md
  • docs/docs/api/hybrid-payment-handler.md
  • docs/docs/api/types.md
  • docs/docs/api/use-payment-checkout.md
  • docs/docs/api/utils.md
  • docs/docs/guides/dynamic-cart.md
  • docs/docs/guides/manual-flow.md
  • docs/docs/quick-start.md
  • example/app/(tabs)/index.tsx
  • package/android/src/main/java/com/margelo/nitro/pay/GooglePayRequestBuilder.kt
  • package/android/src/main/java/com/margelo/nitro/pay/PaymentAmounts.kt
  • package/android/src/test/java/com/margelo/nitro/pay/GooglePayRequestBuilderTest.kt
  • package/android/src/test/java/com/margelo/nitro/pay/PaymentAmountsTest.kt
  • package/ios/HybridPaymentHandler.swift
  • package/ios/PaymentAmount+Decimal.swift
  • package/src/hooks/__tests__/usePaymentCheckout.integration.test.ts
  • package/src/hooks/usePaymentCheckout.ts
  • package/src/types/Payment.ts
  • package/src/utils/__tests__/amounts.test.ts
  • package/src/utils/__tests__/paymentHelpers.test.ts
  • package/src/utils/amounts.ts
  • package/src/utils/index.ts
  • package/src/utils/paymentHelpers.ts

Included review availability: This review used your included allowance. 2 included reviews remain after this review. Your included PR review attempts over the past 7 days set your current allowance at 5 reviews per hour.

📝 Summary

Summary by CodeRabbit

  • New Features

    • Payment amounts now accept numbers or decimal strings for more precise monetary handling.
    • Added currency-aware formatting and payment-request normalization.
    • Cart and payment APIs now return fixed-precision decimal amounts.
  • Bug Fixes

    • Prevented floating-point rounding errors in payment totals.
    • Invalid, non-finite, over-precision, and negative-total amounts are rejected before payment begins.
    • Apple Pay and Google Pay totals now use exact arithmetic, with platform precision rules enforced.
  • Documentation

    • Updated guides and examples to describe decimal amounts, validation, formatting, discounts, and normalization.

The payment API now accepts numeric and decimal-string amounts. JavaScript utilities, checkout, and native payment builders validate and process amounts using exact decimal representations. Documentation, examples, and tests describe and cover the updated behavior.

Changes: Payment amount handling

Layer / File(s) Summary
Define and normalize payment amounts
package/src/types/Payment.ts, package/src/utils/amounts.ts, package/src/utils/paymentHelpers.ts, package/src/utils/index.ts, package/src/utils/__tests__/*, README.md, docs/docs/api/types.md, docs/docs/api/utils.md, CHANGELOG.md
The public amount type accepts numbers and decimal strings. Utilities normalize amounts to currency precision, calculate exact decimal totals, and validate payment and shipping amounts. Request normalization converts amounts to decimal strings and rejects negative totals.
Normalize checkout requests before payment
package/src/hooks/usePaymentCheckout.ts, package/src/hooks/__tests__/usePaymentCheckout.integration.test.ts, README.md, docs/docs/api/use-payment-checkout.md, docs/docs/api/hybrid-payment-handler.md, docs/docs/guides/*, docs/docs/quick-start.md, example/app/(tabs)/index.tsx
The checkout hook accepts both amount forms and normalizes requests before calling payment handlers. Validation failures return null without starting payment. Tests and examples cover validation, retries, and amount formatting.
Parse and validate native payment amounts
package/ios/HybridPaymentHandler.swift, package/ios/PaymentAmount+Decimal.swift, package/android/src/main/java/com/margelo/nitro/pay/PaymentAmounts.kt, package/android/src/main/java/com/margelo/nitro/pay/GooglePayRequestBuilder.kt, package/android/src/test/java/com/margelo/nitro/pay/*
The native builders parse and sum decimal amounts without floating-point arithmetic. They reject negative totals. Android also rejects amounts that require rounding beyond two decimal places.

Priority: ➖ Normal

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix · Severity of issue fixed: Medium

Sequence Diagram(s)

sequenceDiagram
  participant CheckoutCaller
  participant usePaymentCheckout
  participant normalizePaymentRequest
  participant PaymentHandler
  CheckoutCaller->>usePaymentCheckout: startPayment(paymentRequest)
  usePaymentCheckout->>normalizePaymentRequest: validate and normalize amounts
  normalizePaymentRequest-->>usePaymentCheckout: normalized request or validation error
  usePaymentCheckout->>PaymentHandler: start payment with normalized request
Loading

Suggested reviewers: piotr-graczyk-dev

Merge Risk: ⚪ Minimal · up to f9333

The reviewed amount-validation and payment-total paths show no outstanding issue that should block merging.

Pre-merge checks: Passed 4 · Failed 2

Failed Failed checks: 2 warnings

Check name Status Explanation Resolution
Linked Issues check Warning Issue #18 requires exact decimal handling and validation. The reported normal paths satisfy most requirements: iOS uses decimal parsing, Android uses BigDecimal, JavaScript uses exact totals, and Je… Ensure every public JavaScript payment entry point validates and normalizes amounts before native invocation, including raw handler access, or document and enforce an equivalent wrapper. Add automated Swift conversion tests for the `Payment…
Docstring Coverage Warning Docstring coverage is 36.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 47 functions across 15 files. (9 skipped:… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Title check Passed The title uses the required fix: prefix and accurately describes the main change. At 59 characters, it exceeds the preferred 50-character target but remains concise and descriptive.
Description check Passed The description is detailed and directly covers exact decimal handling, validation, native changes, tests, documentation, and known limitations.
Out of Scope Changes check Passed The changed native code, JavaScript amount utilities, hooks, tests, generated bindings, example updates, and documentation all support Issue #18. No demonstrated unrelated product behavior is present.…
Security Check Passed No medium-, high-, or critical-severity vulnerability is evident in the changed code. The diff adds decimal parsing and validation, exact arithmetic, generated type bridges, and payment-request handli…
Full details: Out of Scope Changes check

Explanation

The changed native code, JavaScript amount utilities, hooks, tests, generated bindings, example updates, and documentation all support Issue #18. No demonstrated unrelated product behavior is present. The reported Android execution and sandbox-token checks are test-status notes, not out-of-scope changes.

Full details: Security Check

Explanation

No medium-, high-, or critical-severity vulnerability is evident in the changed code. The diff adds decimal parsing and validation, exact arithmetic, generated type bridges, and payment-request handling. Inputs reach no new injection, network, deserialization, filesystem, logging, or authorization sink. JavaScript normalization and the native iOS and Android paths reject malformed amounts and negative totals. The diff adds no dependency or secret-handling change. No prior security findings were supplied.

Full details: Linked Issues check

Explanation

Issue #18 requires exact decimal handling and validation. The reported normal paths satisfy most requirements: iOS uses decimal parsing, Android uses BigDecimal, JavaScript uses exact totals, and Jest plus Kotlin tests cover the main cases. The requirement is not complete. Direct HybridPaymentHandler.startPayment calls can bypass JavaScript normalization, and iOS can accept an over-precise 1.005 on that path. The issue also requests Swift automated conversion tests, but the PR reports only a scratch script because no XCTest target exists. Manual sandbox-token testing is not a coding requirement for this assessment.

Resolution

Ensure every public JavaScript payment entry point validates and normalizes amounts before native invocation, including raw handler access, or document and enforce an equivalent wrapper. Add automated Swift conversion tests for the PaymentAmount cases when the test target is available.

Full details: Docstring Coverage

Explanation

Docstring coverage is 36.17% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 47 functions across 15 files. (9 skipped: 9 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

  • Fix all pre-merge checks with AI
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.

Comment @coderabbitai help to get the list of available commands.

@jkasprzyk17
jkasprzyk17 merged commit 687bb48 into main Oct 20, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Amounts travel as binary floats; Decimal(19.99) is 19.98999999999999488 on iOS

1 participant