Skip to content

Cds/offline payments #45

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.mobilepaymentssdkreactnative

import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContext
Expand Down Expand Up @@ -206,6 +207,57 @@ class MobilePaymentsSdkReactNativeModule(private val reactContext: ReactApplicat
paymentHandle = null
}

@ReactMethod
fun isOfflineProcessingAllowed(promise: Promise) {
val paymentSettings = MobilePaymentsSdk.settingsManager().getPaymentSettings()
promise.resolve(paymentSettings.isOfflineProcessingAllowed)
}

@ReactMethod
fun getOfflineTotalStoredAmountLimit(promise: Promise) {
val paymentSettings = MobilePaymentsSdk.settingsManager().getPaymentSettings()
promise.resolve(paymentSettings.offlineTotalStoredAmountLimit?.toMoneyMap())
}

@ReactMethod
fun getOfflineTransactionAmountLimit(promise: Promise) {
val paymentSettings = MobilePaymentsSdk.settingsManager().getPaymentSettings()
promise.resolve(paymentSettings.offlineTransactionAmountLimit?.toMoneyMap())
}

@ReactMethod
fun getPayments(promise: Promise) {
val offlinePaymentQueue = MobilePaymentsSdk.paymentManager().getOfflinePaymentQueue()
offlinePaymentQueue.getPayments { result ->
when (result) {
is Success -> {
val paymentList = Arguments.createArray()
result.value.forEach { payment ->
paymentList.pushMap(payment.toPaymentMap())
}
promise.resolve(paymentList)
}
is Failure -> {
promise.reject("GET_OFFLINE_PAYMENTS_FAILED", result.errorMessage, result.toErrorMap())
}
}
}
}

@ReactMethod
fun getTotalStoredPaymentAmount(promise: Promise) {
val offlinePaymentQueue = MobilePaymentsSdk.paymentManager().getOfflinePaymentQueue()
val result = offlinePaymentQueue.getTotalStoredPaymentAmount()
when (result) {
is Success -> {
promise.resolve(result.value.toMoneyMap())
}
is Failure -> {
promise.reject("GET_TOTAL_STORED_PAYMENTS_FAILED", result.errorMessage, result.toErrorMap())
}
}
}

private fun emitEvent(reactContext: ReactContext, eventName: String, map: WritableMap) {
reactContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
Expand Down
116 changes: 110 additions & 6 deletions android/src/main/java/com/mobilepaymentssdkreactnative/Utils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import com.squareup.sdk.mobilepayments.payment.AdditionalPaymentMethod.Type
import com.squareup.sdk.mobilepayments.payment.CurrencyCode
import com.squareup.sdk.mobilepayments.payment.DelayAction
import com.squareup.sdk.mobilepayments.payment.Money
import com.squareup.sdk.mobilepayments.payment.CardPaymentDetails
import com.squareup.sdk.mobilepayments.payment.Card
import com.squareup.sdk.mobilepayments.payment.Payment
import com.squareup.sdk.mobilepayments.payment.Payment.OfflineStatus
import com.squareup.sdk.mobilepayments.payment.Payment.OfflinePayment
import com.squareup.sdk.mobilepayments.payment.Payment.OnlinePayment
import com.squareup.sdk.mobilepayments.payment.Payment.SourceType
Expand Down Expand Up @@ -160,25 +163,126 @@ private fun SourceType.toEnumInt(): Int = when (this) {
}

fun Payment.toPaymentMap(): ReadableMap {
val id = when (this) {
is OfflinePayment -> localId
is OnlinePayment -> id
}
return WritableNativeMap().apply {
putMap("amountMoney", amountMoney.toMoneyMap())
putMap("appFeeMoney", appFeeMoney.toMoneyMap())
putString("createdAt", createdAt.toIsoInstantString())
putString("id", id)
putString("locationId", locationId)
putString("orderId", orderId)
putString("referenceId", referenceId)
putInt("sourceType", sourceType.toEnumInt())
putMap("tipMoney", tipMoney.toMoneyMap())
putInt("totalMoney", totalMoney.amount.toInt())
putMap("totalMoney", totalMoney.toMoneyMap())
putString("updatedAt", updatedAt.toIsoInstantString())
//cashDetails
//externalDetails
when (this@toPaymentMap) {
is OfflinePayment -> {
putString("uploadedAt", uploadedAt?.toIsoInstantString())
putString("localId", localId)
putString("id", id)
putString("status", status.toOfflineStatusString())
putMap("cardDetails", cardDetails?.toCardDetailsMap())
}
is OnlinePayment -> {
putString("id", id)
/*processingFee
status
cardDetails
customerId
note
statementDescription
teamMemberId
capabilities
receiptNumber
remainingBalance
squareAccountDetails
digitalWalletDetails*/
}
}
}
}

private fun OfflineStatus.toOfflineStatusString() : String =
when (this) {
OfflineStatus.QUEUED -> "QUEUED"
OfflineStatus.UPLOADED -> "UPLOADED"
OfflineStatus.FAILED_TO_UPLOAD -> "FAILED_TO_UPLOAD"
OfflineStatus.FAILED_TO_PROCESS -> "FAILED_TO_PROCESS"
OfflineStatus.PROCESSED -> "PROCESSED"
}

private fun CardPaymentDetails.toCardDetailsMap(): ReadableMap {
return WritableNativeMap().apply {
putMap("card", card.toCardMap())
putString("entryMethod", entryMethod.toEntryString())
when (this@toCardDetailsMap) {
is CardPaymentDetails.OfflineCardPaymentDetails -> {
putString("applicationIdentifier", applicationId)
putString("applicationName", applicationName)
}
is CardPaymentDetails.OnlineCardPaymentDetails -> {
putString("applicationIdentifier", applicationId)
putString("applicationName", applicationName)
//authorizationCode
//Status
}
}
}
}

private fun Card.toCardMap(): ReadableMap {
return WritableNativeMap().apply {
putString("brand", brand.toBrandString())
putString("cardCoBrand", cardCoBrand.toCoBrandString())
putString("lastFourDigits", lastFourDigits)
putInt("expirationMonth", expirationMonth)
putInt("expirationYear", expirationYear)
putString("cardholderName", cardholderName)
putString("id", id)
}
}

private fun CardPaymentDetails.EntryMethod.toEntryString(): String =
when(this) {
CardPaymentDetails.EntryMethod.KEYED -> "KEYED"
CardPaymentDetails.EntryMethod.SWIPED -> "SWIPED"
CardPaymentDetails.EntryMethod.EMV -> "EMV"
CardPaymentDetails.EntryMethod.CONTACTLESS -> "CONTACTLESS"
CardPaymentDetails.EntryMethod.ON_FILE -> "ON_FILE"
}

private fun Card.Brand.toBrandString(): String =
when(this) {
Card.Brand.OTHER_BRAND -> "OTHER_BRAND"
Card.Brand.VISA -> "VISA"
Card.Brand.MASTERCARD -> "MASTERCARD"
Card.Brand.AMERICAN_EXPRESS -> "AMERICAN_EXPRESS"
Card.Brand.DISCOVER -> "DISCOVER"
Card.Brand.DISCOVER_DINERS -> "DISCOVER_DINERS"
Card.Brand.EBT -> "EBT"
Card.Brand.JCB -> "JCB"
Card.Brand.CHINA_UNIONPAY -> "CHINA_UNIONPAY"
Card.Brand.SQUARE_GIFT_CARD -> "SQUARE_GIFT_CARD"
Card.Brand.ALIPAY -> "ALIPAY"
Card.Brand.CASH_APP -> "CASH_APP"
Card.Brand.EFTPOS -> "EFTPOS"
Card.Brand.FELICA -> "FELICA"
Card.Brand.INTERAC -> "INTERAC"
Card.Brand.SQUARE_CAPITAL_CARD -> "SQUARE_CAPITAL_CARD"
Card.Brand.SUICA -> "SUICA"
Card.Brand.ID -> "ID"
Card.Brand.QUICPAY -> "QUICPAY"
}

private fun Card.CoBrand.toCoBrandString(): String =
when(this) {
Card.CoBrand.AFTERPAY -> "AFTERPAY"
Card.CoBrand.CLEARPAY -> "CLEARPAY"
Card.CoBrand.NONE -> "NONE"
Card.CoBrand.UNKNOWN -> "UNKNOWN"
}

fun Date.toIsoInstantString(): String =
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
Expand Down
59 changes: 59 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -249,3 +249,62 @@ console.log('Device supports Tap to Pay:', isCapable);
```

> **Note:** These methods are only available on iOS. Calling them on Android will result in an error.

## 📡 Offline Payments (Beta)

The Mobile Payments SDK supports taking payments offline. This is currently in **Beta** and requires seller onboarding.

### 🔧 New Methods

You can manage offline payments using the following methods in the `PaymentSettings` and `OfflinePaymentQueue` namespaces.

#### PaymentSettings

```ts
import { PaymentSettings } from 'mobile-payments-sdk-react-native';

const isAllowed = await PaymentSettings.isOfflineProcessingAllowed();

const totalLimit = await PaymentSettings.getOfflineTotalStoredAmountLimit();

const transactionLimit = await PaymentSettings.getOfflineTransactionAmountLimit();
```

- `isOfflineProcessingAllowed()` – Checks if the current seller can take offline payments.
- `getOfflineTotalStoredAmountLimit()` – Gets the max total value of offline payments that can be stored.
- `getOfflineTransactionAmountLimit()` – Gets the max amount per offline transaction.

#### OfflinePaymentQueue

```ts
import { OfflinePaymentQueue } from 'mobile-payments-sdk-react-native';

const pendingPayments = await OfflinePaymentQueue.getPayments();

const totalStoredAmount = await OfflinePaymentQueue.getTotalStoredPaymentAmount();
```

- `getPayments()` – Returns a list of offline payments currently stored.
- `getTotalStoredPaymentAmount()` – Returns the total value of stored offline payments.

---

### 🧾 Seller Onboarding

> Offline Payments support is **Beta-only** and requires seller opt-in.

To onboard a seller:

1. Send an email to: **[email protected]**
2. Include the following:
- The seller's **business name**
- The seller's **email address** (owner/admin of their Square account)
- Your **application ID**

Square will contact the seller and provide an onboarding form. Once completed, Square will notify you when the seller is ready to process offline payments.

> ℹ️ You can always check whether offline payments are allowed by calling `PaymentSettings.isOfflineProcessingAllowed()`.

If you try to process an offline payment for a seller who hasn’t been onboarded, the SDK will return a **USAGE_ERROR**.

For more, visit the [Square Android Offline Payments docs](https://developer.squareup.com/docs/mobile-payments-sdk/android/offline-payments#seller-onboarding).
2 changes: 2 additions & 0 deletions example/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createNativeStackNavigator } from '@react-navigation/native-stack';
import { NavigationContainer } from '@react-navigation/native';
import HomeView from './Screens/HomeScreen';
import PermissionsScreen from './Screens/PermissionsScreen';
import TestScreen from './Screens/TestScreen';

const Stack = createNativeStackNavigator();

Expand All @@ -19,6 +20,7 @@ export default function App() {
component={PermissionsScreen}
options={{ headerShown: false }}
/>
<Stack.Screen name="Test" component={TestScreen} />
</Stack.Navigator>
</NavigationContainer>
);
Expand Down
12 changes: 10 additions & 2 deletions example/src/Screens/HomeScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
mapUserInfoToFailure,
type PaymentParameters,
type PromptParameters,
ProcessingMode,
} from 'mobile-payments-sdk-react-native';
import React, { useState } from 'react';
import {
Expand Down Expand Up @@ -46,10 +47,11 @@

const handleStartPayment = async () => {
const paymentParameters: PaymentParameters = {
amountMoney: { amount: 100, currencyCode: CurrencyCode.USD },
appFeeMoney: { amount: 0, currencyCode: CurrencyCode.USD },
amountMoney: { amount: 1, currencyCode: CurrencyCode.EUR },
appFeeMoney: { amount: 0, currencyCode: CurrencyCode.EUR },
idempotencyKey: uuid.v4(),
note: 'Payment for services',
processingMode: ProcessingMode.AUTO_DETECT

Check failure on line 54 in example/src/Screens/HomeScreen.tsx

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
// Other parameters you could add:
// autocomplete: true,
// delayAction: DelayAction.CANCEL,
Expand Down Expand Up @@ -112,6 +114,12 @@
{isMockReaderPresented ? 'Hide Mock Reader' : 'Show Mock Reader'}
</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.mockButton}
onPress={() => navigation.navigate('Test')}
>
<Text style={styles.mockReaderText}>{'Go offline test'}</Text>
</TouchableOpacity>
</SafeAreaView>
);
};
Expand Down
75 changes: 75 additions & 0 deletions example/src/Screens/TestScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { useState } from 'react';
import { View, ScrollView, Button, StyleSheet, Text } from 'react-native';
import TestModal from '../components/TestModal';

const TestScreen = () => {
const [testModal, setTestModal] = useState(false);
const [content, setContent] = useState<
{ message: string; isError: boolean }[]
>([]);

const onLog = (message: string, isError: boolean = false) => {
setContent((prevContent) => [...prevContent, { message, isError }]);
};

return (
<View style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent}>
{content.map((item, index) => (
<View key={index} style={styles.logContainer}>
<View
style={[
styles.dot,
{ backgroundColor: item.isError ? 'red' : 'green' },

Check warning on line 23 in example/src/Screens/TestScreen.tsx

View workflow job for this annotation

GitHub Actions / lint

Inline style: { backgroundColor: "item.isError ? 'red' : 'green'" }
]}
/>
<Text style={styles.text}>{item.message}</Text>
</View>
))}
</ScrollView>
<View style={styles.buttonContainer}>
<Button title="Test Methods" onPress={() => setTestModal(true)} />
</View>
<TestModal
visible={testModal}
onClose={() => {
setTestModal(false);
}}
onLog={onLog}
/>
</View>
);
};

const styles = StyleSheet.create({
container: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
padding: 20,
},
logContainer: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 10,
},
dot: {
width: 10,
height: 10,
borderRadius: 5,
marginRight: 10,
},
text: {
fontSize: 18,
color: '#000000'

Check failure on line 65 in example/src/Screens/TestScreen.tsx

View workflow job for this annotation

GitHub Actions / lint

Insert `,`
},
buttonContainer: {
backgroundColor: 'white',
padding: 10,
borderTopWidth: 1,
borderColor: '#ccc',
},
});

export default TestScreen;
Loading
Loading