diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..097a15a --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +2.6.2 diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..e3ca081 --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +git_source(:github) {|repo_name| "https://github.com/#{repo_name}" } + +# Specify your gem's dependencies in girocode.gemspec +gemspec diff --git a/LICENSE b/LICENSE index 44a353d..aaa5544 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -MIT License +The MIT License (MIT) Copyright (c) 2019 Matthias Grosser @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..72e4ec5 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# Girocode + +Create QR codes for SEPA bank transfers. + +## Installation + +Add this line to your application's Gemfile: + +```ruby +gem 'girocode' +``` + +## Usage + +```ruby +code = Girocode.new(iban: 'DE02100500000054540402', name: 'Beispiel AG', currency: 'EUR', amount: 123.45, reference: 'RE 2019/05/445 744507') + +code.to_svg +code.to_png +code.to_html + +# in your console +puts code.to_ansi +``` diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..d433a1e --- /dev/null +++ b/Rakefile @@ -0,0 +1,10 @@ +require "bundler/gem_tasks" +require "rake/testtask" + +Rake::TestTask.new(:test) do |t| + t.libs << "test" + t.libs << "lib" + t.test_files = FileList["test/**/*_test.rb"] +end + +task :default => :test diff --git a/bin/console b/bin/console new file mode 100755 index 0000000..c93ab13 --- /dev/null +++ b/bin/console @@ -0,0 +1,14 @@ +#!/usr/bin/env ruby + +require "bundler/setup" +require "girocode" + +# You can add fixtures and/or initialization code here to make experimenting +# with your gem easier. You can also use a different console, if you like. + +# (If you use this, don't forget to add pry to your Gemfile!) +# require "pry" +# Pry.start + +require "irb" +IRB.start(__FILE__) diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..dce67d8 --- /dev/null +++ b/bin/setup @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail +IFS=$'\n\t' +set -vx + +bundle install + +# Do any other automated setup that you need to do here diff --git a/girocode.gemspec b/girocode.gemspec new file mode 100644 index 0000000..1a3ec90 --- /dev/null +++ b/girocode.gemspec @@ -0,0 +1,25 @@ +lib = File.expand_path('../lib', __FILE__) +$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) +require 'girocode/version' + +Gem::Specification.new do |s| + s.name = 'girocode' + s.version = Girocode::VERSION + s.authors = ['Matthias Grosser'] + s.email = ['mtgrosser@gmx.net'] + + s.summary = %q{Generate QR codes for SEPA credit transfers} + s.description = %q{EPC QR code for SEPA payments in any format} + s.homepage = 'https://github.com/mtgrosser/girocode' + + s.files = Dir['{lib}/**/*.rb', 'LICENSE', 'README.md', 'CHANGELOG', 'Rakefile'] + s.require_paths = ['lib'] + + s.add_dependency 'rqrcode' + s.add_dependency 'bank-contact' + + s.add_development_dependency 'bundler', '~> 1.17' + s.add_development_dependency 'rake', '~> 10.0' + s.add_development_dependency 'minitest', '~> 5.0' + s.add_development_dependency 'simplecov' +end diff --git a/lib/girocode.rb b/lib/girocode.rb new file mode 100644 index 0000000..cf1775a --- /dev/null +++ b/lib/girocode.rb @@ -0,0 +1,12 @@ +require 'bigdecimal' +require 'bank/contact' +require 'rqrcode' + +require_relative 'girocode/version' +require_relative 'girocode/code' + +module Girocode + def self.new(**attrs) + Girocode::Code.new(**attrs) + end +end diff --git a/lib/girocode/code.rb b/lib/girocode/code.rb new file mode 100644 index 0000000..9b41b2d --- /dev/null +++ b/lib/girocode/code.rb @@ -0,0 +1,119 @@ +module Girocode + class Error < StandardError; end + + class Code + ATTRIBUTES = %i[bic name iban currency amount purpose creditor_reference reference bto_info] + attr_reader *ATTRIBUTES + + MAX_PAYLOAD_BYTES = 331 + AMOUNT_RANGE = BigDecimal('0.01')..BigDecimal('999999999.99') + + def initialize(**attrs) + if keys = attrs.keys - ATTRIBUTES and not keys.empty? + raise ArgumentError, "Illegal attributes #{keys.inspect}" + end + attrs.each { |attr, value| send("#{attr}=", value) } + raise ArgumentError, "iban is required" unless iban? + raise ArgumentError, "name is required" unless name? + raise ArgumentError, 'currency is required for amount' if amount && !currency? + raise ArgumentError, "either creditor reference or reference may be set" if creditor_reference? && reference? + raise ArgumentError, "payload too long" if payload.bytesize > MAX_PAYLOAD_BYTES + end + + def bic=(value) + if value.nil? + @bic = nil + else + bic = Bank::BIC.new(value) + raise ArgumentError, "Invalid BIC #{value.inspect}" unless bic.valid? + @bic = bic.to_s + end + end + + def name=(value) + value = value.strip + raise ArgumentError, 'name is required' unless value + raise ArgumentError, 'name too long' if value.size > 70 + raise ArgumentError, 'Illegal name' if value.include?("\n") || value.include?("\r") + @name = value + end + + def iban=(value) + iban = Bank::IBAN.new(value) + raise ArgumentError, "Invalid IBAN #{value.inspect}" unless iban.valid? + @iban = iban.to_s + end + + def currency=(value) + value = value.to_s.upcase + raise ArgumentError, "Invalid currency" unless value.match?(/\A[A-Z]{3}\z/) + @currency = value + end + + def amount=(value) + raise ArgumentError, 'amount is required' unless value + value = BigDecimal(value, Float::DIG + 1) + raise ArgumentError, "invalid amount #{value.inspect}" unless AMOUNT_RANGE.cover?(value) + @amount = value + end + + def purpose=(value) + unless value.nil? + raise ArgumentError, "invalid purpose #{value.inspect}" unless value.match?(/\A[A-z0-9]{0,4}\z/) + end + @purpose = value + end + + def creditor_reference=(value) + unless value.nil? + raise ArgumentError, "invalid creditor reference #{value.inspect}" if value.include?("\n") || value.include?("\r") || value.size > 35 + end + @creditor_reference = value + end + + def reference=(value) + unless value.nil? + raise ArgumentError, "invalid reference #{value.inspect}" if value.include?("\n") || value.include?("\r") || value.size > 140 + end + @reference = value + end + + def bto_info=(value) + unless value.nil? + raise ArgumentError, "invalid bto_info #{value.inspect}" if value.include?("\n") || value.include?("\r") || value.size > 70 + end + @bto_info = value + end + + ATTRIBUTES.each do |attr| + define_method("#{attr}?") do + value = instance_variable_get("@#{attr}") + value.respond_to?(:empty?) ? !value.empty? : !!value + end + end + + def payload + ['BCD', '002', '1', 'SCT', + bic, name, iban,formatted_amount, purpose, + creditor_reference || reference, bto_info].map(&:to_s).join("\n") + end + + def to_qrcode + RQRCode::QRCode.new(payload, level: :m, mode: :byte_8bit) + end + + def to_ascii + to_qrcode.to_s + end + + %i[png svg html ansi].each do |format| + define_method("to_#{format}") { |*args| to_qrcode.public_send("as_#{format}", *args) } + end + + private + + def formatted_amount + "#{currency}#{amount.round(2).to_s('F')}" if currency? && amount + end + end +end diff --git a/lib/girocode/version.rb b/lib/girocode/version.rb new file mode 100644 index 0000000..2c65062 --- /dev/null +++ b/lib/girocode/version.rb @@ -0,0 +1,3 @@ +module Girocode + VERSION = "0.1.0" +end diff --git a/test/data.txt b/test/data.txt new file mode 100644 index 0000000..2a3e248 --- /dev/null +++ b/test/data.txt @@ -0,0 +1,41 @@ +xxxxxxx xxx xx xx xx xxxx x x xxxxxxx +x x xxxxx xxx xx xxx xx x x +x xxx x x x xxxxx xxx x xxx x xxx x +x xxx x xx xxx x xxxxxxx x xxx x +x xxx x xx x xx x xxxxxx x x x x xxx x +x x xxx xx xx x xxx x xx x x x +xxxxxxx x x x x x x x x x x x x x xxxxxxx + xx xxx xxx x +x xxxxxx x x xxx xxxxx xxxx x xxx + xx x xx xx xx x x x xxxx x x x +xx xxx x xx xxxx xxxxx x x x x + x x xxxx xxxx x xxx xxxxx +xx xxx xx xx x x x x xxx xxxx +x x x xx x xxxx xxxx xxx x xxx xxxxx +x xxxxxx xx x xx xxxxx x x xx xxxx + xx x xx xx xx x x xx x x x x xx x +xxxxxxx xxx xx x x x x x x + xx x xxxx x x xx x x xx x x xx x + xxx x x x xxx x xx xxxxx x +xxx xx x x x x xx x xx x + x x x xx xxx x xxxx x xxxxx xx x x +xxx xx xx x x xx x x x x xxx x + x x x xxxx xx xxxxxx x x x xx + x xxx x xx x xx x xx x x xx +x xxx x xx x xxxx x xx x xxxx xx +x x x x xx xx x x xxxx xxxx +x x xxxxxx xxxxxxx x xx x xx x x x +xxx x xxxx xx xx x x xx xxx xxx xxx +xx x xx xx x xxx xxx xx x xxx +xx x xx x xxx x xx xxx x x x x +xx x xxxxx x x x x xxxx xx x xxx xxx +xx xxx xxx xx xxxx x xx x x x +xxx xxxx x x xxx xxx xx xxxxxxxxx + xx xxx xxxx xxx xx xx x x x +xxxxxxx x x x xx xx x x xx x xx +x x x xxxx xx x xx x x +x xxx x xx x xx x xxxxxx x xxxxxxx x +x xxx x xx x xxx xxx x xxx xxx x +x xxx x x xxxxxxxxx xx xxxxx x x +x x x x x x x xx xxx x x x x x +xxxxxxx x x xx x xxx x x x xx xx \ No newline at end of file diff --git a/test/girocode_test.rb b/test/girocode_test.rb new file mode 100644 index 0000000..5d2eda3 --- /dev/null +++ b/test/girocode_test.rb @@ -0,0 +1,19 @@ +require_relative 'test_helper' + +class GirocodeTest < Minitest::Test + def test_that_it_has_a_version_number + refute_nil ::Girocode::VERSION + end + + def test_girocode + attrs = { bic: 'BHBLDEHHXXX', name: 'Franz Mustermänn', iban: 'DE71110220330123456789', currency: :eur, amount: 12.3, purpose: 'GDDS', creditor_reference: 'RF18539007547034' } + code = Girocode.new(attrs) + assert_equal data(:data), code.to_ascii + end + + private + + def data(name) + Pathname(__dir__).join("#{name}.txt").read + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb new file mode 100644 index 0000000..161cd29 --- /dev/null +++ b/test/test_helper.rb @@ -0,0 +1,30 @@ +ENV["RAILS_ENV"] = "test" + +require 'pathname' + +if RUBY_VERSION >= '1.9' + require 'simplecov' + SimpleCov.start do + if artifacts_dir = ENV['CC_BUILD_ARTIFACTS'] + coverage_dir Pathname.new(artifacts_dir).relative_path_from(Pathname.new(SimpleCov.root)).to_s + end + add_filter '/test/' + add_filter 'vendor' + end + + SimpleCov.at_exit do + SimpleCov.result.format! + if result = SimpleCov.result + File.open(File.join(SimpleCov.coverage_path, 'coverage_percent.txt'), 'w') { |f| f << result.covered_percent.to_s } + end + end +end + +require 'rubygems' +require 'bundler/setup' +Bundler.require(:default) +#require 'byebug' + +require 'minitest/autorun' + +require 'girocode'