npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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

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:

  1. A SUNMI device (Build.MANUFACTURER) that has the SUNMI Pay app runs that app.
  2. Otherwise, a device on which the Tap to Pay app's payment activity resolves runs Tap to Pay.
  3. 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 now startPayment({ reference }).
  • The result was { status, message } with the SUNMI Pay app's integer codes. It is now { app, outcome, message, raw }; branch on outcome, and read the app's own answer from raw when logging.
  • The busy rejection 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 equals capabilities.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. No MainActivity code needed. This is why package.json carries the "capacitor": { "android": { "src": "android" } } block and ships the android/ 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.