Class: Upi::Generator

Inherits:
Object
  • Object
show all
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.

Returns:

  • (String)
'INR'
MODE_DEFAULT =

Transaction initiation modes from the specification's mode tag.

Returns:

  • (String)
'00'
MODE_QR =

Returns:

  • (String)
'01'
MODE_SECURE_QR =

Returns:

  • (String)
'02'
MODE_INTENT =

Returns:

  • (String)
'04'
MAX_LENGTHS =

Field limits. pa/pn follow the NPCI field sizes; tn is the widely enforced 50-character transaction note limit; tr/tid are kept inside the shortest limit PSPs are known to apply.

Returns:

  • (Hash[Symbol, Integer])
{ pa: 255, pn: 99, tn: 50, tr: 35, tid: 35 }.freeze
VPA_PATTERN =

A virtual payment address: local@psp.

Returns:

  • (Regexp)
/\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.

Returns:

  • (Regexp)
/\A\d{4}\z/.freeze
REFERENCE_PATTERN =

Reference and transaction ids must be alphanumeric with no spaces.

Returns:

  • (Regexp)
/\A[A-Za-z0-9._-]+\z/.freeze
NUMERIC_PATTERN =

Returns:

  • (Regexp)
/\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.

Returns:

  • (BigDecimal)
BigDecimal('10000000000')
STRICT_UNSAFE =

RFC 3986 unreserved set. Everything else is percent-encoded, which is what makes a space %20 rather than +.

Returns:

  • (Regexp)
/[^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/path the way the NPCI examples show it. &, #, %, + and space are still escaped, so a value can never break out and forge another parameter.

Returns:

  • (Regexp)
%r{[^A-Za-z0-9\-._~:/?=@!$'()*,;]}.freeze
PARAM_ORDER =

Emission order follows the example URIs in the specification.

Returns:

  • (Array[Symbol])
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

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(upi_id:, name:, currency: CURRENCY, merchant_code: nil, no_url_parse: true) ⇒ Generator

Returns a new instance of Generator.

Parameters:

  • upi_id (String)

    payee VPA, e.g. 'merchant@upi'

  • name (String)

    payee name shown on the payer's confirmation screen

  • currency (String) (defaults to: CURRENCY)

    ISO 4217 code; UPI only supports 'INR'

  • merchant_code (String, nil) (defaults to: nil)

    four-digit ISO 18245 merchant category code. Supplying it puts the generator in merchant (P2M) mode.

  • no_url_parse (Boolean) (defaults to: true)

    true (default) percent-encodes values down to the RFC 3986 unreserved set; false leaves URL-safe punctuation readable. Both modes always escape the characters that would otherwise corrupt the query string.



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_parseBoolean (readonly)

Returns the value of attribute no_url_parse.

Returns:

  • (Boolean)


90
91
92
# File 'lib/upi.rb', line 90

def no_url_parse
  @no_url_parse
end

#paramsHash[Symbol, String] (readonly)

Returns the value of attribute params.

Returns:

  • (Hash[Symbol, String])


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.

Parameters:

  • prefix (String) (defaults to: 'UPI')

    alphanumeric prefix to make references recognisable

Returns:

  • (String)

Raises:



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

Parameters:

  • (amount)
  • (String, nil)
  • (String, nil)
  • (String, nil)
  • (String, nil)
  • (amount)
  • (String, nil)

Returns:

  • (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

Parameters:

  • (Object)
  • lenient: (Boolean) (defaults to: false)

Returns:

  • (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.

Parameters:

  • mode (Symbol) (defaults to: :svg)

    :svg (default) or :png output format

  • level (Symbol) (defaults to: :m)

    QR error correction level, :l, :m, :q or :h

  • render_options (Hash)

    passed through to the rqrcode renderer, overriding the defaults (e.g. module_px_size:, module_size:)

Returns:

  • (String)

    SVG markup, or raw PNG bytes

Raises:

  • (ArgumentError)

See Also:



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, **render_options)
  raise ArgumentError, "Unsupported mode: #{mode}. Use :svg or :png." unless i[svg png].include?(mode)

  validate_render_options!(render_options, 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_options) : render_png(qrcode, render_options)
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.

Parameters:

  • (amount)
  • (String)

Returns:

  • (String, nil)

Raises:



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?

Parameters:

  • (amount)
  • (String, nil)

Returns:

  • (String, nil)

Raises:



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?

Parameters:

  • (String, nil)

Returns:

  • (String, nil)


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.

Parameters:

  • (Object)
  • (Hash[Symbol, untyped])

Returns:

  • (String)


352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'lib/upi.rb', line 352

def render_png(qrcode, options)
  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(options)).to_s
end

#render_svg(qrcode, options) ⇒ String

Parameters:

  • (Object)
  • (Hash[Symbol, untyped])

Returns:

  • (String)


345
346
347
# File 'lib/upi.rb', line 345

def render_svg(qrcode, options)
  qrcode.as_svg({ color: '000', shape_rendering: 'crispEdges', module_size: 11 }.merge(options))
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.

Parameters:

  • (String, nil)
  • (String, nil)

Raises:



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

Parameters:

  • (Object)
  • (String)

Returns:

  • (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.

Parameters:

  • amount (Numeric, String, nil) (defaults to: nil)

    payment amount. Omit it (or pass nil) to let the payer type an amount; UPI apps reject am=0.

  • note (String, nil) (defaults to: nil)

    transaction note, max 50 characters

  • transaction_ref_id (String, nil) (defaults to: nil)

    tr. Mandatory for merchant transactions carrying an amount, and must be unique per attempt.

  • transaction_id (String, nil) (defaults to: nil)

    tid, PSP-generated when present

  • url (String, nil) (defaults to: nil)

    http/https transaction detail URL

  • min_amount (Numeric, String, nil) (defaults to: nil)

    mam. When given, the payer can edit the amount down to this floor.

  • initiation_mode (String, nil) (defaults to: nil)

    mode tag, e.g. MODE_QR

Returns:

  • (String)


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

Parameters:

  • (String)

Returns:

  • (String)

Raises:



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?

Parameters:

  • (String, nil)

Returns:

  • (String, nil)

Raises:



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?

Parameters:

  • (String, nil)

Returns:

  • (String, nil)


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?

Parameters:

  • (String, nil)
  • (Symbol)
  • (String)

Returns:

  • (String, nil)

Raises:



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?

Parameters:

  • (String, nil)
  • (Symbol)
  • (String)

Returns:

  • (String, nil)

Raises:



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?

Parameters:

  • (String, nil)

Returns:

  • (String, nil)

Raises:



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

Parameters:

  • (String)

Returns:

  • (String)

Raises:



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