npm package: @hussainpithawala/emv-merchant-qr
Description: EMV® QR Code encoder/decoder for Merchant-Presented Mode v1.0 — Bharat QR / NPCI compatible. Zero dependencies.
Author: Hussain Pithawala
Latest version: 1.0.0

# @hussainpithawala/emv-merchant-qr

[![npm version](https://img.shields.io/npm/v/@hussainpithawala/emv-merchant-qr.svg)](https://www.npmjs.com/package/@hussainpithawala/emv-merchant-qr)
[![CI](https://github.com/hussainpithawala/emv-merchant-qr-lib-ts/actions/workflows/ci.yml/badge.svg)](https://github.com/hussainpithawala/emv-merchant-qr-lib-ts/actions)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
[![Zero Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen.svg)](package.json)

TypeScript encoder/decoder for **EMV® QR Code payloads** — Merchant-Presented Mode v1.0.  
Fully compatible with **Bharat QR** / NPCI payment infrastructure (UPI, RuPay).

> This is the TypeScript port of [`emv-merchant-qr-lib`](https://github.com/hussainpithawala/emv-merchant-qr-lib) (Go).

## Features

- 🔍 **Decode** any EMV/Bharat QR string into a typed `Payload` object
- ✍️ **Encode** a structured payload back to a valid QR Code string
- 🔐 **CRC16-CCITT** validation on decode; automatic computation on encode
- 💳 **Multi-network** — primitive (IDs `02`–`25`) and template (IDs `26`–`51`) MAIs
- 🇮🇳 **Bharat QR / NPCI** — UPI VPA + RuPay credentials in a single QR
- 💰 **Tip & convenience fees** — fixed, percentage, and consumer-prompted tip
- 🗂️ **Additional Data Fields** — bill number, loyalty, store label, and more
- 🌐 **Alternate language** template for Hindi and other regional display names
- 🔧 **Unreserved Templates** (IDs `80`–`99`) for NPCI proprietary data
- **Zero runtime dependencies** — pure TypeScript, ships as CJS + ESM

## Installation

```bash
npm install @hussainpithawala/emv-merchant-qr
# or
yarn add @hussainpithawala/emv-merchant-qr
```

Requires **Node.js 18+**.

## Quick Start

```ts
import { newPayload, encode, decode, PROMPT_VALUE } from '@hussainpithawala/emv-merchant-qr';

// Build a Bharat QR payload for a Mumbai kirana store
const p = newPayload();
p.addTemplateMerchantAccount('26', 'A000000524', { id: '01', value: 'sharma.kirana@icici' });
p.merchantCategoryCode = '5411';  // Grocery Stores
p.transactionCurrency  = '356';   // INR
p.countryCode          = 'IN';
p.merchantName         = 'Sharma Kirana Store';
p.merchantCity         = 'Mumbai';

const raw = encode(p);         // → "0002010226..."
const decoded = decode(raw);   // throws EMVQRCRCMismatchError if corrupted
```

---

## Usage Examples

### Static Bharat QR (printed sticker)

No amount embedded — the consumer enters it in their UPI app.

```ts
const p = newPayload();
p.addTemplateMerchantAccount('26', 'A000000524', { id: '01', value: 'merchant@upi' });
p.merchantCategoryCode = '5411';
p.transactionCurrency  = '356';
p.countryCode          = 'IN';
p.merchantName         = 'My Store';
p.merchantCity         = 'Mumbai';

const qr = encode(p); // print or display this string as a QR code
```

### Dynamic QR (per-transaction, amount locked)

```ts
p.transactionAmount = '450.00'; // consumer cannot alter this
const qr = encode(p);
```

### Full Bharat QR — UPI + RuPay

```ts
p.addTemplateMerchantAccount('26', 'A000000524', { id: '01', value: 'merchant@icici' }); // UPI
p.addTemplateMerchantAccount('27', 'A000000524', { id: '01', value: '4403847000746908' }); // RuPay

const d = decode(encode(p));
console.log(d.hasMultipleNetworks()); // true — consumer app offers network choice
```

### Fixed Convenience Fee (restaurant packaging)

```ts
p.transactionAmount = '800';
p.setFixedConvenienceFee('25');  // ₹25 packaging fee

const d = decode(encode(p));
console.log(d.totalAmount()); // 825
```

### Percentage Convenience Fee (electricity board)

```ts
p.transactionAmount = '5000';
p.setPercentageConvenienceFee('1.50'); // 1.50% surcharge

const d = decode(encode(p));
console.log(d.totalAmount()); // 5075
```

### Consumer-Prompted Tip

```ts
p.transactionAmount = '1200';
p.setPromptForTip(); // consumer app shows "Add a tip?" — must allow 0 tip

const d = decode(encode(p));
console.log(d.tipOrConvenienceIndicator); // "01"
```

### Additional Data Fields (loyalty, bill number, etc.)

```ts
p.setAdditionalData((adf) => {
  adf.billNumber    = 'INV2024-001';     // GST invoice number
  adf.loyaltyNumber = PROMPT_VALUE;      // "***" → consumer app prompts for loyalty card
  adf.storeLabel    = 'Connaught Place'; // branch label
});

const d = decode(encode(p));
console.log(d.loyaltyNumberRequired()); // true
```

### Hindi Alternate Language Name

```ts
p.merchantName = 'Sharma Kirana Store'; // English (required)
p.setLanguageTemplate('hi', 'Sharma Kiraane', ''); // Hindi name

const d = decode(encode(p));
console.log(d.preferredMerchantName('hi')); // "Sharma Kiraane"
console.log(d.preferredMerchantName('en')); // "Sharma Kirana Store"
```

### Error Handling

```ts
import {
  decode,
  EMVQRCRCMismatchError,
  EMVQRInvalidTLVError,
  EMVQRMissingRequiredError,
} from '@hussainpithawala/emv-merchant-qr';

try {
  const p = decode(scannedString);
} catch (err) {
  if (err instanceof EMVQRCRCMismatchError) {
    // QR code is corrupted or tampered
  } else if (err instanceof EMVQRInvalidTLVError) {
    // Malformed TLV structure
  } else if (err instanceof EMVQRMissingRequiredError) {
    // Required field missing (encode-time)
  }
}
```

---

## API Reference

### Top-level functions

| Function | Description |
|---|---|
| `decode(raw)` | Parse a QR string; validates CRC. Throws on error. |
| `decodeWithOptions(raw, opts)` | Parse with `{ skipCRCValidation: true }` option. |
| `encode(payload)` | Serialise and append computed CRC. Throws on error. |
| `encodeWithOptions(payload, opts)` | Serialise with override options. |
| `newPayload()` | Create a `Payload` with `payloadFormatIndicator = "01"`. |

### `Payload` class

#### Data properties

| Property | Type | Notes |
|---|---|---|
| `payloadFormatIndicator` | `string` | Always `"01"` |
| `merchantAccountInfos` | `MerchantAccountInfo[]` | Primitive and template MAIs |
| `merchantCategoryCode` | `string` | ISO 18245 |
| `transactionCurrency` | `string` | ISO 4217 numeric (`"356"` = INR) |
| `transactionAmount` | `string` | Empty = consumer enters amount |
| `tipOrConvenienceIndicator` | `string` | `""` / `"01"` / `"02"` / `"03"` |
| `valueConvenienceFeeFixed` | `string` | Set when indicator = `"02"` |
| `valueConvenienceFeePercent` | `string` | Set when indicator = `"03"` |
| `countryCode` | `string` | ISO 3166-1 alpha-2 |
| `merchantName` | `string` | Max 25 chars |
| `merchantCity` | `string` | Max 15 chars |
| `postalCode` | `string` | Optional |
| `additionalData` | `AdditionalDataField \| null` | |
| `languageTemplate` | `LanguageTemplate \| null` | |
| `unreservedTemplates` | `UnreservedTemplate[]` | IDs `"80"`–`"99"` |

#### Methods

| Method | Description |
|---|---|
| `addPrimitiveMerchantAccount(id, value)` | Add primitive MAI (IDs `"02"`–`"25"`). Pass `""` for auto-ID. |
| `addTemplateMerchantAccount(id, guid, ...extra)` | Add template MAI (IDs `"26"`–`"51"`). Pass `""` for auto-ID. |
| `setFixedConvenienceFee(amount)` | Fixed fee added automatically by consumer app. |
| `setPercentageConvenienceFee(percent)` | Percentage fee, e.g. `"1.50"` for 1.50%. |
| `setPromptForTip()` | Consumer app prompts for tip (must allow 0). |
| `setAdditionalData(fn)` | Set ADF sub-fields via callback. |
| `setLanguageTemplate(lang, name, city)` | Set alternate language display name. |
| `totalAmount()` | Base + fee. Throws if no `transactionAmount`. |
| `loyaltyNumberRequired()` | `true` when `loyaltyNumber === "***"`. |
| `mobileNumberRequired()` | `true` when `mobileNumber === "***"`. |
| `preferredMerchantName(lang)` | Language-aware name with English fallback. |
| `preferredMerchantCity(lang)` | Language-aware city with English fallback. |
| `hasMultipleNetworks()` | `true` when > 1 MAI entry. |

### `MerchantAccountInfo` class

| Method | Description |
|---|---|
| `globallyUniqueID()` | Returns sub-field `"00"` value (template MAIs). |
| `subField(id)` | Returns the value of sub-field `id`, or `""`. |
| `isTemplate()` | `true` for IDs `"26"`–`"51"`. |

### Error classes

| Class | When thrown |
|---|---|
| `EMVQRError` | Base class for all EMV QR errors |
| `EMVQRInvalidLengthError` | Payload string too short |
| `EMVQRInvalidTLVError` | Malformed TLV structure |
| `EMVQRCRCMismatchError` | CRC does not match |
| `EMVQRMissingRequiredError` | Required field absent (encode) |
| `EMVQRParseError` | Field-specific parse failure (has `.fieldId`) |

### Constants

```ts
// Tip indicators
TIP_INDICATOR_PROMPT_CONSUMER       // "01"
TIP_INDICATOR_FIXED_CONVENIENCE_FEE // "02"
TIP_INDICATOR_PERCENTAGE_FEE        // "03"

// Sentinel — triggers consumer app prompt for input
PROMPT_VALUE // "***"

// Indian context
// Currency: "356" (INR)   Country: "IN"   NPCI GUID: "A000000524"
```

---

## Bharat QR Reference

NPCI mandated (Sept 2017) that all Bharat QR codes must include at least:

| MAI Slot | Network | Notes |
|---|---|---|
| Primitive `02`–`08` | Visa, MC, Amex, etc. | Acquirer-assigned 16-char merchant IDs |
| Template `26` | **UPI** | GUID = `A000000524`, sub-field `01` = VPA |
| Template `27` | **RuPay** | GUID = `A000000524`, sub-field `01` = acquirer cred |

```
Currency : 356 (INR)
Country  : IN
```

---

## Contributing

See [CONTRIBUTING.md](CONTRIBUTING.md).

## License

MIT — see [LICENSE](LICENSE).

## Disclaimer

EMV® is a registered trademark of EMVCo, LLC. This library is an independent
open-source implementation and is not affiliated with or endorsed by EMVCo or NPCI.