upi 2.0.1 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.idea/upi.iml +6 -5
- data/.rubocop.yml +11 -3
- data/CHANGELOG.md +57 -0
- data/Gemfile.lock +1 -1
- data/README.md +160 -58
- data/lib/upi/version.rb +1 -1
- data/lib/upi.rb +341 -88
- data/sig/upi/generator.rbs +77 -3
- data/upi.gemspec +40 -0
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 0c35150decd0ea8da417f11c7d5513cdf82a3609a61b071d9e41d6977ec0b802
|
|
4
|
+
data.tar.gz: d24b6754719ce81625612e38827d8a453df4d9a09b1a7d6966ff71c6972f0289
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5eba315c7f151124b1087ceba8555358fa5b95e3f07bc208e314b7f82f7497ea9617a9cab45bc7599a29574ef739b8e38a9067a68c3f10b9cf5aab100d9271f3
|
|
7
|
+
data.tar.gz: 7fe580edf651ff6eb855c66996fb7013121800a96ef5d8c8f6dc2a3690c9a14847d7cdf403ed3b78b6b59f59a005076de5b2e4b0d52c445486754e2db083f3c6
|
data/.idea/upi.iml
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
<orderEntry type="sourceFolder" forTests="false" />
|
|
14
14
|
<orderEntry type="library" scope="PROVIDED" name="ast (v2.4.2, RVM: ruby-3.1.0) [gem]" level="application" />
|
|
15
15
|
<orderEntry type="library" scope="PROVIDED" name="bundler (v2.3.3, RVM: ruby-3.1.0) [gem]" level="application" />
|
|
16
|
+
<orderEntry type="library" scope="PROVIDED" name="byebug (v11.1.3, RVM: ruby-3.1.0) [gem]" level="application" />
|
|
16
17
|
<orderEntry type="library" scope="PROVIDED" name="chunky_png (v1.4.0, RVM: ruby-3.1.0) [gem]" level="application" />
|
|
17
18
|
<orderEntry type="library" scope="PROVIDED" name="diff-lcs (v1.5.1, RVM: ruby-3.1.0) [gem]" level="application" />
|
|
18
19
|
<orderEntry type="library" scope="PROVIDED" name="json (v2.7.2, RVM: ruby-3.1.0) [gem]" level="application" />
|
|
@@ -39,21 +40,21 @@
|
|
|
39
40
|
<option name="myRootTask">
|
|
40
41
|
<RakeTaskImpl id="rake">
|
|
41
42
|
<subtasks>
|
|
42
|
-
<RakeTaskImpl description="Build upi-2.0.
|
|
43
|
+
<RakeTaskImpl description="Build upi-2.0.2.gem into the pkg directory" fullCommand="build" id="build" />
|
|
43
44
|
<RakeTaskImpl id="build">
|
|
44
45
|
<subtasks>
|
|
45
|
-
<RakeTaskImpl description="Generate SHA512 checksum if upi-2.0.
|
|
46
|
+
<RakeTaskImpl description="Generate SHA512 checksum if upi-2.0.2.gem into the checksums directory" fullCommand="build:checksum" id="checksum" />
|
|
46
47
|
</subtasks>
|
|
47
48
|
</RakeTaskImpl>
|
|
48
49
|
<RakeTaskImpl description="Remove any temporary products" fullCommand="clean" id="clean" />
|
|
49
50
|
<RakeTaskImpl description="Remove any generated files" fullCommand="clobber" id="clobber" />
|
|
50
|
-
<RakeTaskImpl description="Build and install upi-2.0.
|
|
51
|
+
<RakeTaskImpl description="Build and install upi-2.0.2.gem into system gems" fullCommand="install" id="install" />
|
|
51
52
|
<RakeTaskImpl id="install">
|
|
52
53
|
<subtasks>
|
|
53
|
-
<RakeTaskImpl description="Build and install upi-2.0.
|
|
54
|
+
<RakeTaskImpl description="Build and install upi-2.0.2.gem into system gems without network access" fullCommand="install:local" id="local" />
|
|
54
55
|
</subtasks>
|
|
55
56
|
</RakeTaskImpl>
|
|
56
|
-
<RakeTaskImpl description="Create tag v2.0.
|
|
57
|
+
<RakeTaskImpl description="Create tag v2.0.2 and build and push upi-2.0.2.gem to https://rubygems.org" fullCommand="release[remote]" id="release[remote]" />
|
|
57
58
|
<RakeTaskImpl description="Run RuboCop" fullCommand="rubocop" id="rubocop" />
|
|
58
59
|
<RakeTaskImpl id="rubocop">
|
|
59
60
|
<subtasks>
|
data/.rubocop.yml
CHANGED
|
@@ -15,8 +15,16 @@ Layout/LineLength:
|
|
|
15
15
|
Metrics/MethodLength:
|
|
16
16
|
Max: 60
|
|
17
17
|
|
|
18
|
-
Metrics/BlockLength:
|
|
19
|
-
Max: 150
|
|
20
|
-
|
|
21
18
|
Metrics/ParameterLists:
|
|
22
19
|
Max: 15
|
|
20
|
+
|
|
21
|
+
Metrics/AbcSize:
|
|
22
|
+
Max: 20
|
|
23
|
+
|
|
24
|
+
Metrics/ClassLength:
|
|
25
|
+
Max: 250
|
|
26
|
+
|
|
27
|
+
Metrics/BlockLength:
|
|
28
|
+
Max: 150
|
|
29
|
+
Exclude:
|
|
30
|
+
- 'spec/**/*_spec.rb'
|
data/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,61 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
|
+
- In the making
|
|
3
|
+
|
|
4
|
+
## [3.0.0] - 2026-09-17
|
|
5
|
+
|
|
6
|
+
Correctness release. Earlier versions could emit a UPI URI that violated the NPCI
|
|
7
|
+
UPI Linking Specification. The QR code still scanned, so the only symptom was a
|
|
8
|
+
payment that failed in the payer's app, seemingly at random, depending on the
|
|
9
|
+
amount and note involved.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
- Spaces are now percent-encoded as `%20` instead of `+`. The specification's own
|
|
13
|
+
example URIs use `%20`; `+` is only correct for HTML form bodies, and PSP apps
|
|
14
|
+
that decode strictly showed names like `Test+User`.
|
|
15
|
+
- Amounts are normalised to exactly two decimal places. `1499.5` now emits
|
|
16
|
+
`am=1499.50`, and float representation noise (`0.1 + 0.2`) no longer emits
|
|
17
|
+
`am=0.30000000000000004`, which PSP apps reject.
|
|
18
|
+
- An amount with more than two decimal places, a zero amount, or a negative amount
|
|
19
|
+
now raises `Upi::ValidationError` instead of producing an unpayable QR code.
|
|
20
|
+
- `am` and `tn` are omitted entirely when not supplied. Previously every call
|
|
21
|
+
without an amount emitted `am=0&tn=`, which Google Pay and PhonePe reject.
|
|
22
|
+
- Values can no longer break out of the query string. A note containing `&` or `#`
|
|
23
|
+
used to truncate or forge parameters in `no_url_parse: false` mode.
|
|
24
|
+
- PNG output is sized by module rather than pinned to a 300px canvas, so a longer
|
|
25
|
+
payload produces a larger image instead of modules too small to scan reliably.
|
|
26
|
+
- `#upi_content` and `#generate_qr` no longer mutate the instance. `#params` is
|
|
27
|
+
frozen, calls no longer leak into one another, and instances can be shared.
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Validation of every field against the specification: payee address format,
|
|
31
|
+
four-digit ISO 18245 merchant code, `INR`-only currency, 50-character note
|
|
32
|
+
limit, 35-character alphanumeric references, and `http(s)`-only URLs.
|
|
33
|
+
- `transaction_ref_id` is now required for merchant transactions carrying an
|
|
34
|
+
amount, as the specification mandates, and `Upi::Generator.generate_reference`
|
|
35
|
+
builds a unique one. A reused reference is rejected by PSPs as a duplicate,
|
|
36
|
+
which is why a QR with a hard-coded reference works once and then stops.
|
|
37
|
+
- `min_amount:` for the `mam` tag, which lets the payer edit the amount down to a
|
|
38
|
+
floor.
|
|
39
|
+
- `initiation_mode:` for the `mode` tag, with `MODE_QR` and friends. Opt-in.
|
|
40
|
+
- `level:` and renderer options on `#generate_qr`.
|
|
41
|
+
- `Upi::ValidationError`.
|
|
42
|
+
|
|
43
|
+
### Changed
|
|
44
|
+
- Parameters are emitted in the order used by the specification's examples:
|
|
45
|
+
`pa, pn, mc, tid, tr, tn, am, mam, cu, url, mode`.
|
|
46
|
+
- `no_url_parse: false` now means "leave URL-safe punctuation readable" rather
|
|
47
|
+
than "do not escape anything"; separators are always escaped in both modes.
|
|
48
|
+
- `#params` returns the frozen constructor parameters, not the last call's state.
|
|
49
|
+
- QR error correction defaults to `:m` rather than rqrcode's `:h`, giving larger
|
|
50
|
+
modules at the same image size.
|
|
51
|
+
|
|
52
|
+
## [2.0.2] - 2024-09-08
|
|
53
|
+
- For Individual Mode there need not be merchant code being sent. Removed default '0000'. Can be still added explicitly.
|
|
54
|
+
- Changed the upi_content method to not parse the upi address and keep it as it is.
|
|
55
|
+
- Add support for no uri parse.
|
|
56
|
+
|
|
57
|
+
## [2.0.1] - 2024-09-06
|
|
58
|
+
- Updated Dependencies
|
|
2
59
|
|
|
3
60
|
## [2.0.0] - 2024-09-06
|
|
4
61
|
- Add support for changing amounts after initialization
|
data/Gemfile.lock
CHANGED
data/README.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# UPI
|
|
2
2
|
|
|
3
|
-
The `upi` gem
|
|
3
|
+
The `upi` gem generates UPI payment URIs and QR codes, in PNG or SVG, that conform to the
|
|
4
|
+
[NPCI UPI Linking Specification](https://www.npci.org.in/what-we-do/upi/product-overview)
|
|
5
|
+
(common URL specification for deep linking and proximity integration).
|
|
4
6
|
|
|
5
7
|
## Installation
|
|
6
8
|
|
|
@@ -20,14 +22,12 @@ Or install it yourself as:
|
|
|
20
22
|
|
|
21
23
|
## Usage
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
+
Initialize `Upi::Generator` with the payee details, then call `generate_qr` for a QR code or
|
|
26
|
+
`upi_content` for the raw payment URI. Generator instances are immutable and safe to reuse
|
|
27
|
+
and share across threads.
|
|
25
28
|
|
|
26
|
-
###
|
|
29
|
+
### Individual mode (P2P)
|
|
27
30
|
|
|
28
|
-
Individual Mode:
|
|
29
|
-
|
|
30
|
-
### Create a new UPI QR code generator instance
|
|
31
31
|
```ruby
|
|
32
32
|
require 'upi'
|
|
33
33
|
|
|
@@ -35,105 +35,207 @@ generator = Upi::Generator.new(
|
|
|
35
35
|
upi_id: 'test@upi',
|
|
36
36
|
name: 'Test Name'
|
|
37
37
|
)
|
|
38
|
+
```
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Do not pass `merchant_code` for individual payments; it is omitted entirely.
|
|
40
41
|
|
|
41
|
-
|
|
42
|
+
#### QR code in SVG format
|
|
42
43
|
|
|
43
|
-
### Generate QR code in SVG format
|
|
44
44
|
```ruby
|
|
45
45
|
svg_content = generator.generate_qr(100, 'Personal Payment', mode: :svg)
|
|
46
46
|
File.write('qr_code.svg', svg_content)
|
|
47
47
|
```
|
|
48
48
|
|
|
49
|
-
|
|
49
|
+
#### QR code in PNG format
|
|
50
|
+
|
|
50
51
|
```ruby
|
|
51
52
|
png_content = generator.generate_qr(100, 'Personal Payment', mode: :png)
|
|
52
53
|
File.binwrite('qr_code.png', png_content)
|
|
53
54
|
```
|
|
54
55
|
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
#### Payment URI
|
|
57
|
+
|
|
58
|
+
`upi_content` returns a UPI URI you can use as the `href` of a "Pay Now" link:
|
|
57
59
|
|
|
58
60
|
```ruby
|
|
59
|
-
# Generate UPI payment URL
|
|
60
61
|
payment_url = generator.upi_content(100, 'Personal Payment')
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
# The generate_url method returns a UPI URI string that can be used as a link in your HTML:
|
|
62
|
+
# => "upi://pay?pa=test@upi&pn=Test%20Name&tn=Personal%20Payment&am=100.00&cu=INR"
|
|
64
63
|
```
|
|
65
64
|
|
|
66
65
|
```html
|
|
67
66
|
<a href="<%= payment_url %>">Pay Now</a>
|
|
68
67
|
```
|
|
69
68
|
|
|
70
|
-
|
|
69
|
+
#### Letting the payer choose the amount
|
|
70
|
+
|
|
71
|
+
Omit the amount and the `am` tag is left out, so the payer types their own. Never pass `0` for
|
|
72
|
+
this — UPI apps reject `am=0`, and the gem raises rather than emit it.
|
|
71
73
|
|
|
72
|
-
### Create a new UPI QR code generator instance
|
|
73
74
|
```ruby
|
|
74
|
-
|
|
75
|
+
generator.upi_content
|
|
76
|
+
# => "upi://pay?pa=test@upi&pn=Test%20Name&cu=INR"
|
|
77
|
+
```
|
|
75
78
|
|
|
79
|
+
### Merchant mode (P2M)
|
|
80
|
+
|
|
81
|
+
Pass a four-digit [ISO 18245](https://en.wikipedia.org/wiki/Merchant_category_code) merchant
|
|
82
|
+
category code:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
76
85
|
generator = Upi::Generator.new(
|
|
77
|
-
upi_id: '
|
|
86
|
+
upi_id: 'merchant@upi',
|
|
78
87
|
name: 'Test Name',
|
|
79
|
-
merchant_code: '
|
|
80
|
-
|
|
88
|
+
merchant_code: '5411'
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A merchant payment that carries an amount **must** also carry a transaction reference (`tr`),
|
|
93
|
+
and that reference **must be unique for every payment attempt**. PSPs reject a reference they
|
|
94
|
+
have already seen as a duplicate, so a QR code with a hard-coded reference works once and then
|
|
95
|
+
silently stops. `Upi::Generator.generate_reference` builds a suitable one:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
reference = Upi::Generator.generate_reference('ORD') # => "ORD20260917131656E4BBC91A"
|
|
99
|
+
|
|
100
|
+
svg_content = generator.generate_qr(
|
|
101
|
+
500, 'Payment for Goods',
|
|
102
|
+
transaction_ref_id: reference,
|
|
103
|
+
transaction_id: 'TXN456',
|
|
104
|
+
url: 'https://merchant.com/payment',
|
|
105
|
+
mode: :svg
|
|
81
106
|
)
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
In practice the reference is your own order or invoice number, which is what makes
|
|
110
|
+
reconciliation work:
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
generator.upi_content(500, 'Payment for Goods', transaction_ref_id: "ORD#{order.id}")
|
|
114
|
+
```
|
|
82
115
|
|
|
83
|
-
|
|
116
|
+
Leaving the amount out produces a static "shop counter" QR, which needs no reference because
|
|
117
|
+
the payer supplies a fresh one each time:
|
|
84
118
|
|
|
119
|
+
```ruby
|
|
120
|
+
generator.upi_content
|
|
121
|
+
# => "upi://pay?pa=merchant@upi&pn=Test%20Name&mc=5411&cu=INR"
|
|
85
122
|
```
|
|
86
123
|
|
|
87
|
-
###
|
|
124
|
+
### Letting the payer pay at least a minimum
|
|
125
|
+
|
|
126
|
+
`min_amount` maps to the `mam` tag: the amount becomes editable, down to that floor.
|
|
127
|
+
|
|
88
128
|
```ruby
|
|
89
|
-
|
|
90
|
-
|
|
129
|
+
generator.upi_content(500, 'Donation', transaction_ref_id: 'ORD90211', min_amount: 100)
|
|
130
|
+
# => "upi://pay?pa=merchant@upi&pn=Test%20Name&mc=5411&tr=ORD90211&tn=Donation&am=500.00&mam=100.00&cu=INR"
|
|
91
131
|
```
|
|
92
132
|
|
|
93
|
-
|
|
133
|
+
## Parameters
|
|
134
|
+
|
|
135
|
+
### `Upi::Generator.new`
|
|
136
|
+
|
|
137
|
+
| Parameter | Required | Notes |
|
|
138
|
+
| --- | --- | --- |
|
|
139
|
+
| `upi_id:` | yes | Payee VPA, `name@psp`. Max 255 characters. |
|
|
140
|
+
| `name:` | yes | Payee name shown on the payer's confirmation screen. Max 99 characters. |
|
|
141
|
+
| `currency:` | no | Defaults to `'INR'`, the only value UPI supports. |
|
|
142
|
+
| `merchant_code:` | no | Four-digit ISO 18245 category code. Its presence selects merchant mode. |
|
|
143
|
+
| `no_url_parse:` | no | Encoding strictness, see [Encoding](#encoding). Defaults to `true`. |
|
|
144
|
+
|
|
145
|
+
### `#upi_content` and `#generate_qr`
|
|
146
|
+
|
|
147
|
+
Both take the same payment arguments; `generate_qr` adds the rendering ones.
|
|
148
|
+
|
|
149
|
+
| Parameter | Notes |
|
|
150
|
+
| --- | --- |
|
|
151
|
+
| `amount` | First positional. Omit to let the payer choose. Max two decimal places. |
|
|
152
|
+
| `note` | Second positional. Maps to `tn`. Max 50 characters. |
|
|
153
|
+
| `transaction_ref_id:` | `tr`. Mandatory for merchant payments with an amount. Max 35 alphanumeric characters. |
|
|
154
|
+
| `transaction_id:` | `tid`, PSP-generated when present. Max 35 alphanumeric characters. |
|
|
155
|
+
| `url:` | `http(s)` URL with further transaction detail. |
|
|
156
|
+
| `min_amount:` | `mam`. Makes the amount editable down to this floor. |
|
|
157
|
+
| `initiation_mode:` | `mode`. Opt-in, see [Initiation mode](#initiation-mode). |
|
|
158
|
+
| `mode:` | `generate_qr` only. `:svg` (default) or `:png`. |
|
|
159
|
+
| `level:` | `generate_qr` only. QR error correction: `:l`, `:m` (default), `:q`, `:h`. |
|
|
160
|
+
|
|
161
|
+
Any other keyword to `generate_qr` is passed through to the renderer, so
|
|
162
|
+
`module_px_size:`, `border_modules:`, `color:` and the rest of the
|
|
163
|
+
[rqrcode](https://github.com/whomwah/rqrcode) options still work:
|
|
164
|
+
|
|
94
165
|
```ruby
|
|
95
|
-
|
|
96
|
-
File.binwrite('qr_code_merchant.png', png_content)
|
|
166
|
+
generator.generate_qr(100, 'Tea', mode: :png, module_px_size: 12, color: '333')
|
|
97
167
|
```
|
|
98
168
|
|
|
99
|
-
|
|
100
|
-
You can generate a UPI payment URL suitable for use in HTML links by using the generate_url method. This URL can be used as the href attribute in a "Pay Now" button or link.
|
|
169
|
+
## Amounts
|
|
101
170
|
|
|
171
|
+
Amounts are normalised to the two-decimal format UPI requires, and anything that cannot be
|
|
172
|
+
represented that way raises rather than producing an unpayable QR code.
|
|
102
173
|
|
|
103
174
|
```ruby
|
|
104
|
-
#
|
|
105
|
-
|
|
106
|
-
|
|
175
|
+
generator.upi_content(100) # => "...&am=100.00&..."
|
|
176
|
+
generator.upi_content(1499.5) # => "...&am=1499.50&..."
|
|
177
|
+
generator.upi_content(0.1 + 0.2) # => "...&am=0.30&..." float noise is discarded
|
|
178
|
+
generator.upi_content(BigDecimal('99.9'))
|
|
107
179
|
|
|
108
|
-
#
|
|
180
|
+
generator.upi_content(100.456) # raises Upi::ValidationError, more than two decimals
|
|
181
|
+
generator.upi_content(0) # raises Upi::ValidationError, UPI apps reject am=0
|
|
109
182
|
```
|
|
110
183
|
|
|
111
|
-
|
|
112
|
-
|
|
184
|
+
Pass a `String` or `BigDecimal` when the amount comes from a currency column, so no float ever
|
|
185
|
+
touches it.
|
|
186
|
+
|
|
187
|
+
## Validation
|
|
188
|
+
|
|
189
|
+
Every field is checked against the specification before a URI is built, and a failure raises
|
|
190
|
+
`Upi::ValidationError` (a subclass of `Upi::Error`). This is deliberate: an invalid UPI URI
|
|
191
|
+
still produces a perfectly scannable QR code, so without validation the only symptom is a
|
|
192
|
+
payment that fails later, in the payer's app.
|
|
193
|
+
|
|
194
|
+
```ruby
|
|
195
|
+
Upi::Generator.new(upi_id: 'not-a-vpa', name: 'X')
|
|
196
|
+
# Upi::ValidationError: upi_id "not-a-vpa" is not a valid UPI address (expected 'name@psp')
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Encoding
|
|
200
|
+
|
|
201
|
+
Values are percent-encoded per RFC 3986, with spaces as `%20`, matching the example URIs in
|
|
202
|
+
the specification. `no_url_parse` selects how aggressive that is:
|
|
203
|
+
|
|
204
|
+
* `true` (default) encodes down to the unreserved set.
|
|
205
|
+
* `false` leaves URL-safe punctuation such as `:` and `/` readable.
|
|
206
|
+
|
|
207
|
+
Either way the characters that would corrupt the query string — `&`, `#`, `%`, `+` and space —
|
|
208
|
+
are always escaped, so a note like `Tea & Coffee #12` can never truncate the URI or forge
|
|
209
|
+
another parameter. Redirect URLs keep their `://` readable in both modes.
|
|
210
|
+
|
|
211
|
+
## Initiation mode
|
|
212
|
+
|
|
213
|
+
The specification lists a `mode` tag (`01` for QR, `04` for intent). It is left out by default,
|
|
214
|
+
since most PSP apps accept a URI without it and existing integrations rely on that. Opt in when
|
|
215
|
+
you want it:
|
|
216
|
+
|
|
217
|
+
```ruby
|
|
218
|
+
generator.upi_content(500, 'Goods', transaction_ref_id: 'ORD1',
|
|
219
|
+
initiation_mode: Upi::Generator::MODE_QR)
|
|
220
|
+
# => "...&cu=INR&mode=01"
|
|
113
221
|
```
|
|
114
222
|
|
|
115
|
-
|
|
223
|
+
Signed QR codes (`mode=02`, requiring the `sign` and `orgid` tags) are not supported.
|
|
116
224
|
|
|
117
|
-
|
|
118
|
-
* Replace `'Test Name'` with the recipient's name.
|
|
119
|
-
* The `amount` parameter specifies the payment amount.
|
|
120
|
-
* `note` is an optional field to include additional information.
|
|
121
|
-
Parameters
|
|
122
|
-
* upi_id: The UPI ID of the recipient.
|
|
123
|
-
* name: The name of the recipient.
|
|
124
|
-
* amount: The amount for the payment (required for UPI transactions).
|
|
125
|
-
* currency: Currency code (default is 'INR').
|
|
126
|
-
* note: Additional note or description for the payment.
|
|
127
|
-
* merchant_code: Optional merchant code.
|
|
128
|
-
* transaction_ref_id: Optional transaction reference ID.
|
|
129
|
-
* transaction_id: Optional transaction ID.
|
|
130
|
-
* url: Optional URL for additional information or payment redirect.
|
|
225
|
+
## Upgrading from 2.x
|
|
131
226
|
|
|
132
|
-
|
|
133
|
-
|
|
227
|
+
3.0 changes the generated URI so that it complies with the specification. See the
|
|
228
|
+
[CHANGELOG](CHANGELOG.md) for the full list. The changes most likely to affect you:
|
|
134
229
|
|
|
135
|
-
*
|
|
136
|
-
|
|
230
|
+
* Spaces encode as `%20`, not `+`, and parameters are emitted in the specification's order, so
|
|
231
|
+
any test asserting an exact URI string needs updating.
|
|
232
|
+
* Amounts always carry two decimals: `am=100` is now `am=100.00`.
|
|
233
|
+
* Calling without an amount no longer emits `am=0&tn=`; both tags are omitted.
|
|
234
|
+
* Invalid input now raises `Upi::ValidationError` instead of silently producing a broken QR
|
|
235
|
+
code. Merchant payments with an amount now require `transaction_ref_id`.
|
|
236
|
+
* `#params` returns the frozen constructor parameters rather than the last call's state.
|
|
237
|
+
* PNG output is sized by module rather than pinned to 300px, so images vary in size with the
|
|
238
|
+
payload. Pass `size:` or `module_px_size:` to control it.
|
|
137
239
|
|
|
138
240
|
## Development
|
|
139
241
|
|
|
@@ -143,7 +245,7 @@ To install this gem onto your local machine, run `bundle exec rake install`. To
|
|
|
143
245
|
|
|
144
246
|
## Contributing
|
|
145
247
|
|
|
146
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/stingrayzboy/upi. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/
|
|
248
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/stingrayzboy/upi. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/stingrayzboy/upi/blob/master/CODE_OF_CONDUCT.md).
|
|
147
249
|
|
|
148
250
|
## License
|
|
149
251
|
|
data/lib/upi/version.rb
CHANGED
data/lib/upi.rb
CHANGED
|
@@ -2,113 +2,366 @@
|
|
|
2
2
|
|
|
3
3
|
require 'rqrcode'
|
|
4
4
|
require 'chunky_png'
|
|
5
|
-
require '
|
|
5
|
+
require 'bigdecimal'
|
|
6
|
+
require 'securerandom'
|
|
6
7
|
require_relative 'upi/version'
|
|
7
8
|
|
|
8
9
|
module Upi
|
|
9
|
-
#
|
|
10
|
+
# Base error class for the gem.
|
|
11
|
+
class Error < StandardError; end
|
|
12
|
+
|
|
13
|
+
# Raised when the supplied payment details cannot produce a spec-compliant UPI URI.
|
|
10
14
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
15
|
+
# Failing loudly at generation time is deliberate: an invalid UPI URI still
|
|
16
|
+
# produces a perfectly scannable QR code, so the only symptom is a payment
|
|
17
|
+
# that fails in the payer's app, long after the QR was handed out.
|
|
18
|
+
class ValidationError < Error; end
|
|
19
|
+
|
|
20
|
+
# Builds UPI payment URIs and QR codes that conform to the NPCI "UPI Linking
|
|
21
|
+
# Specifications" (common URL specification for deep linking and proximity
|
|
22
|
+
# integration, v1.6).
|
|
13
23
|
#
|
|
14
|
-
#
|
|
24
|
+
# The canonical example from that document is the shape this class targets:
|
|
15
25
|
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
# name: 'Test Name'
|
|
19
|
-
# )
|
|
26
|
+
# upi://pay?pa=nadeem@npci&pn=nadeem%20chinna&mc=0000&tid=...&tr=...
|
|
27
|
+
# &tn=Pay%20to%20mystar%20store&am=10&mam=null&cu=INR&url=https://...
|
|
20
28
|
#
|
|
21
|
-
#
|
|
22
|
-
# png_content = generator.generate_qr(100, 'Personal Payment', mode: :png)
|
|
23
|
-
# payment_url = generator.upi_content(100, 'Personal Payment')
|
|
29
|
+
# Example usage:
|
|
24
30
|
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
-
#
|
|
28
|
-
#
|
|
29
|
-
# - `currency`: The currency code (default is 'INR').
|
|
30
|
-
# - `note`: An optional note or description for the payment.
|
|
31
|
-
# - `merchant_code`: An optional merchant code.
|
|
32
|
-
# - `transaction_ref_id`: An optional transaction reference ID.
|
|
33
|
-
# - `transaction_id`: An optional transaction ID.
|
|
34
|
-
# - `url`: An optional URL for additional information or payment redirect.
|
|
31
|
+
# generator = Upi::Generator.new(upi_id: 'test@upi', name: 'Test Name')
|
|
32
|
+
# svg = generator.generate_qr(100, 'Personal Payment', mode: :svg)
|
|
33
|
+
# png = generator.generate_qr(100, 'Personal Payment', mode: :png)
|
|
34
|
+
# uri = generator.upi_content(100, 'Personal Payment')
|
|
35
35
|
#
|
|
36
|
-
#
|
|
37
|
-
# generator = Upi::Generator.new(
|
|
38
|
-
# upi_id: 'test@upi',
|
|
39
|
-
# name: 'Test Name'
|
|
40
|
-
# )
|
|
41
|
-
# svg_content = generator.generate_qr(100, 'Personal Payment', mode: :svg)
|
|
42
|
-
# File.write('qr_code.svg', svg_content)
|
|
36
|
+
# Instances are immutable and safe to share between threads.
|
|
43
37
|
#
|
|
44
|
-
# @see https://www.npci.org.in/what-we-do/upi/product-overview
|
|
38
|
+
# @see https://www.npci.org.in/what-we-do/upi/product-overview
|
|
45
39
|
class Generator
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
40
|
+
# Currently the only currency UPI supports.
|
|
41
|
+
CURRENCY = 'INR'
|
|
42
|
+
|
|
43
|
+
# Transaction initiation modes from the specification's `mode` tag.
|
|
44
|
+
MODE_DEFAULT = '00'
|
|
45
|
+
MODE_QR = '01'
|
|
46
|
+
MODE_SECURE_QR = '02'
|
|
47
|
+
MODE_INTENT = '04'
|
|
48
|
+
|
|
49
|
+
# Field limits. `pa`/`pn` follow the NPCI field sizes; `tn` is the widely
|
|
50
|
+
# enforced 50-character transaction note limit; `tr`/`tid` are kept inside
|
|
51
|
+
# the shortest limit PSPs are known to apply.
|
|
52
|
+
MAX_LENGTHS = { pa: 255, pn: 99, tn: 50, tr: 35, tid: 35 }.freeze
|
|
53
|
+
|
|
54
|
+
# A virtual payment address: `local@psp`.
|
|
55
|
+
VPA_PATTERN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,}@[A-Za-z][A-Za-z0-9.-]{0,}\z/.freeze
|
|
56
|
+
|
|
57
|
+
# Merchant category code, ISO 18245: exactly four digits.
|
|
58
|
+
MERCHANT_CODE_PATTERN = /\A\d{4}\z/.freeze
|
|
59
|
+
|
|
60
|
+
# Reference and transaction ids must be alphanumeric with no spaces.
|
|
61
|
+
REFERENCE_PATTERN = /\A[A-Za-z0-9._-]+\z/.freeze
|
|
62
|
+
|
|
63
|
+
NUMERIC_PATTERN = /\A\d+(\.\d+)?\z/.freeze
|
|
64
|
+
|
|
65
|
+
# Largest amount we will emit. Per-transaction ceilings are set by the bank
|
|
66
|
+
# and the merchant category, so this only catches obviously bad input.
|
|
67
|
+
MAX_AMOUNT = BigDecimal('10000000000')
|
|
68
|
+
|
|
69
|
+
# RFC 3986 unreserved set. Everything else is percent-encoded, which is what
|
|
70
|
+
# makes a space `%20` rather than `+`.
|
|
71
|
+
STRICT_UNSAFE = /[^A-Za-z0-9\-._~]/.freeze
|
|
72
|
+
|
|
73
|
+
# Leaves characters that are legal inside a query value untouched, so a
|
|
74
|
+
# redirect URL stays readable as `https://host/path` the way the NPCI
|
|
75
|
+
# examples show it. `&`, `#`, `%`, `+` and space are still escaped, so a
|
|
76
|
+
# value can never break out and forge another parameter.
|
|
77
|
+
LENIENT_UNSAFE = %r{[^A-Za-z0-9\-._~:/?=@!$'()*,;]}.freeze
|
|
78
|
+
|
|
79
|
+
# Emission order follows the example URIs in the specification.
|
|
80
|
+
PARAM_ORDER = %i[pa pn mc tid tr tn am mam cu url mode].freeze
|
|
81
|
+
|
|
82
|
+
# Renderer options accepted by rqrcode, used to reject a mistyped payment
|
|
83
|
+
# keyword rather than let it fall through to the renderer and quietly drop a
|
|
84
|
+
# field from the QR code.
|
|
85
|
+
SVG_OPTIONS = %i[fill use_path offset color shape_rendering module_size standalone
|
|
86
|
+
viewbox svg_attributes].freeze
|
|
87
|
+
PNG_OPTIONS = %i[bit_depth border_modules color_mode color file fill module_px_size
|
|
88
|
+
resize_exactly_to resize_gte_to size].freeze
|
|
89
|
+
|
|
90
|
+
attr_reader :params, :no_url_parse
|
|
91
|
+
|
|
92
|
+
# @param upi_id [String] payee VPA, e.g. `'merchant@upi'`
|
|
93
|
+
# @param name [String] payee name shown on the payer's confirmation screen
|
|
94
|
+
# @param currency [String] ISO 4217 code; UPI only supports `'INR'`
|
|
95
|
+
# @param merchant_code [String, nil] four-digit ISO 18245 merchant category
|
|
96
|
+
# code. Supplying it puts the generator in merchant (P2M) mode.
|
|
97
|
+
# @param no_url_parse [Boolean] `true` (default) percent-encodes values down
|
|
98
|
+
# to the RFC 3986 unreserved set; `false` leaves URL-safe punctuation
|
|
99
|
+
# readable. Both modes always escape the characters that would otherwise
|
|
100
|
+
# corrupt the query string.
|
|
101
|
+
def initialize(upi_id:, name:, currency: CURRENCY, merchant_code: nil, no_url_parse: true)
|
|
102
|
+
@upi_id = validate_vpa!(upi_id)
|
|
103
|
+
@name = validate_text!(name, :pn, 'name')
|
|
104
|
+
@currency = validate_currency!(currency)
|
|
105
|
+
@merchant_code = validate_merchant_code!(merchant_code)
|
|
106
|
+
@no_url_parse = no_url_parse
|
|
107
|
+
@params = { pa: @upi_id, pn: @name, mc: @merchant_code, cu: @currency }.compact.freeze
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Generates a unique transaction reference suitable for the `tr` field.
|
|
111
|
+
#
|
|
112
|
+
# A `tr` must be unique per payment attempt: PSPs reject a repeat of one
|
|
113
|
+
# they have already seen, which is why a QR carrying a hard-coded reference
|
|
114
|
+
# works once and then quietly stops.
|
|
115
|
+
#
|
|
116
|
+
# @param prefix [String] alphanumeric prefix to make references recognisable
|
|
117
|
+
# @return [String]
|
|
118
|
+
def self.generate_reference(prefix = 'UPI')
|
|
119
|
+
raise ValidationError, "reference prefix #{prefix.inspect} must be alphanumeric" unless prefix.to_s.match?(REFERENCE_PATTERN)
|
|
120
|
+
|
|
121
|
+
"#{prefix}#{Time.now.strftime("%Y%m%d%H%M%S")}#{SecureRandom.hex(4).upcase}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Builds the `upi://pay?...` URI.
|
|
125
|
+
#
|
|
126
|
+
# @param amount [Numeric, String, nil] payment amount. Omit it (or pass nil)
|
|
127
|
+
# to let the payer type an amount; UPI apps reject `am=0`.
|
|
128
|
+
# @param note [String, nil] transaction note, max 50 characters
|
|
129
|
+
# @param transaction_ref_id [String, nil] `tr`. Mandatory for merchant
|
|
130
|
+
# transactions carrying an amount, and must be unique per attempt.
|
|
131
|
+
# @param transaction_id [String, nil] `tid`, PSP-generated when present
|
|
132
|
+
# @param url [String, nil] `http`/`https` transaction detail URL
|
|
133
|
+
# @param min_amount [Numeric, String, nil] `mam`. When given, the payer can
|
|
134
|
+
# edit the amount down to this floor.
|
|
135
|
+
# @param initiation_mode [String, nil] `mode` tag, e.g. {MODE_QR}
|
|
136
|
+
# @return [String]
|
|
137
|
+
def upi_content(amount = nil, note = nil, transaction_ref_id: nil, transaction_id: nil,
|
|
138
|
+
url: nil, min_amount: nil, initiation_mode: nil)
|
|
139
|
+
fields = build_fields(amount, note, transaction_ref_id, transaction_id, url,
|
|
140
|
+
min_amount, initiation_mode)
|
|
141
|
+
query = PARAM_ORDER.map do |key|
|
|
142
|
+
next if key == :pa
|
|
143
|
+
|
|
144
|
+
value = fields[key]
|
|
145
|
+
"#{key}=#{encode(value, lenient: key == :url)}" unless value.nil?
|
|
146
|
+
end.compact
|
|
147
|
+
|
|
148
|
+
"upi://pay?pa=#{fields[:pa]}&#{query.join("&")}"
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Renders the payment URI as a QR code.
|
|
152
|
+
#
|
|
153
|
+
# @param mode [Symbol] `:svg` (default) or `:png` output format
|
|
154
|
+
# @param level [Symbol] QR error correction level, `:l`, `:m`, `:q` or `:h`
|
|
155
|
+
# @param render_options [Hash] passed through to the rqrcode renderer,
|
|
156
|
+
# overriding the defaults (e.g. `module_px_size:`, `module_size:`)
|
|
157
|
+
# @return [String] SVG markup, or raw PNG bytes
|
|
158
|
+
# @see #upi_content for the payment parameters
|
|
159
|
+
def generate_qr(amount = nil, note = nil, transaction_ref_id: nil, transaction_id: nil,
|
|
160
|
+
url: nil, min_amount: nil, initiation_mode: nil,
|
|
161
|
+
mode: :svg, level: :m, **render_options)
|
|
162
|
+
raise ArgumentError, "Unsupported mode: #{mode}. Use :svg or :png." unless %i[svg png].include?(mode)
|
|
163
|
+
|
|
164
|
+
validate_render_options!(render_options, mode)
|
|
165
|
+
content = upi_content(amount, note, transaction_ref_id: transaction_ref_id,
|
|
166
|
+
transaction_id: transaction_id, url: url,
|
|
167
|
+
min_amount: min_amount, initiation_mode: initiation_mode)
|
|
168
|
+
qrcode = RQRCode::QRCode.new(content, level: level)
|
|
169
|
+
|
|
170
|
+
mode == :svg ? render_svg(qrcode, render_options) : render_png(qrcode, render_options)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
private
|
|
174
|
+
|
|
175
|
+
def build_fields(amount, note, transaction_ref_id, transaction_id, url, min_amount, initiation_mode)
|
|
176
|
+
normalized_amount = normalize_amount(amount, 'amount')
|
|
177
|
+
reference = validate_reference!(transaction_ref_id, :tr, 'transaction_ref_id')
|
|
178
|
+
require_reference!(reference, normalized_amount)
|
|
179
|
+
|
|
180
|
+
params.merge(
|
|
181
|
+
tid: validate_reference!(transaction_id, :tid, 'transaction_id'),
|
|
182
|
+
tr: reference,
|
|
183
|
+
tn: presence(validate_text!(note, :tn, 'note')),
|
|
184
|
+
am: normalized_amount,
|
|
185
|
+
mam: normalize_min_amount(min_amount, normalized_amount),
|
|
186
|
+
url: validate_url!(url),
|
|
187
|
+
mode: validate_initiation_mode!(initiation_mode)
|
|
188
|
+
)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# The specification marks `tr` mandatory for merchant transactions and for
|
|
192
|
+
# dynamic (amount-bearing) URLs. Without it a PSP's risk engine sees an
|
|
193
|
+
# amount-carrying merchant payload with nothing to reconcile against, and
|
|
194
|
+
# rejects it behind a generic error.
|
|
195
|
+
def require_reference!(reference, amount)
|
|
196
|
+
return if reference || @merchant_code.nil? || amount.nil?
|
|
197
|
+
|
|
198
|
+
raise ValidationError,
|
|
199
|
+
'transaction_ref_id is mandatory for merchant transactions with an amount, ' \
|
|
200
|
+
'and must be unique per payment attempt. ' \
|
|
201
|
+
'Use Upi::Generator.generate_reference to build one.'
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def validate_vpa!(upi_id)
|
|
205
|
+
value = upi_id.to_s.strip
|
|
206
|
+
raise ValidationError, 'upi_id is required' if value.empty?
|
|
207
|
+
|
|
208
|
+
raise ValidationError, "upi_id must be at most #{MAX_LENGTHS[:pa]} characters" if value.length > MAX_LENGTHS[:pa]
|
|
209
|
+
raise ValidationError, "upi_id #{upi_id.inspect} is not a valid UPI address (expected 'name@psp')" unless value.match?(VPA_PATTERN)
|
|
210
|
+
|
|
211
|
+
value
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
def validate_text!(text, key, label)
|
|
215
|
+
return nil if text.nil?
|
|
216
|
+
|
|
217
|
+
value = text.to_s.tr("\r\n\t", ' ').strip
|
|
218
|
+
if value.length > MAX_LENGTHS[key]
|
|
219
|
+
raise ValidationError,
|
|
220
|
+
"#{label} must be at most #{MAX_LENGTHS[key]} characters, got #{value.length}"
|
|
221
|
+
end
|
|
222
|
+
raise ValidationError, "#{label} is required" if key == :pn && value.empty?
|
|
223
|
+
|
|
224
|
+
value
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def validate_currency!(currency)
|
|
228
|
+
value = currency.to_s.strip.upcase
|
|
229
|
+
raise ValidationError, "currency #{currency.inspect} is not supported; UPI only supports 'INR'" unless value == CURRENCY
|
|
230
|
+
|
|
231
|
+
value
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def validate_merchant_code!(merchant_code)
|
|
235
|
+
return nil if merchant_code.nil?
|
|
236
|
+
|
|
237
|
+
value = merchant_code.to_s.strip
|
|
238
|
+
unless value.match?(MERCHANT_CODE_PATTERN)
|
|
239
|
+
raise ValidationError,
|
|
240
|
+
"merchant_code #{merchant_code.inspect} must be a four-digit ISO 18245 category code"
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
value
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def validate_reference!(reference, key, label)
|
|
247
|
+
return nil if reference.nil?
|
|
248
|
+
|
|
249
|
+
value = reference.to_s.strip
|
|
250
|
+
return nil if value.empty?
|
|
251
|
+
|
|
252
|
+
raise ValidationError, "#{label} must be at most #{MAX_LENGTHS[key]} characters, got #{value.length}" if value.length > MAX_LENGTHS[key]
|
|
253
|
+
raise ValidationError, "#{label} #{reference.inspect} must be alphanumeric with no spaces" unless value.match?(REFERENCE_PATTERN)
|
|
254
|
+
|
|
255
|
+
value
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def validate_url!(url)
|
|
259
|
+
return nil if url.nil?
|
|
260
|
+
|
|
261
|
+
value = url.to_s.strip
|
|
262
|
+
return nil if value.empty?
|
|
263
|
+
raise ValidationError, "url #{url.inspect} must start with http:// or https://" unless value.start_with?('http://', 'https://')
|
|
264
|
+
|
|
265
|
+
value
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def validate_initiation_mode!(initiation_mode)
|
|
269
|
+
return nil if initiation_mode.nil?
|
|
270
|
+
|
|
271
|
+
value = initiation_mode.to_s
|
|
272
|
+
raise ValidationError, "initiation_mode #{initiation_mode.inspect} must be two digits" unless value.match?(/\A\d{2}\z/)
|
|
273
|
+
|
|
274
|
+
value
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def normalize_min_amount(min_amount, amount)
|
|
278
|
+
normalized = normalize_amount(min_amount, 'min_amount')
|
|
279
|
+
return nil if normalized.nil?
|
|
280
|
+
|
|
281
|
+
raise ValidationError, "min_amount #{normalized} cannot exceed amount #{amount}" if amount && BigDecimal(normalized) > BigDecimal(amount)
|
|
282
|
+
|
|
283
|
+
normalized
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# UPI amounts are decimal with at most two places. Emitting anything else --
|
|
287
|
+
# `am=0`, `am=1499.5`, or the `0.30000000000000004` a float sum produces --
|
|
288
|
+
# is rejected by PSP apps, so normalize here and reject what cannot be
|
|
289
|
+
# represented rather than silently rounding someone's money.
|
|
290
|
+
def normalize_amount(amount, label)
|
|
291
|
+
return nil if amount.nil?
|
|
292
|
+
return nil if amount.is_a?(String) && amount.strip.empty?
|
|
293
|
+
|
|
294
|
+
decimal = to_decimal(amount, label)
|
|
295
|
+
raise ValidationError, "#{label} must be greater than zero, got #{amount.inspect}" unless decimal.positive?
|
|
296
|
+
raise ValidationError, "#{label} #{amount.inspect} is too large" if decimal > MAX_AMOUNT
|
|
297
|
+
|
|
298
|
+
if decimal != decimal.round(2)
|
|
299
|
+
raise ValidationError,
|
|
300
|
+
"#{label} #{amount.inspect} has more than two decimal places; " \
|
|
301
|
+
'UPI amounts allow at most two. Round it before passing it in.'
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
whole, fraction = decimal.to_s('F').split('.')
|
|
305
|
+
"#{whole}.#{fraction.to_s.ljust(2, "0")[0, 2]}"
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def to_decimal(amount, label)
|
|
309
|
+
case amount
|
|
310
|
+
when BigDecimal then amount
|
|
311
|
+
when Integer then BigDecimal(amount)
|
|
312
|
+
# 15 significant digits discards IEEE representation noise (0.1 + 0.2)
|
|
313
|
+
# while preserving precision the caller actually meant.
|
|
314
|
+
when Float, Rational then BigDecimal(amount, 15)
|
|
315
|
+
when String
|
|
316
|
+
value = amount.strip
|
|
317
|
+
raise ValidationError, "#{label} #{amount.inspect} is not a valid number" unless value.match?(NUMERIC_PATTERN)
|
|
318
|
+
|
|
319
|
+
BigDecimal(value)
|
|
88
320
|
else
|
|
89
|
-
raise
|
|
321
|
+
raise ValidationError, "#{label} must be Numeric or String, got #{amount.class}"
|
|
90
322
|
end
|
|
91
323
|
end
|
|
92
324
|
|
|
93
|
-
def
|
|
94
|
-
|
|
325
|
+
def presence(value)
|
|
326
|
+
value unless value.nil? || value.empty?
|
|
327
|
+
end
|
|
95
328
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
329
|
+
def encode(value, lenient: false)
|
|
330
|
+
pattern = lenient || !no_url_parse ? LENIENT_UNSAFE : STRICT_UNSAFE
|
|
331
|
+
value.to_s.gsub(pattern) { |char| char.bytes.map { |byte| format('%%%02X', byte) }.join }
|
|
99
332
|
end
|
|
100
333
|
|
|
101
|
-
|
|
334
|
+
def validate_render_options!(options, mode)
|
|
335
|
+
allowed = mode == :svg ? SVG_OPTIONS : PNG_OPTIONS
|
|
336
|
+
unknown = options.keys - allowed
|
|
337
|
+
return if unknown.empty?
|
|
102
338
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
@params[:tid] = transaction_id
|
|
108
|
-
@params[:url] = url
|
|
109
|
-
@params
|
|
339
|
+
raise ArgumentError,
|
|
340
|
+
"unknown keyword#{"s" if unknown.size > 1}: #{unknown.map(&:inspect).join(", ")}. " \
|
|
341
|
+
"Payment details go to #upi_content's parameters; #{mode} renderer options are: " \
|
|
342
|
+
"#{allowed.join(", ")}."
|
|
110
343
|
end
|
|
111
|
-
end
|
|
112
344
|
|
|
113
|
-
|
|
345
|
+
def render_svg(qrcode, options)
|
|
346
|
+
qrcode.as_svg({ color: '000', shape_rendering: 'crispEdges', module_size: 11 }.merge(options))
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
# Sizing is driven by module size rather than a fixed canvas: a longer
|
|
350
|
+
# payload needs more modules, and pinning the canvas shrinks each module
|
|
351
|
+
# until scanners start to struggle.
|
|
352
|
+
def render_png(qrcode, options)
|
|
353
|
+
defaults = {
|
|
354
|
+
bit_depth: 1,
|
|
355
|
+
border_modules: 4,
|
|
356
|
+
color_mode: ChunkyPNG::COLOR_GRAYSCALE,
|
|
357
|
+
color: 'black',
|
|
358
|
+
file: nil,
|
|
359
|
+
fill: 'white',
|
|
360
|
+
module_px_size: 8,
|
|
361
|
+
resize_exactly_to: false,
|
|
362
|
+
resize_gte_to: false
|
|
363
|
+
}
|
|
364
|
+
qrcode.as_png(**defaults.merge(options)).to_s
|
|
365
|
+
end
|
|
366
|
+
end
|
|
114
367
|
end
|
data/sig/upi/generator.rbs
CHANGED
|
@@ -1,15 +1,89 @@
|
|
|
1
1
|
module Upi
|
|
2
|
+
class Error < StandardError
|
|
3
|
+
end
|
|
4
|
+
|
|
5
|
+
class ValidationError < Error
|
|
6
|
+
end
|
|
7
|
+
|
|
2
8
|
class Generator
|
|
9
|
+
type amount = Numeric | String | nil
|
|
10
|
+
type fields = Hash[Symbol, String?]
|
|
11
|
+
|
|
12
|
+
CURRENCY: String
|
|
13
|
+
MODE_DEFAULT: String
|
|
14
|
+
MODE_QR: String
|
|
15
|
+
MODE_SECURE_QR: String
|
|
16
|
+
MODE_INTENT: String
|
|
17
|
+
MAX_LENGTHS: Hash[Symbol, Integer]
|
|
18
|
+
VPA_PATTERN: Regexp
|
|
19
|
+
MERCHANT_CODE_PATTERN: Regexp
|
|
20
|
+
REFERENCE_PATTERN: Regexp
|
|
21
|
+
NUMERIC_PATTERN: Regexp
|
|
22
|
+
MAX_AMOUNT: BigDecimal
|
|
23
|
+
STRICT_UNSAFE: Regexp
|
|
24
|
+
LENIENT_UNSAFE: Regexp
|
|
25
|
+
PARAM_ORDER: Array[Symbol]
|
|
26
|
+
|
|
27
|
+
@upi_id: String
|
|
28
|
+
@name: String
|
|
29
|
+
@currency: String
|
|
30
|
+
@merchant_code: String?
|
|
31
|
+
@no_url_parse: bool
|
|
3
32
|
@params: Hash[Symbol, String]
|
|
4
33
|
|
|
5
34
|
attr_reader params: Hash[Symbol, String]
|
|
35
|
+
attr_reader no_url_parse: bool
|
|
36
|
+
|
|
37
|
+
def self.generate_reference: (?String prefix) -> String
|
|
38
|
+
|
|
39
|
+
def initialize: (
|
|
40
|
+
upi_id: String,
|
|
41
|
+
name: String,
|
|
42
|
+
?currency: String,
|
|
43
|
+
?merchant_code: String?,
|
|
44
|
+
?no_url_parse: bool
|
|
45
|
+
) -> void
|
|
6
46
|
|
|
7
|
-
def
|
|
47
|
+
def upi_content: (
|
|
48
|
+
?amount amount,
|
|
49
|
+
?String? note,
|
|
50
|
+
?transaction_ref_id: String?,
|
|
51
|
+
?transaction_id: String?,
|
|
52
|
+
?url: String?,
|
|
53
|
+
?min_amount: amount,
|
|
54
|
+
?initiation_mode: String?
|
|
55
|
+
) -> String
|
|
8
56
|
|
|
9
|
-
def
|
|
57
|
+
def generate_qr: (
|
|
58
|
+
?amount amount,
|
|
59
|
+
?String? note,
|
|
60
|
+
?transaction_ref_id: String?,
|
|
61
|
+
?transaction_id: String?,
|
|
62
|
+
?url: String?,
|
|
63
|
+
?min_amount: amount,
|
|
64
|
+
?initiation_mode: String?,
|
|
65
|
+
?mode: Symbol,
|
|
66
|
+
?level: Symbol,
|
|
67
|
+
**untyped render_options
|
|
68
|
+
) -> String
|
|
10
69
|
|
|
11
70
|
private
|
|
12
71
|
|
|
13
|
-
def
|
|
72
|
+
def build_fields: (amount, String?, String?, String?, String?, amount, String?) -> fields
|
|
73
|
+
def require_reference!: (String?, String?) -> void
|
|
74
|
+
def validate_vpa!: (String) -> String
|
|
75
|
+
def validate_text!: (String?, Symbol, String) -> String?
|
|
76
|
+
def validate_currency!: (String) -> String
|
|
77
|
+
def validate_merchant_code!: (String?) -> String?
|
|
78
|
+
def validate_reference!: (String?, Symbol, String) -> String?
|
|
79
|
+
def validate_url!: (String?) -> String?
|
|
80
|
+
def validate_initiation_mode!: (String?) -> String?
|
|
81
|
+
def normalize_min_amount: (amount, String?) -> String?
|
|
82
|
+
def normalize_amount: (amount, String) -> String?
|
|
83
|
+
def to_decimal: (untyped, String) -> BigDecimal
|
|
84
|
+
def presence: (String?) -> String?
|
|
85
|
+
def encode: (untyped, ?lenient: bool) -> String
|
|
86
|
+
def render_svg: (untyped, Hash[Symbol, untyped]) -> String
|
|
87
|
+
def render_png: (untyped, Hash[Symbol, untyped]) -> String
|
|
14
88
|
end
|
|
15
89
|
end
|
data/upi.gemspec
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/upi/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'upi'
|
|
7
|
+
spec.version = Upi::VERSION
|
|
8
|
+
spec.authors = ['Faraz Noor']
|
|
9
|
+
spec.email = ['faraznoor75@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'Generate UPI codes for payments.'
|
|
12
|
+
spec.description = 'This gem generates UPI QR codes for payments. It can be used in e-commerce applications to generate QR codes for payments. The QR codes can be scanned by UPI apps to make payments. The gem uses the rqrcode gem to generate the QR codes.'
|
|
13
|
+
spec.homepage = 'https://github.com/stingrayzboy/upi'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 2.6.0'
|
|
16
|
+
|
|
17
|
+
spec.metadata['allowed_push_host'] = 'https://rubygems.org'
|
|
18
|
+
|
|
19
|
+
spec.metadata['homepage_uri'] = 'https://github.com/stingrayzboy/upi'
|
|
20
|
+
spec.metadata['source_code_uri'] = 'https://github.com/stingrayzboy/upi'
|
|
21
|
+
spec.metadata['changelog_uri'] = 'https://github.com/stingrayzboy/upi/blob/master/CHANGELOG.md'
|
|
22
|
+
|
|
23
|
+
# Specify which files should be added to the gem when it is released.
|
|
24
|
+
# The `git ls-files -z` loads the files in the RubyGem that have been added into git.
|
|
25
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
26
|
+
`git ls-files -z`.split("\x0").reject do |f|
|
|
27
|
+
(f == __FILE__) || f.match(%r{\A(?:(?:test|spec|features)/|\.(?:git|travis|circleci)|appveyor)})
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
spec.bindir = 'exe'
|
|
31
|
+
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
|
|
32
|
+
spec.require_paths = ['lib']
|
|
33
|
+
|
|
34
|
+
# Uncomment to register a new dependency of your gem
|
|
35
|
+
# spec.add_dependency "example-gem", "~> 1.0"
|
|
36
|
+
spec.add_runtime_dependency 'chunky_png'
|
|
37
|
+
spec.add_runtime_dependency 'rqrcode', '2.2.0'
|
|
38
|
+
# For more information and examples about making a new gem, check out our
|
|
39
|
+
# guide at: https://bundler.io/guides/creating_gem.html
|
|
40
|
+
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: upi
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version:
|
|
4
|
+
version: 3.0.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Faraz Noor
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date:
|
|
11
|
+
date: 2026-09-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: chunky_png
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- lib/upi/version.rb
|
|
68
68
|
- sig/upi.rbs
|
|
69
69
|
- sig/upi/generator.rbs
|
|
70
|
+
- upi.gemspec
|
|
70
71
|
homepage: https://github.com/stingrayzboy/upi
|
|
71
72
|
licenses:
|
|
72
73
|
- MIT
|
|
@@ -90,7 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
90
91
|
- !ruby/object:Gem::Version
|
|
91
92
|
version: '0'
|
|
92
93
|
requirements: []
|
|
93
|
-
rubygems_version: 3.
|
|
94
|
+
rubygems_version: 3.1.6
|
|
94
95
|
signing_key:
|
|
95
96
|
specification_version: 4
|
|
96
97
|
summary: Generate UPI codes for payments.
|