@salonhub/capacitor-multisafepay-terminal
v0.3.0
Published
Capacitor plugin for taking payments with the MultiSafepay Pay apps: the SUNMI Pay app on SmartPOS terminals, and Tap to Pay on any NFC-capable Android device
Maintainers
Readme
CapacitorMultisafepayTerminal
This is a Capacitor plugin that hands the screen to the MultiSafepay Pay app on an Android device to take a card payment for a pre-created order, and reports the app's answer when it hands the screen back. It drives two Pay apps behind one contract:
- the SUNMI Pay app (
com.multisafepay.pos.sunmi), pre-installed on MultiSafepay SmartPOS terminals such as the SUNMI P3 MIX; - the Tap to Pay app (
com.phonepos.mspsoftposapp), installed from Google Play on any NFC-capable Android 11+ device, such as a tablet at the counter.
The plugin decides which app a device runs, builds that app's intent, and reads that app's answer. The consumer sees one request shape, one result shape and one outcome vocabulary, and never learns which dialect was spoken.
Important: every answer is UI-level only. Money should never balance on the word of a device callback. Fetch the authoritative transaction result from your backend, which is fed by MultiSafepay webhooks.
Which app runs
MultiSafepay's own rule, decided natively at the moment of asking, so an app installed while the register is standing there is noticed on the next call:
- A SUNMI device (
Build.MANUFACTURER) that has the SUNMI Pay app runs that app. - Otherwise, a device on which the Tap to Pay app's payment activity resolves runs Tap to Pay.
- Otherwise the device cannot take payments itself.
The SUNMI Pay app is shipped for SUNMI payment hardware only, so its presence on any other device is ignored. Each app's presence is the package manager's answer, never sniffed off the device model.
The plugin's library manifest carries the Android 11+ <queries> declarations for both apps, so consumers get package visibility for free via the manifest merger. The consuming app's main activity must use launchMode="singleTask": the SUNMI Pay app returns its answer as a fresh intent at your package, and only a singleTask activity receives that as onNewIntent instead of a second copy of the app.
Intent contracts
Both apps are given the same order reference and amount. The plugin spells them per app.
| | SUNMI Pay app | Tap to Pay app |
|---|---|---|
| Intent | the app's launch intent, aimed at com.multisafepay.pos.middleware.IntentActivity | com.phonepos.mspsoftposapp.ACTION_MANUAL_PAYMENT, aimed at com.phonepos.mspsoftposapp.ManualPayInputActivity |
| Launch | startActivity | startActivityForResult |
| amount | long, minor units | String, two decimals ("0.01" is one cent), integer math |
| currency | String | String |
| order_id | String, the order reference | String, the order reference |
| package_name | String, your applicationId | String, your applicationId |
| callback_activity | | String, your main activity's class name |
| skip_manual_input | | true: the register already knows the amount |
| Answer | a fresh intent at your package with integer status and message | the activity result with result_status (a word), message, description |
The onNewIntent door also recognises a result_status extra as a Tap to Pay answer, should MultiSafepay ever use the package_name and callback_activity pair it is handed.
Outcomes
Each app is read through its own table into one vocabulary. Neither table knows the other exists.
| outcome | SUNMI Pay app | Tap to Pay app | what it means |
|---|---|---|---|
| completed | 471 | COMPLETED (or success) | the customer paid |
| cancelled | 17 | CANCELLED | closed on the device before anything was charged |
| declined | 88 | DECLINED | the card was refused; the order stands |
| not-started | 875 | no readable result_status | the app never ran the payment |
| unknown | any other integer | | no usable answer |
Two things follow from the table. cancelled and not-started mean nothing was charged and no webhook will ever close the order, so cancel it on your backend; not-started usually carries the app's own message, worth showing the cashier. unknown must not cancel anything: it says nothing about whether a card was charged, so ask your backend.
The two apps differ in one deliberate place. An unrecognised SUNMI integer, including the 0 a malformed result reads as, is unknown and proceeds to the backend. A Tap to Pay result without a readable status is not-started: the Activity Result API couples that answer to our own launch, so an empty result is the app closing without running a payment, and the app puts result_status on every real outcome.
Tap to Pay words are matched case-insensitively, as MultiSafepay's own example app does. Their README spells the words in lowercase and the live app answers them in uppercase.
How to use it?
Import the class and instantiate it:
import { CapacitorMultisafepayTerminal, PayApp, PaymentOutcome } from '@salonhub/capacitor-multisafepay-terminal';
const terminal = new CapacitorMultisafepayTerminal();Ask what this device can do:
let capabilities = await terminal.getCapabilities();
/* {
local: true when either app is on this device
sunmi: true on a SUNMI device with the SUNMI Pay app
tapToPay: true when the Tap to Pay app is installed (and sunmi is false)
} */
if (await terminal.isAvailable()) {
/* the same answer as capabilities.local */
}Start a payment for an order that was already created via the MultiSafepay API:
let result = await terminal.startPayment({
reference: 'ORDER-12345', /* the backend's order reference */
amount: 1000, /* an integer in minor units, so € 10,00 */
currency: 'EUR', /* default */
timeout: 210000 /* default, see below */
});
/* result = {
app: PayApp.SUNMI | PayApp.TAP_TO_PAY
outcome: one of PaymentOutcome
message: the app's own message, only when it gave one
raw: the app's untouched answer, for your log; null when nothing came
back, because the watchdog gave up or a newer payment superseded
} */
if (result.outcome == PaymentOutcome.NOT_STARTED || result.outcome == PaymentOutcome.CANCELLED) {
/* cancel the order on your backend */
}The call rejects with pay_app_not_installed when neither app is on the device, invalid_amount when the amount is not a non-negative integer, invalid_reference when the reference is missing, and invalid_timeout when the timeout is not a positive whole number of milliseconds.
The watchdog. A Pay app that never answers must not stand a register still forever, so startPayment() gives up after timeout milliseconds and resolves unknown with raw: null. The default of 210 000 sits past the 180-second card payment limit: by then the Pay app has stopped and the server has expired the transaction and sent its webhook, with 30 seconds to spare for both to land, so a backend asked afterwards answers definitively. An answer that arrives after the watchdog fired is dropped and logged. The watchdog never cancels anything: silence is not evidence that no card was tapped.
A payment started while another is still pending does not reject either. The pending one is answered unknown with raw: null, and the new one takes its place. In both cases the old flow proceeds to your backend exactly as it would after any answer it cannot read.
If the app is killed while the Pay app is in the foreground, the pending call is lost. Recover the outcome from your backend.
Migrating from 0.2.0
startPayment({ orderId })is nowstartPayment({ reference }).- The result was
{ status, message }with the SUNMI Pay app's integer codes. It is now{ app, outcome, message, raw }; branch onoutcome, and read the app's own answer fromrawwhen logging. - The
busyrejection is gone. A newer payment supersedes a pending one, and a watchdog gives up on one that never answers, as described above. getCapabilities()is new.isAvailable()still works and equalscapabilities.local.
Installation in the Salonhub Application
This package is installed twice, feeding two pipelines that never see each other's dependencies:
- JS library — in the Application's own
package.json, so the class can be imported in the per-platform devices bundle. - Native plugin — in
build/capacitor/package.json, where the Capacitor CLI picks it up for automatic registration (capacitor.plugins.json) in every build variant. NoMainActivitycode needed. This is whypackage.jsoncarries the"capacitor": { "android": { "src": "android" } }block and ships theandroid/library module.
Both should name the same version so the two installs cannot drift. During development both may point at one checkout with file:../Dependencies/CapacitorMultisafepayTerminal.
Development
npm install && npm run build produces the dist/ bundles. The Android module reads its toolchain versions from the consuming app's rootProject.ext and falls back to the Capacitor 8 baseline for a standalone build. The selection rule, the amount handling and both outcome tables are pure Java under android/src/main/java with JUnit tests under android/src/test/java; they run with any composite build that includes :capacitor-android and this module.
This plugin has been created by Niels Leenheer under the MIT license. The development of this plugin is sponsored by Salonhub.
