Class: Upi::Generator
- Inherits:
-
Object
- Object
- Upi::Generator
- Defined in:
- lib/upi.rb,
sig/upi/generator.rbs
Overview
Builds UPI payment URIs and QR codes that conform to the NPCI "UPI Linking Specifications" (common URL specification for deep linking and proximity integration, v1.6).
The canonical example from that document is the shape this class targets:
upi://pay?pa=nadeem@npci&pn=nadeem%20chinna&mc=0000&tid=...&tr=...
&tn=Pay%20to%20mystar%20store&am=10&mam=null&cu=INR&url=https://...
Example usage:
generator = Upi::Generator.new(upi_id: 'test@upi', name: 'Test Name')
svg = generator.generate_qr(100, 'Personal Payment', mode: :svg)
png = generator.generate_qr(100, 'Personal Payment', mode: :png)
uri = generator.upi_content(100, 'Personal Payment')
Instances are immutable and safe to share between threads.
Constant Summary collapse
- CURRENCY =
Currently the only currency UPI supports.
'INR'- MODE_DEFAULT =
Transaction initiation modes from the specification's
modetag. '00'- MODE_QR =
'01'- MODE_SECURE_QR =
'02'- MODE_INTENT =
'04'- MAX_LENGTHS =
Field limits.
pa/pnfollow the NPCI field sizes;tnis the widely enforced 50-character transaction note limit;tr/tidare kept inside the shortest limit PSPs are known to apply. { pa: 255, pn: 99, tn: 50, tr: 35, tid: 35 }.freeze
- VPA_PATTERN =
A virtual payment address:
local@psp. /\A[A-Za-z0-9][A-Za-z0-9._-]{0,}@[A-Za-z][A-Za-z0-9.-]{0,}\z/.freeze
- MERCHANT_CODE_PATTERN =
Merchant category code, ISO 18245: exactly four digits.
/\A\d{4}\z/.freeze
- REFERENCE_PATTERN =
Reference and transaction ids must be alphanumeric with no spaces.
/\A[A-Za-z0-9._-]+\z/.freeze
- NUMERIC_PATTERN =
/\A\d+(\.\d+)?\z/.freeze
- MAX_AMOUNT =
Largest amount we will emit. Per-transaction ceilings are set by the bank and the merchant category, so this only catches obviously bad input.
BigDecimal('10000000000')
- STRICT_UNSAFE =
RFC 3986 unreserved set. Everything else is percent-encoded, which is what makes a space
%20rather than+. /[^A-Za-z0-9\-._~]/.freeze
- LENIENT_UNSAFE =
Leaves characters that are legal inside a query value untouched, so a redirect URL stays readable as
https://host/paththe way the NPCI examples show it.&,#,%,+and space are still escaped, so a value can never break out and forge another parameter. %r{[^A-Za-z0-9\-._~:/?=@!$'()*,;]}.freeze
- PARAM_ORDER =
Emission order follows the example URIs in the specification.
i[pa pn mc tid tr tn am mam cu url mode].freeze
- SVG_OPTIONS =
Renderer options accepted by rqrcode, used to reject a mistyped payment keyword rather than let it fall through to the renderer and quietly drop a field from the QR code.
i[fill use_path offset color shape_rendering module_size standalone viewbox svg_attributes].freeze
- PNG_OPTIONS =
i[bit_depth border_modules color_mode color file fill module_px_size resize_exactly_to resize_gte_to size].freeze
Instance Attribute Summary collapse
-
#no_url_parse ⇒ Boolean
readonly
Returns the value of attribute no_url_parse.
-
#params ⇒ Hash[Symbol, String]
readonly
Returns the value of attribute params.
Class Method Summary collapse
-
.generate_reference(prefix = 'UPI') ⇒ String
Generates a unique transaction reference suitable for the
trfield.
Instance Method Summary collapse
- #build_fields(amount, note, transaction_ref_id, transaction_id, url, min_amount, initiation_mode) ⇒ fields
- #encode(value, lenient: false) ⇒ String
-
#generate_qr(amount = nil, note = nil, transaction_ref_id: nil, transaction_id: nil, url: nil, min_amount: nil, initiation_mode: nil, mode: :svg, level: :m, **render_options) ⇒ String
Renders the payment URI as a QR code.
-
#initialize(upi_id:, name:, currency: CURRENCY, merchant_code: nil, no_url_parse: true) ⇒ Generator
constructor
A new instance of Generator.
-
#normalize_amount(amount, label) ⇒ String?
UPI amounts are decimal with at most two places.
- #normalize_min_amount(min_amount, amount) ⇒ String?
- #presence(value) ⇒ String?
-
#render_png(qrcode, options) ⇒ String
Sizing is driven by module size rather than a fixed canvas: a longer payload needs more modules, and pinning the canvas shrinks each module until scanners start to struggle.
- #render_svg(qrcode, options) ⇒ String
-
#require_reference!(reference, amount) ⇒ void
The specification marks
trmandatory for merchant transactions and for dynamic (amount-bearing) URLs. - #to_decimal(amount, label) ⇒ BigDecimal
-
#upi_content(amount = nil, note = nil, transaction_ref_id: nil, transaction_id: nil, url: nil, min_amount: nil, initiation_mode: nil) ⇒ String
Builds the
upi://pay?...URI. - #validate_currency!(currency) ⇒ String
- #validate_initiation_mode!(initiation_mode) ⇒ String?
- #validate_merchant_code!(merchant_code) ⇒ String?
- #validate_reference!(reference, key, label) ⇒ String?
- #validate_text!(text, key, label) ⇒ String?
- #validate_url!(url) ⇒ String?
- #validate_vpa!(upi_id) ⇒ String
Constructor Details
#initialize(upi_id:, name:, currency: CURRENCY, merchant_code: nil, no_url_parse: true) ⇒ Generator
Returns a new instance of Generator.
101 102 103 104 105 106 107 108 |
# File 'lib/upi.rb', line 101 def initialize(upi_id:, name:, currency: CURRENCY, merchant_code: nil, no_url_parse: true) @upi_id = validate_vpa!(upi_id) @name = validate_text!(name, :pn, 'name') @currency = validate_currency!(currency) @merchant_code = validate_merchant_code!(merchant_code) @no_url_parse = no_url_parse @params = { pa: @upi_id, pn: @name, mc: @merchant_code, cu: @currency }.compact.freeze end |
Instance Attribute Details
#no_url_parse ⇒ Boolean (readonly)
Returns the value of attribute no_url_parse.
90 91 92 |
# File 'lib/upi.rb', line 90 def no_url_parse @no_url_parse end |
#params ⇒ Hash[Symbol, String] (readonly)
Returns the value of attribute params.
90 91 92 |
# File 'lib/upi.rb', line 90 def params @params end |
Class Method Details
.generate_reference(prefix = 'UPI') ⇒ String
Generates a unique transaction reference suitable for the tr field.
A tr must be unique per payment attempt: PSPs reject a repeat of one
they have already seen, which is why a QR carrying a hard-coded reference
works once and then quietly stops.
118 119 120 121 122 |
# File 'lib/upi.rb', line 118 def self.generate_reference(prefix = 'UPI') raise ValidationError, "reference prefix #{prefix.inspect} must be alphanumeric" unless prefix.to_s.match?(REFERENCE_PATTERN) "#{prefix}#{Time.now.strftime("%Y%m%d%H%M%S")}#{SecureRandom.hex(4).upcase}" end |
Instance Method Details
#build_fields(amount, note, transaction_ref_id, transaction_id, url, min_amount, initiation_mode) ⇒ fields
175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 |
# File 'lib/upi.rb', line 175 def build_fields(amount, note, transaction_ref_id, transaction_id, url, min_amount, initiation_mode) normalized_amount = normalize_amount(amount, 'amount') reference = validate_reference!(transaction_ref_id, :tr, 'transaction_ref_id') require_reference!(reference, normalized_amount) params.merge( tid: validate_reference!(transaction_id, :tid, 'transaction_id'), tr: reference, tn: presence(validate_text!(note, :tn, 'note')), am: normalized_amount, mam: normalize_min_amount(min_amount, normalized_amount), url: validate_url!(url), mode: validate_initiation_mode!(initiation_mode) ) end |
#encode(value, lenient: false) ⇒ String
329 330 331 332 |
# File 'lib/upi.rb', line 329 def encode(value, lenient: false) pattern = lenient || !no_url_parse ? LENIENT_UNSAFE : STRICT_UNSAFE value.to_s.gsub(pattern) { |char| char.bytes.map { |byte| format('%%%02X', byte) }.join } end |
#generate_qr(amount = nil, note = nil, transaction_ref_id: nil, transaction_id: nil, url: nil, min_amount: nil, initiation_mode: nil, mode: :svg, level: :m, **render_options) ⇒ String
Renders the payment URI as a QR code.
159 160 161 162 163 164 165 166 167 168 169 170 171 |
# File 'lib/upi.rb', line 159 def generate_qr(amount = nil, note = nil, transaction_ref_id: nil, transaction_id: nil, url: nil, min_amount: nil, initiation_mode: nil, mode: :svg, level: :m, **) raise ArgumentError, "Unsupported mode: #{mode}. Use :svg or :png." unless i[svg png].include?(mode) (, mode) content = upi_content(amount, note, transaction_ref_id: transaction_ref_id, transaction_id: transaction_id, url: url, min_amount: min_amount, initiation_mode: initiation_mode) qrcode = RQRCode::QRCode.new(content, level: level) mode == :svg ? render_svg(qrcode, ) : render_png(qrcode, ) end |
#normalize_amount(amount, label) ⇒ String?
UPI amounts are decimal with at most two places. Emitting anything else --
am=0, am=1499.5, or the 0.30000000000000004 a float sum produces --
is rejected by PSP apps, so normalize here and reject what cannot be
represented rather than silently rounding someone's money.
290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 |
# File 'lib/upi.rb', line 290 def normalize_amount(amount, label) return nil if amount.nil? return nil if amount.is_a?(String) && amount.strip.empty? decimal = to_decimal(amount, label) raise ValidationError, "#{label} must be greater than zero, got #{amount.inspect}" unless decimal.positive? raise ValidationError, "#{label} #{amount.inspect} is too large" if decimal > MAX_AMOUNT if decimal != decimal.round(2) raise ValidationError, "#{label} #{amount.inspect} has more than two decimal places; " \ 'UPI amounts allow at most two. Round it before passing it in.' end whole, fraction = decimal.to_s('F').split('.') "#{whole}.#{fraction.to_s.ljust(2, "0")[0, 2]}" end |
#normalize_min_amount(min_amount, amount) ⇒ String?
277 278 279 280 281 282 283 284 |
# File 'lib/upi.rb', line 277 def normalize_min_amount(min_amount, amount) normalized = normalize_amount(min_amount, 'min_amount') return nil if normalized.nil? raise ValidationError, "min_amount #{normalized} cannot exceed amount #{amount}" if amount && BigDecimal(normalized) > BigDecimal(amount) normalized end |
#presence(value) ⇒ String?
325 326 327 |
# File 'lib/upi.rb', line 325 def presence(value) value unless value.nil? || value.empty? end |
#render_png(qrcode, options) ⇒ String
Sizing is driven by module size rather than a fixed canvas: a longer payload needs more modules, and pinning the canvas shrinks each module until scanners start to struggle.
352 353 354 355 356 357 358 359 360 361 362 363 364 365 |
# File 'lib/upi.rb', line 352 def render_png(qrcode, ) defaults = { bit_depth: 1, border_modules: 4, color_mode: ChunkyPNG::COLOR_GRAYSCALE, color: 'black', file: nil, fill: 'white', module_px_size: 8, resize_exactly_to: false, resize_gte_to: false } qrcode.as_png(**defaults.merge()).to_s end |
#render_svg(qrcode, options) ⇒ String
345 346 347 |
# File 'lib/upi.rb', line 345 def render_svg(qrcode, ) qrcode.as_svg({ color: '000', shape_rendering: 'crispEdges', module_size: 11 }.merge()) end |
#require_reference!(reference, amount) ⇒ void
This method returns an undefined value.
The specification marks tr mandatory for merchant transactions and for
dynamic (amount-bearing) URLs. Without it a PSP's risk engine sees an
amount-carrying merchant payload with nothing to reconcile against, and
rejects it behind a generic error.
195 196 197 198 199 200 201 202 |
# File 'lib/upi.rb', line 195 def require_reference!(reference, amount) return if reference || @merchant_code.nil? || amount.nil? raise ValidationError, 'transaction_ref_id is mandatory for merchant transactions with an amount, ' \ 'and must be unique per payment attempt. ' \ 'Use Upi::Generator.generate_reference to build one.' end |
#to_decimal(amount, label) ⇒ BigDecimal
308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 |
# File 'lib/upi.rb', line 308 def to_decimal(amount, label) case amount when BigDecimal then amount when Integer then BigDecimal(amount) # 15 significant digits discards IEEE representation noise (0.1 + 0.2) # while preserving precision the caller actually meant. when Float, Rational then BigDecimal(amount, 15) when String value = amount.strip raise ValidationError, "#{label} #{amount.inspect} is not a valid number" unless value.match?(NUMERIC_PATTERN) BigDecimal(value) else raise ValidationError, "#{label} must be Numeric or String, got #{amount.class}" end end |
#upi_content(amount = nil, note = nil, transaction_ref_id: nil, transaction_id: nil, url: nil, min_amount: nil, initiation_mode: nil) ⇒ String
Builds the upi://pay?... URI.
137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/upi.rb', line 137 def upi_content(amount = nil, note = nil, transaction_ref_id: nil, transaction_id: nil, url: nil, min_amount: nil, initiation_mode: nil) fields = build_fields(amount, note, transaction_ref_id, transaction_id, url, min_amount, initiation_mode) query = PARAM_ORDER.map do |key| next if key == :pa value = fields[key] "#{key}=#{encode(value, lenient: key == :url)}" unless value.nil? end.compact "upi://pay?pa=#{fields[:pa]}&#{query.join("&")}" end |
#validate_currency!(currency) ⇒ String
227 228 229 230 231 232 |
# File 'lib/upi.rb', line 227 def validate_currency!(currency) value = currency.to_s.strip.upcase raise ValidationError, "currency #{currency.inspect} is not supported; UPI only supports 'INR'" unless value == CURRENCY value end |
#validate_initiation_mode!(initiation_mode) ⇒ String?
268 269 270 271 272 273 274 275 |
# File 'lib/upi.rb', line 268 def validate_initiation_mode!(initiation_mode) return nil if initiation_mode.nil? value = initiation_mode.to_s raise ValidationError, "initiation_mode #{initiation_mode.inspect} must be two digits" unless value.match?(/\A\d{2}\z/) value end |
#validate_merchant_code!(merchant_code) ⇒ String?
234 235 236 237 238 239 240 241 242 243 244 |
# File 'lib/upi.rb', line 234 def validate_merchant_code!(merchant_code) return nil if merchant_code.nil? value = merchant_code.to_s.strip unless value.match?(MERCHANT_CODE_PATTERN) raise ValidationError, "merchant_code #{merchant_code.inspect} must be a four-digit ISO 18245 category code" end value end |
#validate_reference!(reference, key, label) ⇒ String?
246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/upi.rb', line 246 def validate_reference!(reference, key, label) return nil if reference.nil? value = reference.to_s.strip return nil if value.empty? raise ValidationError, "#{label} must be at most #{MAX_LENGTHS[key]} characters, got #{value.length}" if value.length > MAX_LENGTHS[key] raise ValidationError, "#{label} #{reference.inspect} must be alphanumeric with no spaces" unless value.match?(REFERENCE_PATTERN) value end |
#validate_text!(text, key, label) ⇒ String?
214 215 216 217 218 219 220 221 222 223 224 225 |
# File 'lib/upi.rb', line 214 def validate_text!(text, key, label) return nil if text.nil? value = text.to_s.tr("\r\n\t", ' ').strip if value.length > MAX_LENGTHS[key] raise ValidationError, "#{label} must be at most #{MAX_LENGTHS[key]} characters, got #{value.length}" end raise ValidationError, "#{label} is required" if key == :pn && value.empty? value end |
#validate_url!(url) ⇒ String?
258 259 260 261 262 263 264 265 266 |
# File 'lib/upi.rb', line 258 def validate_url!(url) return nil if url.nil? value = url.to_s.strip return nil if value.empty? raise ValidationError, "url #{url.inspect} must start with http:// or https://" unless value.start_with?('http://', 'https://') value end |
#validate_vpa!(upi_id) ⇒ String
204 205 206 207 208 209 210 211 212 |
# File 'lib/upi.rb', line 204 def validate_vpa!(upi_id) value = upi_id.to_s.strip raise ValidationError, 'upi_id is required' if value.empty? raise ValidationError, "upi_id must be at most #{MAX_LENGTHS[:pa]} characters" if value.length > MAX_LENGTHS[:pa] raise ValidationError, "upi_id #{upi_id.inspect} is not a valid UPI address (expected 'name@psp')" unless value.match?(VPA_PATTERN) value end |