├── .gitignore ├── .ruby-version ├── tmp └── .gitignore ├── assets ├── logo.png ├── example.pdf └── default.css ├── client ├── billbo │ ├── version.rb │ ├── json_util.rb │ └── stripe_like.rb └── billbo.rb ├── Procfile ├── config ├── shotgun.rb ├── plugins │ ├── sentry.rb │ ├── segmentio.rb │ └── s3.rb ├── boot.rb ├── rack_app.rb ├── schema.rb ├── environment.rb └── configuration.rb ├── .env ├── spec ├── visual │ ├── refund.png │ ├── subscription_proration.png │ ├── subscription_with_vat.png │ ├── subscription_custom_description.png │ ├── subscription_without_vat_export.png │ ├── subscription_without_vat_reverse.png │ ├── subscription_with_vat_with_discount.png │ └── subscription_without_vat_with_discount.png ├── configuration_service_spec.rb ├── client │ ├── billboo │ │ └── json_util_spec.rb │ └── bilbo_spec.rb ├── spec_helper.rb ├── pdf_service_spec.rb ├── job_spec.rb ├── analytics_channel_spec.rb ├── cassettes │ ├── job_vat.yml │ ├── job_credit_note.yml │ ├── job_exchange_rates.yml │ ├── process_refund_orphan.yml │ ├── preview_success.yml │ ├── preview_quantity_success.yml │ ├── validate_vat.yml │ ├── account.yml │ ├── configuration_preload.yml │ ├── hook_invoice_created.yml │ └── app_create_subscription.yml ├── vat_service_spec.rb ├── stripe_service_spec.rb ├── invoice_spec.rb ├── hooks_spec.rb └── invoice_service_spec.rb ├── config.ru ├── lib ├── configuration_service.rb ├── pdf_service.rb ├── stripe_service.rb ├── invoice_service.rb └── vat_service.rb ├── app ├── invoice_cloud_uploader.rb ├── invoice_file_uploader.rb ├── base.rb ├── hooks.rb ├── job.rb ├── analytics_channel.rb ├── template_view_model.rb ├── invoice.rb ├── templates │ └── default.html.slim └── app.rb ├── Guardfile ├── .env.development ├── .env.test ├── Rakefile ├── .travis.yml ├── accounting ├── fix_vat ├── check_vat ├── moss ├── exact ├── ic └── yuki ├── billbo.gemspec ├── LICENSE.txt ├── Gemfile ├── deploy-heroku ├── migrations └── taxpercent.rb ├── README.md └── Gemfile.lock /.gitignore: -------------------------------------------------------------------------------- 1 | invoices 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 2.3.8 2 | -------------------------------------------------------------------------------- /tmp/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/assets/logo.png -------------------------------------------------------------------------------- /client/billbo/version.rb: -------------------------------------------------------------------------------- 1 | module Billbo 2 | VERSION = "1.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec puma -w 3 -t 20:20 -e production -p $PORT --preload 2 | -------------------------------------------------------------------------------- /assets/example.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/assets/example.pdf -------------------------------------------------------------------------------- /config/shotgun.rb: -------------------------------------------------------------------------------- 1 | $environment ||= :development 2 | require './config/boot' 3 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | API_TOKEN=billbo 2 | INVOICE_NUMBER_FORMAT=%{year}%06d 3 | DUE_DAYS=12 4 | -------------------------------------------------------------------------------- /spec/visual/refund.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/spec/visual/refund.png -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # Environment. 2 | require './config/environment' 3 | 4 | run Configuration.app 5 | -------------------------------------------------------------------------------- /config/plugins/sentry.rb: -------------------------------------------------------------------------------- 1 | Raven.configure do |config| 2 | config.dsn = Configuration.sentry_dsn 3 | end 4 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | 3 | # Require needed gems. 4 | Bundler.require(:default, $environment) 5 | -------------------------------------------------------------------------------- /spec/visual/subscription_proration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/spec/visual/subscription_proration.png -------------------------------------------------------------------------------- /spec/visual/subscription_with_vat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/spec/visual/subscription_with_vat.png -------------------------------------------------------------------------------- /spec/visual/subscription_custom_description.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/spec/visual/subscription_custom_description.png -------------------------------------------------------------------------------- /spec/visual/subscription_without_vat_export.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/spec/visual/subscription_without_vat_export.png -------------------------------------------------------------------------------- /spec/visual/subscription_without_vat_reverse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/spec/visual/subscription_without_vat_reverse.png -------------------------------------------------------------------------------- /spec/visual/subscription_with_vat_with_discount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/spec/visual/subscription_with_vat_with_discount.png -------------------------------------------------------------------------------- /lib/configuration_service.rb: -------------------------------------------------------------------------------- 1 | class ConfigurationService 2 | 3 | def account 4 | @primary_country ||= Stripe::Account.retrieve 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/visual/subscription_without_vat_with_discount.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DV/billbo/master/spec/visual/subscription_without_vat_with_discount.png -------------------------------------------------------------------------------- /app/invoice_cloud_uploader.rb: -------------------------------------------------------------------------------- 1 | class InvoiceCloudUploader < CarrierWave::Uploader::Base 2 | storage :fog 3 | 4 | def store_dir 5 | nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /app/invoice_file_uploader.rb: -------------------------------------------------------------------------------- 1 | class InvoiceFileUploader < CarrierWave::Uploader::Base 2 | storage :file 3 | 4 | def store_dir 5 | 'invoices' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | guard 'minitest' do 2 | watch(%r|^spec/(.*)_spec\.rb|) 3 | watch(%r|^lib/(.*)([^/]+)\.rb|) { |m| "spec/#{m[1]}#{m[2]}_spec.rb" } 4 | watch(%r|^spec/spec_helper\.rb|) { "spec" } 5 | end 6 | -------------------------------------------------------------------------------- /config/plugins/segmentio.rb: -------------------------------------------------------------------------------- 1 | require 'analytics_channel' 2 | 3 | Configuration.segmentio = Segment::Analytics.new({ 4 | write_key: Configuration.segmentio_write_key, 5 | on_error: Proc.new { |status, msg| puts msg } 6 | }) 7 | 8 | Rumor.register :analytics, AnalyticsChannel.new(Configuration.segmentio) 9 | -------------------------------------------------------------------------------- /.env.development: -------------------------------------------------------------------------------- 1 | HOST=localhost:9292 2 | DATABASE_URL=postgres://localhost/billbo_dev 3 | SELLER_ADDRESS_LINE1=Sand Hill Road 4 | SELLER_ADDRESS_LINE2=Menlo Park, CA 94025 5 | SELLER_VAT_NUMBER=LU21416127 6 | SELLER_BANK_NAME=Bank of America 7 | SELLER_BIC=BIC1234 8 | SELLER_IBAN=IBAN1234 9 | SELLER_COMPANY_NAME=ACME 10 | SELLER_EMAIL=info@acme.org 11 | -------------------------------------------------------------------------------- /.env.test: -------------------------------------------------------------------------------- 1 | HOST=localhost:9292 2 | DATABASE_URL=postgres://localhost/billbo_test 3 | STRIPE_SECRET_KEY=test 4 | SELLER_ADDRESS_LINE1=Sand Hill Road 5 | SELLER_ADDRESS_LINE2=Menlo Park, CA 94025 6 | SELLER_VAT_NUMBER=LU21416127 7 | SELLER_BANK_NAME=Bank of America 8 | SELLER_BIC=BIC1234 9 | SELLER_IBAN=IBAN1234 10 | SELLER_COMPANY_NAME=ACME 11 | SELLER_EMAIL=info@acme.org 12 | -------------------------------------------------------------------------------- /spec/configuration_service_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe ConfigurationService do 4 | 5 | let(:service) { ConfigurationService.new } 6 | 7 | describe '#account' do 8 | it 'returns the Stripe account' do 9 | VCR.use_cassette('account') do 10 | service.account.must_be_kind_of Stripe::Account 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /config/plugins/s3.rb: -------------------------------------------------------------------------------- 1 | CarrierWave.configure do |config| 2 | config.fog_credentials = { 3 | :provider => 'AWS', 4 | :aws_access_key_id => Configuration.s3_key_id, 5 | :aws_secret_access_key => Configuration.s3_secret_key, 6 | :region => Configuration.s3_region, 7 | } 8 | 9 | config.fog_directory = Configuration.s3_bucket 10 | config.fog_public = false 11 | end 12 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/testtask' 2 | 3 | Rake::TestTask.new(:spec) do |t| 4 | t.libs = ['app', 'lib', 'spec'] 5 | t.test_files = FileList['spec/**/*_spec.rb'] 6 | end 7 | 8 | task :environment do 9 | require './config/environment' 10 | end 11 | 12 | task :console => :environment do 13 | require 'irb' 14 | require 'irb/completion' 15 | ARGV.clear 16 | IRB.start 17 | end 18 | 19 | task :default => :spec 20 | 21 | task :job => :environment do 22 | Job.new.perform 23 | end 24 | -------------------------------------------------------------------------------- /config/rack_app.rb: -------------------------------------------------------------------------------- 1 | Configuration.app = Rack::Builder.app do 2 | use Raven::Rack if Configuration.sentry? 3 | use Rack::Static, :urls => ['/assets'] 4 | 5 | secure = Rack::Builder.app do 6 | use Rack::Auth::Basic, 'Billbo' do |_, token| 7 | token == Configuration.api_token 8 | end 9 | 10 | run Rack::URLMap.new( 11 | '/' => App, 12 | '/hook' => Hooks 13 | ) 14 | end 15 | 16 | run Rack::URLMap.new( 17 | '/' => secure, 18 | '/ping' => lambda { |env| [200, {}, ['OK']] } 19 | ) 20 | end 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | 3 | rvm: 4 | - 2.3.1 5 | 6 | install: bundle install --without development --deployment 7 | 8 | cache: 9 | directories: 10 | - vendor/bundle 11 | 12 | services: 13 | - postgresql 14 | 15 | before_script: 16 | - psql -c 'CREATE DATABASE billbo_test;' -U postgres 17 | 18 | addons: 19 | code_climate: 20 | repo_token: 21 | secure: "c8ZzZa0NJycJ/uJZw8XG2CgHwBzkBOG17ZIFFXqvARmCJHgpu5Qkt4oSG9RUQOV4AaOMLzVjY7ry1ptGgxHi0nMGwQ8R65Xtq9jM6EfGmNrA0VpEGra8JQ5pYCaFi49zMjmFthEwm14EiSnIm2+OzfWbwSwYvwAapVG6YaR9gwI=" 22 | 23 | notifications: 24 | email: false 25 | -------------------------------------------------------------------------------- /accounting/fix_vat: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require './config/environment' 3 | 4 | ARGV.each do |number| 5 | invoice = Invoice.where(number: number).first 6 | raise 'No invoice with that number' unless invoice 7 | 8 | exp_vat_rate = VatService.new.vat_rate( 9 | country_code: invoice.customer_country_code, 10 | vat_registered: invoice.customer_vat_registered 11 | ).to_i 12 | 13 | exp_vat_amount = invoice.subtotal_after_discount * exp_vat_rate / 100.0 14 | 15 | invoice.update( 16 | vat_rate: exp_vat_rate, 17 | vat_amount: exp_vat_amount, 18 | total: invoice.total + exp_vat_amount, 19 | pdf_generated_at: nil 20 | ) 21 | end 22 | -------------------------------------------------------------------------------- /lib/pdf_service.rb: -------------------------------------------------------------------------------- 1 | class PdfService 2 | 3 | def initialize(uploader: Configuration.uploader) 4 | @uploader = uploader 5 | end 6 | 7 | def generate_pdf(invoice) 8 | phantom = Shrimp::Phantom.new( 9 | "http://X:#{Configuration.api_token}@#{Configuration.host}/invoices/#{invoice.number}") 10 | phantom.to_pdf("tmp/#{invoice.number}.pdf") 11 | 12 | invoice.pdf_generated! if uploader.store!(File.open("tmp/#{invoice.number}.pdf")) 13 | end 14 | 15 | def retrieve_pdf(invoice) 16 | uploader.retrieve_from_store!("#{invoice.number}.pdf") 17 | uploader.file 18 | end 19 | 20 | private 21 | 22 | attr_reader :uploader 23 | end 24 | -------------------------------------------------------------------------------- /accounting/check_vat: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require './config/environment' 3 | 4 | Invoice.quarter(ARGV[0].to_i, ARGV[1].to_i).each do |invoice| 5 | next if invoice.credit_note? 6 | 7 | country_code = if vat = invoice.customer_vat_number 8 | vat[0..1] 9 | else 10 | invoice.customer_country_code 11 | end 12 | 13 | exp_vat_rate = VatService.new.vat_rate( 14 | country_code: country_code, 15 | vat_registered: invoice.customer_vat_registered 16 | ).to_i 17 | 18 | vat_rate = (invoice.vat_rate || 0).to_i 19 | 20 | if exp_vat_rate != vat_rate 21 | puts "#{invoice.number} (#{invoice.stripe_customer_id}): #{exp_vat_rate} (#{country_code}) != #{vat_rate}" 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /client/billbo/json_util.rb: -------------------------------------------------------------------------------- 1 | module Billbo 2 | module JsonUtil 3 | def parse_attributes(data) 4 | case data 5 | when Hash 6 | data.map do |(k,v)| 7 | { 8 | k => v && case k.to_s 9 | when /_at$/ 10 | Time.parse(v) 11 | when /_on$/ 12 | Date.parse(v) 13 | else 14 | parse_attributes(v) 15 | end 16 | } 17 | end.reduce(&:merge!) 18 | when Array 19 | data.map{|v| parse_attributes(v)} 20 | else 21 | data 22 | end 23 | end 24 | module_function :parse_attributes 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /billbo.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../client', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'billbo/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "billbo" 8 | spec.version = Billbo::VERSION 9 | spec.authors = ["Mattias Putman", "Tijs Planckaert"] 10 | spec.email = ["mattias@piesync.com", "tijs@piesync.com"] 11 | spec.summary = %q{Easy to use billing service for Stripe with VAT support} 12 | spec.homepage = "https://github.com/piesync/billbo" 13 | spec.license = "MIT" 14 | 15 | spec.files = `git ls-files -z`.split("\x0") 16 | .select { |f| f.start_with?('client/') } + ['billbo.gemspec'] 17 | spec.require_paths = ["client"] 18 | 19 | spec.add_dependency 'stripe' 20 | spec.add_dependency 'multi_json' 21 | end 22 | -------------------------------------------------------------------------------- /spec/client/billboo/json_util_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../spec_helper' 2 | 3 | describe Billbo::JsonUtil do 4 | include Billbo::JsonUtil 5 | 6 | describe 'parse_attributes' do 7 | let(:now) { Time.now } 8 | 9 | it 'parses time attributes' do 10 | parse_attributes(foo_at: now.to_s)[:foo_at]. 11 | must_be_kind_of Time 12 | end 13 | 14 | it 'parses date attributes' do 15 | parse_attributes(foo_on: now.to_s)[:foo_on]. 16 | must_be_kind_of Date 17 | end 18 | 19 | it 'handles nil' do 20 | parse_attributes(foo_on: nil)[:foo_on]. 21 | must_be_nil 22 | end 23 | 24 | it 'pass other attributes' do 25 | [1, 't', :x, true, false].each do |v| 26 | parse_attributes(foo: v)[:foo]. 27 | must_equal v 28 | end 29 | end 30 | 31 | it 'parses nested attributes' do 32 | parse_attributes(parent: [{foo_at: now.to_s}])[:parent][0][:foo_at]. 33 | must_be_kind_of Time 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014 PieSync 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby '2.3.8' 3 | 4 | gemspec 5 | 6 | gem 'dotenv' 7 | gem 'activesupport' 8 | gem 'sinatra', :require => 'sinatra/base' 9 | gem 'stripe', '>= 1.42' 10 | gem 'valvat' 11 | gem 'multi_json' 12 | gem 'oj' 13 | gem 'sequel' 14 | gem 'pg' 15 | gem 'rumor', github: 'piesync/rumor' 16 | gem 'sucker_punch' 17 | gem 'analytics-ruby', :require => 'segment' 18 | 19 | gem 'rake' 20 | 21 | gem 'shrimp' 22 | gem 'carrierwave' 23 | gem 'fog' 24 | gem 'slim' 25 | gem 'money' 26 | gem 'eu_central_bank' 27 | gem 'countries', github: 'challengee/countries' 28 | 29 | gem 'tox', github: 'piesync/tox' 30 | gem 'savon' 31 | 32 | group :test do 33 | gem 'webmock', github: 'bblimke/webmock' 34 | gem 'vcr' 35 | gem 'mocha' 36 | gem 'rack-test', :require => 'rack/test' 37 | gem 'capybara' 38 | gem 'poltergeist' 39 | gem 'timecop' 40 | end 41 | 42 | group :development do 43 | gem 'guard' 44 | gem 'guard-minitest' 45 | gem 'shotgun' 46 | gem 'choice' 47 | end 48 | 49 | group :production do 50 | gem 'puma' 51 | gem 'sentry-raven', :git => "https://github.com/getsentry/raven-ruby.git", :require => 'raven' 52 | end 53 | -------------------------------------------------------------------------------- /app/base.rb: -------------------------------------------------------------------------------- 1 | class Base < Sinatra::Base 2 | include Rumor::Source 3 | 4 | disable :show_exceptions 5 | 6 | before do 7 | content_type 'application/json' 8 | end 9 | 10 | error Stripe::APIError do |e| 11 | stripe_error(e, type: 'api_error') 12 | end 13 | 14 | error Stripe::CardError do |e| 15 | status 402 16 | stripe_error(e, type: 'card_error', code: e.code, param: e.param) 17 | end 18 | 19 | error Stripe::InvalidRequestError do |e| 20 | status 400 21 | stripe_error(e, type: 'invalid_request_error', param: e.param) 22 | end 23 | 24 | error do |e| 25 | json(error: { message: 'Something went wrong' }) 26 | end 27 | 28 | protected 29 | 30 | def json body = nil 31 | if body 32 | MultiJson.dump(body) 33 | else 34 | @json ||= MultiJson.load(request.body.read, symbolize_keys: true) 35 | end 36 | end 37 | 38 | def stripe_error(e, extra = {}) 39 | json(error: { message: e.to_s }.merge(extra)) 40 | end 41 | 42 | def invoice_service(customer_id:) 43 | InvoiceService.new(customer_id: customer_id) 44 | end 45 | 46 | def vat_service 47 | VatService.new 48 | end 49 | 50 | def pdf_service 51 | PdfService.new 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /app/hooks.rb: -------------------------------------------------------------------------------- 1 | # Receives Webhooks from Stripe. 2 | class Hooks < Base 3 | 4 | # All hooks are idempotent. 5 | # Returns 200 if successful 6 | post '/' do 7 | event = Stripe::Event.construct_from(json) 8 | 9 | # Call method based on event type if it exists. 10 | method_name = event.type.gsub('.', '_').to_sym 11 | send(method_name, event) if respond_to?(method_name, true) 12 | 13 | # Send rumor event. 14 | rumor(method_name).on(event.data.object).mention(event: event).spread(async: false) 15 | 16 | status 200 17 | end 18 | 19 | private 20 | 21 | # Used to finalize invoices (assign number). 22 | def invoice_payment_succeeded(event) 23 | stripe_invoice = Stripe::Invoice.construct_from(event.data.object) 24 | 25 | invoice_service( 26 | customer_id: stripe_invoice.customer 27 | ).process_payment( 28 | stripe_event_id: event.id, 29 | stripe_invoice_id: stripe_invoice.id 30 | ) 31 | end 32 | 33 | # Used to handle refunds and create credit notes. 34 | def charge_refunded(event) 35 | stripe_charge = Stripe::Charge.construct_from(event.data.object) 36 | 37 | # we only handle full refunds for now 38 | if stripe_charge.refunded 39 | invoice_service( 40 | customer_id: stripe_charge.customer 41 | ).process_refund( 42 | stripe_event_id: event.id, 43 | stripe_invoice_id: stripe_charge.invoice 44 | ) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $environment = :test 2 | require_relative '../config/boot' 3 | 4 | require 'minitest/spec' 5 | require 'minitest/autorun' 6 | require 'mocha/mini_test' 7 | require 'webmock/minitest' 8 | require 'capybara/poltergeist' 9 | 10 | # Configure VCR 11 | VCR.configure do |c| 12 | c.cassette_library_dir = (Pathname.new(__FILE__) + '../../spec/cassettes').to_s 13 | c.hook_into :webmock 14 | c.default_cassette_options = { 15 | record: :once, 16 | allow_unused_http_interactions: true 17 | } 18 | 19 | c.filter_sensitive_data('') do |interaction| 20 | (interaction.request.headers['Authorization'] || []).first 21 | end 22 | 23 | c.ignore_localhost = true 24 | c.ignore_hosts 'codeclimate.com' 25 | end 26 | 27 | VCR.use_cassette('configuration_preload') do 28 | require_relative '../config/environment' 29 | end 30 | 31 | # Minitest clear db hook 32 | module MiniTestHooks 33 | def setup 34 | Configuration.db[:invoices].delete 35 | end 36 | end 37 | 38 | # Include these hooks in every testcase. 39 | MiniTest::Spec.send :include, MiniTestHooks 40 | 41 | # Configure Billbo 42 | Billbo.host = 'billbo.test' 43 | Billbo.token = 'TOKEN' 44 | 45 | # Configure Capybara 46 | Capybara.default_driver = :poltergeist 47 | Capybara.app = Configuration.app 48 | 49 | # Override the Billbo host to the in-process capybara server 50 | Configuration.host = "#{Capybara.current_session.server.host}:#{Capybara.current_session.server.port}" 51 | -------------------------------------------------------------------------------- /lib/stripe_service.rb: -------------------------------------------------------------------------------- 1 | # This Service handles creating and updating Stripe subscriptions, 2 | # because these 2 actions have VAT/invoice consequences. 3 | class StripeService 4 | 5 | # customer_id - Stripe customer id. 6 | def initialize(customer_id:) 7 | @customer_id = customer_id 8 | end 9 | 10 | # Creates a subscription in Stripe with the appropriate tax_percent. 11 | # 12 | # options - All options that can be passed to Stripe subscription create. 13 | # 14 | # Returns the created Stripe subscription and invoice. 15 | def create_subscription(options) 16 | # This call automatically creates an invoice, always. 17 | customer.subscriptions.create({ 18 | tax_percent: calculate_vat_rate 19 | }.merge(options)) 20 | end 21 | 22 | def subscription(id) 23 | customer.subscriptions.retrieve(id) 24 | end 25 | 26 | # Gets metadata for the customer. 27 | def customer_metadata 28 | customer.metadata.to_h.merge!(email: customer.email) 29 | end 30 | 31 | private 32 | 33 | def calculate_vat_rate 34 | country_code = if vat = customer.metadata[:vat_number] 35 | vat[0..1] 36 | else 37 | customer.metadata[:country_code] 38 | end 39 | 40 | vat_service.vat_rate \ 41 | country_code: country_code, 42 | vat_registered: (customer.metadata[:vat_registered] == 'true') 43 | end 44 | 45 | def customer 46 | @customer ||= Stripe::Customer.retrieve(@customer_id) 47 | end 48 | 49 | def vat_service 50 | @vat_service ||= VatService.new 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /accounting/moss: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require './config/environment' 3 | 4 | quarter = ARGV[0].to_i 5 | year = (ARGV[1] || Time.now.year).to_i 6 | 7 | countries = VatService::VAT_RATES.keys - [Configuration.primary_country] 8 | 9 | invoices = Invoice.quarter(year, quarter) \ 10 | .where(customer_vat_number: nil) \ 11 | .where(customer_country_code: countries) \ 12 | .order_by(:number) 13 | 14 | output = invoices.map do |original| 15 | invoice = original.reference || original 16 | 17 | subtotal_eur = ((invoice.total_eur - invoice.vat_amount_eur).to_f / 100).round(2) 18 | vat_eur = (invoice.vat_amount_eur.to_f / 100).round(2) 19 | total_eur = (invoice.total_eur.to_f / 100).round(2) 20 | 21 | if original.credit_note? 22 | subtotal_eur = -subtotal_eur 23 | vat_eur = -vat_eur 24 | total_eur = -total_eur 25 | end 26 | 27 | cols = [ 28 | original.number, 29 | subtotal_eur, 30 | vat_eur, 31 | total_eur, 32 | invoice.customer_country_code 33 | ] 34 | 35 | raise "No customer country code for #{invoice.number}" unless invoice.customer_country_code 36 | 37 | country_index = countries.index(invoice.customer_country_code) 38 | 39 | offset = cols.size 40 | 41 | cols[offset + country_index*2] = total_eur 42 | cols[offset + country_index*2 + 1] = vat_eur 43 | 44 | cols 45 | end 46 | 47 | puts ([ 48 | 'Number', 49 | 'Net', 50 | 'VAT', 51 | 'Gross', 52 | 'Country Code' 53 | ] + countries.zip(countries).flatten.zip(['Revenue', 'VAT'].cycle).map { |c| c.join(' ')} ).join("\t") 54 | 55 | puts output.map { |i| i.join("\t") }.join("\n") 56 | -------------------------------------------------------------------------------- /app/job.rb: -------------------------------------------------------------------------------- 1 | # Gathers VIES data and generates PDFs. 2 | class Job 3 | 4 | def perform 5 | # Iterate over all invoice we did not generate a PDF for yet. 6 | invoices = Invoice.where(pdf_generated_at: nil,reserved_at: nil) 7 | .where('finalized_at IS NOT NULL') 8 | 9 | perform_for(invoices) 10 | end 11 | 12 | def perform_for(invoices) 13 | # Update Exchange rates from ECB 14 | Money.default_bank.update_rates 15 | 16 | invoices.each do |invoice| 17 | perform_for_single(invoice) 18 | end 19 | end 20 | 21 | private 22 | 23 | def perform_for_single(invoice) 24 | if !invoice.credit_note? 25 | # First load VIES data into the invoice. 26 | vat_service.load_vies_data(invoice: invoice) if invoice.customer_vat_number 27 | 28 | # Calculate total and vat amount in euro. 29 | invoice.update \ 30 | exchange_rate_eur: Money.new(100, invoice.currency).exchange_to(:eur).to_f, 31 | vat_amount_eur: Money.new(invoice.vat_amount, invoice.currency).exchange_to(:eur).cents, 32 | total_eur: Money.new(invoice.total, invoice.currency).exchange_to(:eur).cents 33 | end 34 | 35 | # Now generate an invoice. 36 | pdf_service.generate_pdf(invoice) 37 | 38 | rescue VatService::ViesDown => e 39 | # Just wait until it's up again... 40 | rescue StandardError => e 41 | if Configuration.sentry? 42 | Raven.capture_exception(e, extra: { 43 | invoice: invoice.id 44 | }) 45 | else 46 | raise e 47 | end 48 | end 49 | 50 | def vat_service 51 | @vat_service ||= VatService.new 52 | end 53 | 54 | def pdf_service 55 | @pdf_service ||= PdfService.new 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/pdf_service_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe PdfService do 4 | 5 | let(:stripe_event_id) { 'xxx' } 6 | let(:service) { PdfService.new } 7 | let(:invoice_service) { InvoiceService.new(customer_id: customer.id) } 8 | 9 | let(:customer) do 10 | Stripe::Customer.create \ 11 | card: { 12 | number: '4242424242424242', 13 | exp_month: '12', 14 | exp_year: '30', 15 | cvc: '222' 16 | } 17 | end 18 | 19 | let(:plan) do 20 | begin 21 | Stripe::Plan.retrieve('test') 22 | rescue 23 | Stripe::Plan.create \ 24 | id: 'test', 25 | name: 'Test Plan', 26 | amount: 1499, 27 | currency: 'usd', 28 | interval: 'month' 29 | end 30 | end 31 | 32 | describe '#generate_pdf, #retrieve_pdf' do 33 | it 'generates a pdf representiation of an invoice and stores it' do 34 | VCR.use_cassette('pdf_generation') do 35 | invoice_service.create_subscription(plan: plan.id) 36 | invoice_service.process_payment( 37 | stripe_event_id: stripe_event_id, 38 | stripe_invoice_id: invoice_service.last_stripe_invoice.id 39 | ) 40 | 41 | invoice = Invoice.first 42 | 43 | service.generate_pdf(invoice) 44 | 45 | invoice = invoice.reload 46 | invoice.pdf_generated_at.wont_be_nil 47 | 48 | uploader = Configuration.uploader 49 | uploader.retrieve_from_store!("#{invoice.number}.pdf") 50 | 51 | exists = File.exists?( 52 | File.expand_path("../../#{uploader.store_path}/#{uploader.filename}", __FILE__)) 53 | 54 | exists.must_equal true 55 | 56 | # Retrieve pdf 57 | service = PdfService.new 58 | service.retrieve_pdf(invoice).filename.must_equal "#{invoice.number}.pdf" 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /app/analytics_channel.rb: -------------------------------------------------------------------------------- 1 | class AnalyticsChannel < Rumor::Channel 2 | 3 | def initialize(segmentio) 4 | @segmentio = segmentio 5 | end 6 | 7 | on(:charge_succeeded) do |rumor| 8 | track_revenue(charge: rumor.subject) 9 | end 10 | 11 | on(:charge_refunded) do |rumor| 12 | track_revenue(charge: rumor.subject, negative: true) 13 | end 14 | 15 | on(:customer_subscription_created) do |rumor| 16 | end 17 | 18 | on(:customer_subscription_updated) do |rumor| 19 | end 20 | 21 | on(:customer_subscription_deleted) do |rumor| 22 | end 23 | 24 | private 25 | 26 | def track_revenue(charge:, negative: false) 27 | charge = Stripe::Charge.construct_from(charge) 28 | 29 | # Get analytics id based on Stripe customer. 30 | analytics_id = get_analytics_id_for_customer(charge.customer) 31 | 32 | # Revenue from cents. 33 | revenue = charge.amount / 100.0 34 | revenue = -revenue if negative 35 | 36 | # We need to subtract the vat amount before tracking it. 37 | # Subtracts 0 if vat_amount is nil. 38 | # TK can we do this using DB only? issue #26 39 | invoice = Stripe::Invoice.retrieve(charge.invoice) 40 | vat = invoice.metadata[:vat_amount].to_i / 100.0 41 | vat = -vat if negative 42 | revenue -= vat 43 | 44 | # Track. 45 | track analytics_id, 'revenue changed', 46 | revenue: revenue, 47 | currency: invoice.currency, 48 | vat_amount: vat, 49 | vat_rate: invoice.metadata[:vat_rate] || '0', 50 | total: revenue + vat 51 | end 52 | 53 | def get_analytics_id_for_customer(customer_id) 54 | customer = Stripe::Customer.retrieve(customer_id) 55 | customer.metadata[:analytics_id] || customer.email 56 | end 57 | 58 | def track user, event, properties = {} 59 | @segmentio.track \ 60 | user_id: user, 61 | event: event, 62 | properties: properties 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /config/schema.rb: -------------------------------------------------------------------------------- 1 | # version 0 2 | Configuration.db.create_table :invoices do 3 | 4 | # Ids 5 | primary_key :id 6 | String :stripe_id, unique: true 7 | String :stripe_customer_id 8 | String :stripe_subscription_id 9 | 10 | # Numbering 11 | Integer :year 12 | Integer :sequence_number 13 | String :number, unique: true 14 | 15 | # Invoice lifecycle 16 | Boolean :added_vat 17 | Time :finalized_at 18 | Time :reserved_at 19 | String :interval 20 | 21 | # Credit notes 22 | Boolean :credit_note, default: false 23 | String :reference_number 24 | 25 | # PDF generation 26 | Time :pdf_generated_at 27 | 28 | # Amounts 29 | Integer :subtotal 30 | Integer :discount_amount 31 | Integer :subtotal_after_discount 32 | Integer :vat_amount 33 | Decimal :vat_rate 34 | Integer :total 35 | String :currency 36 | 37 | # Used exchange rate and amounts in EUR 38 | Decimal :exchange_rate_eur 39 | Integer :vat_amount_eur 40 | Integer :total_eur 41 | 42 | # Card used to pay the invoice 43 | String :card_brand 44 | String :card_last4 45 | String :card_country_code 46 | 47 | # Snapshot of customer 48 | String :customer_email 49 | String :customer_name 50 | String :customer_company_name 51 | String :customer_country_code 52 | String :customer_address 53 | Boolean :customer_vat_registered 54 | String :customer_vat_number 55 | String :customer_accounting_id 56 | 57 | # GeoIP information 58 | String :ip_address 59 | String :ip_country_code 60 | 61 | # VIES information 62 | String :vies_company_name 63 | String :vies_address 64 | String :vies_request_identifier 65 | 66 | end unless Configuration.db.table_exists?(:invoices) 67 | 68 | # version 1 69 | Configuration.db.add_column( 70 | :invoices, :stripe_event_id, String 71 | ) unless Configuration.db[:invoices].columns.include?(:stripe_event_id) 72 | 73 | # version 2 74 | Configuration.db.add_column( 75 | :invoices, :stripe_subscription_id, String 76 | ) unless Configuration.db[:invoices].columns.include?(:stripe_subscription_id) 77 | -------------------------------------------------------------------------------- /deploy-heroku: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'securerandom' 4 | require 'choice' 5 | 6 | Choice.options do 7 | banner 'Usage: ./deploy-heroku [APPNAME] options...' 8 | option :api_token do 9 | short '-t' 10 | long '--token=TOKEN' 11 | desc 'Security token used to access the API of your Billbo instance' 12 | default SecureRandom.uuid 13 | end 14 | 15 | option :stripe_secret_key, required: true do 16 | short '-s' 17 | long '--stripe=STIPE_SECRET_KEY' 18 | desc 'Your secret Stripe API key' 19 | end 20 | 21 | option :segmentio_write_key do 22 | long '--segmentio=SEGMENTIO_WRITE_KEY' 23 | desc 'Your Segment.io write key' 24 | end 25 | 26 | option :invoice_number_format do 27 | long '--nrformat=INVOICE_NUMBER_FORMAT' 28 | desc 'String format for invoice numbers (printf), default: %{year}%06d' 29 | end 30 | 31 | option :sentry_dsn do 32 | long '--sentry=SENTRY_DSN' 33 | desc 'Sentry endpoint to send errors to' 34 | end 35 | end 36 | 37 | app = ARGV[0] 38 | 39 | if app.nil? 40 | puts 'Usage: ./deploy-heroku [APPNAME]' 41 | exit 42 | end 43 | 44 | # Check if the app already exists. 45 | `heroku ps --app #{app} > /dev/null 2>&1` 46 | exists = $?.success? 47 | 48 | if exists 49 | puts 'App already exists... Try again' 50 | else 51 | puts "Deploying for the first time... Creating #{app} on Heroku" 52 | `heroku create #{app} --addons heroku-postgresql` 53 | 54 | puts "Pushing..." 55 | `git push git@heroku.com:#{app}.git master --force` 56 | 57 | variables = [ 58 | :api_token, 59 | :stripe_secret_key, 60 | :segmentio_write_key, 61 | :invoice_number_format, 62 | :sentry_dsn 63 | ] 64 | 65 | heroku_env = variables.map do |key| 66 | [key.to_s.upcase, Choice.choices[key]].join('=') if Choice.choices[key] 67 | end.join(' ') 68 | 69 | puts 'Configuring Remote Billbo Instance...' 70 | `heroku config:set #{heroku_env} BUILDPACK_URL=https://github.com/ddollar/heroku-buildpack-multi --app #{app}` 71 | 72 | puts 'Billbo has been deployed to Heroku' 73 | puts "Check this url to confirm that it works: https://X:#{Choice.choices[:api_token]}@#{app}.herokuapp.com/ping" 74 | end 75 | -------------------------------------------------------------------------------- /spec/job_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe Job do 4 | 5 | describe '#perform' do 6 | it 'calls perform for for all missing pdf invoices' do 7 | invoices = 5.times.map { Invoice.new.finalize! } 8 | Invoice.create 9 | 10 | job = Job.new 11 | 12 | job.expects(:perform_for).once.with do |invoices| 13 | invoices.to_a.size == 5 14 | end 15 | 16 | job.perform 17 | 18 | invoices.take(3).each(&:pdf_generated!) 19 | 20 | job.expects(:perform_for).once.with do |invoices| 21 | invoices.to_a.size == 2 22 | end 23 | 24 | job.perform 25 | end 26 | end 27 | 28 | describe '#perform_for' do 29 | it 'loads euro amounts and generates a pdf' do 30 | VCR.use_cassette('job_exchange_rates') do 31 | invoice = Invoice.create(vat_amount: 100, total: 1000, currency: 'usd') 32 | 33 | VatService.any_instance.expects(:load_vies_data).with(invoice: invoice).never 34 | PdfService.any_instance.expects(:generate_pdf).with(invoice).once 35 | 36 | Job.new.perform_for([invoice]) 37 | 38 | invoice = invoice.reload 39 | 40 | (invoice.vat_amount_eur > 0).must_equal true 41 | (invoice.total_eur > 0).must_equal true 42 | (invoice.exchange_rate_eur > 0).must_equal true 43 | end 44 | end 45 | 46 | describe 'a vat number is present' do 47 | it 'loads vies data, euro amounts and generates a pdf' do 48 | VCR.use_cassette('job_vat') do 49 | invoice = Invoice.create(vat_amount: 100, total: 1000, currency: 'usd', customer_vat_number: 'something') 50 | 51 | PdfService.any_instance.expects(:generate_pdf).with(invoice).once 52 | 53 | Job.new.perform_for([invoice]) 54 | end 55 | end 56 | end 57 | 58 | describe 'called with a credit note' do 59 | it 'generates a pdf' do 60 | VCR.use_cassette('job_credit_note') do 61 | invoice = Invoice.create(vat_amount: 100, total: 1000, currency: 'usd') 62 | credit_note = Invoice.create(credit_note: true, reference_number: invoice.number) 63 | 64 | PdfService.any_instance.expects(:generate_pdf).with(credit_note).once 65 | 66 | Job.new.perform_for([credit_note]) 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | $environment ||= (ENV['RACK_ENV'] || :development).to_sym 2 | $token = ENV['API_TOKEN'] || 'billbo' 3 | 4 | # Include lib 5 | %w{app lib config}.each do |dir| 6 | $: << File.expand_path("../../#{dir}", __FILE__) 7 | end 8 | 9 | # Bundle (gems) 10 | require 'boot' 11 | 12 | I18n.config.enforce_available_locales = true 13 | 14 | # Require needed active support bits 15 | require 'active_support/core_ext/integer' 16 | 17 | # Load Environment variables from env files 18 | Dotenv.load( 19 | File.expand_path("../../.env.#{$environment}", __FILE__), 20 | File.expand_path('../../.env', __FILE__) 21 | ) 22 | 23 | # Configuration 24 | require 'configuration_service' 25 | require 'configuration' 26 | 27 | Configuration.from_env 28 | 29 | # Connect to database 30 | Configuration.db = Sequel.connect(Configuration.database_url) 31 | 32 | # Configure Stripe 33 | Stripe.api_key = Configuration.stripe_secret_key 34 | Stripe.api_version = '2015-10-16' 35 | 36 | # Configure Shrimp 37 | Shrimp.configure do |config| 38 | config.format = 'A4' 39 | config.zoom = 1 40 | config.orientation = 'portrait' 41 | end 42 | 43 | # Configure Money 44 | Money.default_bank = EuCentralBank.new 45 | 46 | # Configure Timeouts for VIES checks 47 | { 48 | open_timeout: 10, 49 | read_timeout: 10 50 | }.each do |key, d| 51 | Valvat::Lookup.client.globals[key] = d 52 | end 53 | 54 | # Configure Rumor 55 | require 'rumor/async/sucker_punch' 56 | 57 | # DB schema 58 | require 'schema' 59 | 60 | # Models 61 | require 'invoice' 62 | 63 | # Invoice generation. 64 | require 'invoice_file_uploader' 65 | require 'invoice_cloud_uploader' 66 | 67 | # Services 68 | require 'vat_service' 69 | require 'stripe_service' 70 | require 'invoice_service' 71 | require 'pdf_service' 72 | 73 | # The Apis 74 | require 'base' 75 | require 'hooks' 76 | require 'app' 77 | 78 | # Load plugins 79 | require 'plugins/sentry' if Configuration.sentry? 80 | require 'plugins/segmentio' if Configuration.segmentio? 81 | require 'plugins/s3' if Configuration.s3? 82 | 83 | # The Rack app 84 | require 'rack_app' 85 | 86 | # Cronjob 87 | require 'job' 88 | 89 | # Preload and validate configuration 90 | Configuration.preload 91 | raise 'configuration not valid' unless Configuration.valid? 92 | 93 | # Disconnect before forking. 94 | Configuration.db.disconnect 95 | -------------------------------------------------------------------------------- /app/template_view_model.rb: -------------------------------------------------------------------------------- 1 | require 'forwardable' 2 | 3 | class TemplateViewModel 4 | extend Forwardable 5 | attr_reader :credit_note 6 | 7 | def initialize(invoice:, stripe_invoice:, stripe_coupon:, credit_note: nil) 8 | @invoice = invoice 9 | @stripe_invoice = stripe_invoice 10 | @stripe_coupon = stripe_coupon 11 | @credit_note = credit_note 12 | end 13 | 14 | # Delegate correct methods to underlaying models. 15 | def_delegators :invoice, 16 | :number, :finalized_at, :paid?, :card_brand, :card_last4, :due_at, :total, :currency, 17 | :customer_name, :customer_company_name, :customer_address, :customer_country_code, 18 | :customer_vat_registered?, :customer_vat_number, :discount?, :discount_amount, 19 | :subtotal_after_discount, :vat?, :vat_rate, :vat_amount, :vat_amount_eur, :total, 20 | :total_eur, :eu? 21 | 22 | def_delegators :stripe_invoice, :lines 23 | 24 | def_delegators :configuration, 25 | :seller_logo_url, :seller_company_name, :seller_address_line1, :seller_address_line2, 26 | :primary_country, :seller_email, :seller_vat_number, :seller_other_info, 27 | :seller_bank_name, :seller_bic, :seller_iban 28 | 29 | def finalized_at 30 | (credit_note || invoice).finalized_at 31 | end 32 | 33 | def due_at 34 | (credit_note || invoice).due_at 35 | end 36 | 37 | def credit_note? 38 | @credit_note 39 | end 40 | 41 | def document_type 42 | if @credit_note || total.to_i < 0.0 43 | 'Credit Note' 44 | else 45 | 'Invoice' 46 | end 47 | end 48 | 49 | private 50 | 51 | attr_reader :invoice, :stripe_invoice, :stripe_coupon 52 | 53 | def configuration 54 | Configuration 55 | end 56 | 57 | def format_money(amount, currency) 58 | Money.new(amount, currency).format(sign_before_symbol: true) 59 | end 60 | 61 | def subscription_line_description(line) 62 | # If line item has type subscription, then the line metadata is the subscription metadata (version > 2014-11-20) 63 | description = line.metadata[:description] || line.plan.name 64 | 65 | "#{description} (#{format_date(line.period.start)} - #{format_date(line.period.end)})" 66 | end 67 | 68 | def percent_off 69 | stripe_coupon.try(:percent_off) 70 | end 71 | 72 | def currency_symbol(currency) 73 | Money::Currency.new(currency).symbol 74 | end 75 | 76 | def format_date(timestamp) 77 | at = Time.at(timestamp) 78 | at.strftime("%B #{at.day.ordinalize}, %Y") 79 | end 80 | 81 | def country_name(code) 82 | code && Country.new(code).name 83 | end 84 | end 85 | 86 | -------------------------------------------------------------------------------- /spec/analytics_channel_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | require 'analytics_channel' 3 | 4 | describe AnalyticsChannel do 5 | include Rumor::Source 6 | 7 | let(:analytics) { mock } 8 | let(:channel) { AnalyticsChannel.new(analytics) } 9 | 10 | let(:customer_id) { 'c1' } 11 | let(:customer) do 12 | Stripe::Customer.construct_from \ 13 | email: 'oss@piesync.com', 14 | metadata: {} 15 | end 16 | 17 | let(:invoice_id) { 'i1' } 18 | let(:invoice) do 19 | Stripe::Invoice.construct_from \ 20 | currency: 'usd', 21 | metadata: { 22 | vat_amount: '210', 23 | vat_rate: '21' 24 | } 25 | end 26 | 27 | let(:amount) { 1210 } 28 | let(:charge) do 29 | { amount: amount, customer: customer_id, invoice: invoice_id } 30 | end 31 | 32 | before do 33 | Stripe::Customer.stubs(:retrieve) 34 | .with(customer_id) 35 | .returns(customer) 36 | 37 | Stripe::Invoice.stubs(:retrieve) 38 | .with(invoice_id) 39 | .returns(invoice) 40 | end 41 | 42 | describe 'charge_succeeded' do 43 | it 'tracks revenue changed' do 44 | analytics.expects(:track).with \ 45 | user_id: 'oss@piesync.com', 46 | event: 'revenue changed', 47 | properties: { 48 | revenue: 10.0, 49 | currency: 'usd', 50 | vat_amount: 2.1, 51 | vat_rate: '21', 52 | total: 12.1 53 | } 54 | 55 | channel.handle rumor(:charge_succeeded).on(charge) 56 | end 57 | 58 | describe 'vat amount not available' do 59 | let(:amount) { 1000 } 60 | let(:invoice) do 61 | Stripe::Invoice.construct_from \ 62 | currency: 'usd', 63 | metadata: {} 64 | end 65 | 66 | it 'tracks revenue changed' do 67 | analytics.expects(:track).with \ 68 | user_id: 'oss@piesync.com', 69 | event: 'revenue changed', 70 | properties: { 71 | revenue: 10.0, 72 | currency: 'usd', 73 | vat_amount: 0.0, 74 | vat_rate: '0', 75 | total: 10.0 76 | } 77 | 78 | channel.handle rumor(:charge_succeeded).on(charge) 79 | end 80 | end 81 | end 82 | 83 | describe 'charge_refunded' do 84 | it 'tracks revenue changed' do 85 | analytics.expects(:track).with \ 86 | user_id: 'oss@piesync.com', 87 | event: 'revenue changed', 88 | properties: { 89 | revenue: -10.0, 90 | currency: 'usd', 91 | vat_amount: -2.1, 92 | vat_rate: '21', 93 | total: -12.1 94 | } 95 | 96 | channel.handle rumor(:charge_refunded).on(charge) 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/cassettes/job_vat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept-Encoding: 11 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 12 | Accept: 13 | - "*/*" 14 | User-Agent: 15 | - Ruby 16 | response: 17 | status: 18 | code: 200 19 | message: OK 20 | headers: 21 | Server: 22 | - myracloud 23 | Date: 24 | - Tue, 18 Sep 2018 10:41:34 GMT 25 | Content-Type: 26 | - text/xml 27 | Transfer-Encoding: 28 | - chunked 29 | Connection: 30 | - keep-alive 31 | Last-Modified: 32 | - Mon, 17 Sep 2018 13:55:12 GMT 33 | Etag: 34 | - '"681-5761185a11000"' 35 | Expires: 36 | - Mon, 17 Sep 2018 14:06:25 GMT 37 | Cache-Control: 38 | - max-age=300 39 | X-Cdn: 40 | - '1' 41 | body: 42 | encoding: UTF-8 43 | string: "\n\n\tReference 45 | rates\n\t\n\t\tEuropean Central 46 | Bank\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n" 63 | http_version: 64 | recorded_at: Tue, 18 Sep 2018 10:41:34 GMT 65 | recorded_with: VCR 2.9.2 66 | -------------------------------------------------------------------------------- /spec/cassettes/job_credit_note.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept-Encoding: 11 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 12 | Accept: 13 | - "*/*" 14 | User-Agent: 15 | - Ruby 16 | response: 17 | status: 18 | code: 200 19 | message: OK 20 | headers: 21 | Server: 22 | - myracloud 23 | Date: 24 | - Tue, 18 Sep 2018 10:41:35 GMT 25 | Content-Type: 26 | - text/xml 27 | Transfer-Encoding: 28 | - chunked 29 | Connection: 30 | - keep-alive 31 | Last-Modified: 32 | - Mon, 17 Sep 2018 13:55:12 GMT 33 | Etag: 34 | - '"681-5761185a11000"' 35 | Expires: 36 | - Mon, 17 Sep 2018 14:06:25 GMT 37 | Cache-Control: 38 | - max-age=300 39 | X-Cdn: 40 | - '1' 41 | body: 42 | encoding: UTF-8 43 | string: "\n\n\tReference 45 | rates\n\t\n\t\tEuropean Central 46 | Bank\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n" 63 | http_version: 64 | recorded_at: Tue, 18 Sep 2018 10:41:35 GMT 65 | recorded_with: VCR 2.9.2 66 | -------------------------------------------------------------------------------- /spec/cassettes/job_exchange_rates.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept-Encoding: 11 | - gzip;q=1.0,deflate;q=0.6,identity;q=0.3 12 | Accept: 13 | - "*/*" 14 | User-Agent: 15 | - Ruby 16 | response: 17 | status: 18 | code: 200 19 | message: OK 20 | headers: 21 | Server: 22 | - myracloud 23 | Date: 24 | - Tue, 18 Sep 2018 10:41:31 GMT 25 | Content-Type: 26 | - text/xml 27 | Transfer-Encoding: 28 | - chunked 29 | Connection: 30 | - keep-alive 31 | Last-Modified: 32 | - Mon, 17 Sep 2018 13:55:12 GMT 33 | Etag: 34 | - '"681-5761185a11000"' 35 | Expires: 36 | - Mon, 17 Sep 2018 14:06:25 GMT 37 | Cache-Control: 38 | - max-age=300 39 | X-Cdn: 40 | - '1' 41 | body: 42 | encoding: UTF-8 43 | string: "\n\n\tReference 45 | rates\n\t\n\t\tEuropean Central 46 | Bank\n\t\n\t\n\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\t\n\t\t\n\t\n" 63 | http_version: 64 | recorded_at: Tue, 18 Sep 2018 10:41:31 GMT 65 | recorded_with: VCR 2.9.2 66 | -------------------------------------------------------------------------------- /migrations/taxpercent.rb: -------------------------------------------------------------------------------- 1 | require './config/environment' 2 | 3 | execute = ARGV[1] == '--execute' 4 | 5 | def vat_rate(customer) 6 | VatService.new.vat_rate( 7 | country_code: customer.metadata[:country_code], 8 | vat_registered: (customer.metadata[:vat_registered] == 'true') 9 | ) 10 | end 11 | 12 | def safe(subject) 13 | begin 14 | yield 15 | rescue StandardError => e 16 | puts "Something went wrong with #{subject}!" 17 | puts e.message, *e.backtrace 18 | end 19 | end 20 | 21 | puts "Migrating subscriptions\n" 22 | 23 | customers = Stripe::Customer.all(limit: 100) 24 | 25 | while !customers.data.empty? 26 | customers.each do |customer| 27 | safe(customer.id) do 28 | vat_rate = vat_rate(customer) 29 | 30 | if !vat_rate.zero? 31 | customer.subscriptions.each do |subscription| 32 | puts "migrating #{subscription.id} subscription for #{customer.id} (#{customer.email})" 33 | puts "country: #{customer.metadata[:country_code]} vat: #{customer.metadata[:vat_registered]}" 34 | puts "vat: #{vat_rate}" 35 | 36 | if execute 37 | puts "EXEC" 38 | subscription.tax_percent = vat_rate 39 | subscription.save 40 | end 41 | 42 | puts 43 | end 44 | end 45 | end 46 | end 47 | 48 | # Fetch more customers. 49 | puts "Fetching more customers!" 50 | customers = Stripe::Customer.all(limit: 100, starting_after: customers.data.last.id) 51 | end 52 | 53 | puts "Migrating Unpaid invoices\n" 54 | 55 | invoices = Stripe::Invoice.all(limit: 100) 56 | 57 | while !invoices.data.empty? 58 | invoices.each do |invoice| 59 | safe(invoice.id) do 60 | # If the invoice is unpaid we can still fix taxes! 61 | # If VAT has been added already, remove it and readd using tax_percent. 62 | # This will allow us to deal with old VAT when the invoice gets paid. 63 | if !invoice.paid 64 | 65 | # Fetch the customer and see if VAT should be paid. 66 | customer = Stripe::Customer.retrieve(invoice.customer) 67 | vat_rate = vat_rate(customer) 68 | 69 | if line = invoice.lines.find { |line| line.metadata[:type] == 'vat' } 70 | item = Stripe::InvoiceItem.retrieve(line.id) 71 | puts "removing old VAT (#{item.metadata[:rate]}) from #{invoice.id} of #{customer.id} (#{customer.email})" 72 | 73 | if execute 74 | puts "EXEC" 75 | is_closed = invoice.closed 76 | 77 | # Reopen the invoice for modification if it was closed. 78 | if is_closed 79 | puts 'reopening invoice' 80 | invoice.closed = false 81 | invoice.save 82 | end 83 | 84 | item.delete 85 | 86 | if is_closed 87 | puts 'closing again' 88 | invoice.closed = true 89 | invoice.save 90 | end 91 | end 92 | end 93 | 94 | if !vat_rate.zero? 95 | # VAT needs to be added! 96 | puts "adding VAT to #{invoice.id} of #{customer.id} (#{customer.email})" 97 | puts "country: #{customer.metadata[:country_code]} vat: #{customer.metadata[:vat_registered]}" 98 | puts "vat: #{vat_rate}" 99 | 100 | if execute 101 | puts "EXEC" 102 | invoice.tax_percent = vat_rate 103 | invoice.save 104 | end 105 | 106 | puts 107 | end 108 | end 109 | end 110 | end 111 | 112 | # Fetch more invoices. 113 | puts "Fetching more invoices!" 114 | invoices = Stripe::Invoice.all(limit: 100, starting_after: invoices.data.last.id) 115 | end 116 | -------------------------------------------------------------------------------- /spec/vat_service_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe VatService do 4 | 5 | let(:service) { VatService.new } 6 | 7 | describe '#calculate' do 8 | it 'calculates correct VAT amount' do 9 | example(100, 'US', true).amount.must_equal(0) 10 | example(100, 'US', false).amount.must_equal(0) 11 | 12 | example(100, 'FR', true).amount.must_equal(0) 13 | example(100, 'FR', false).amount.must_equal(20) 14 | 15 | example(100, 'BE', false).amount.must_equal(21) 16 | example(100, 'BE', true).amount.must_equal(21) 17 | 18 | # Rounded. 19 | example(2010, 'BE', true).amount.must_equal(422) 20 | example(2060, 'BE', true).amount.must_equal(433) 21 | 22 | # No country. 23 | example(1000, nil, true).amount.must_equal(0) 24 | end 25 | 26 | it 'works for the canary islands' do 27 | example(100, 'IC', true).amount.must_equal(0) 28 | end 29 | end 30 | 31 | describe '#validate' do 32 | it 'validates the VAT number' do 33 | VCR.use_cassette('validate_vat') do 34 | service.valid?(vat_number: 'LU21416127').must_equal true 35 | service.valid?(vat_number: 'IE6388047V').must_equal true 36 | service.valid?(vat_number: 'LU21416128').must_equal false 37 | end 38 | end 39 | 40 | it 'validates the VAT number using the checksum' do 41 | Valvat::Lookup.stubs(:validate).returns(nil) 42 | service.valid?(vat_number: 'LU21416127').must_equal true 43 | service.valid?(vat_number: 'IE6388047V').must_equal true 44 | service.valid?(vat_number: 'LU21416128').must_equal false 45 | end 46 | end 47 | 48 | describe '#details' do 49 | it 'returns details about the VAT number' do 50 | VCR.use_cassette('details_vat') do 51 | details = service.details(vat_number: 'LU21416127') 52 | 53 | details[:country_code].must_equal 'LU' 54 | details[:vat_number].must_equal '21416127' 55 | details[:name].must_equal 'EBAY EUROPE S.A R.L.' 56 | details[:address].must_equal "22, BOULEVARD ROYAL\nL-2449 LUXEMBOURG" 57 | details[:request_identifier].must_be_nil 58 | end 59 | end 60 | 61 | it 'returns details about the VAT number and a request identifier' do 62 | VCR.use_cassette('details_own_vat') do 63 | details = service.details(vat_number: 'LU21416127', own_vat: 'IE6388047V') 64 | 65 | details[:country_code].must_equal 'LU' 66 | details[:vat_number].must_equal '21416127' 67 | details[:name].must_equal 'EBAY EUROPE S.A R.L.' 68 | details[:address].must_equal "22, BOULEVARD ROYAL\nL-2449 LUXEMBOURG" 69 | details[:request_identifier].wont_be_nil 70 | end 71 | end 72 | 73 | describe 'the VIES service is down' do 74 | it 'raises an error' do 75 | Valvat.any_instance.stubs(:exists?).returns(nil) 76 | 77 | proc do 78 | service.details(vat_number: 'LU21416127') 79 | end.must_raise(VatService::ViesDown) 80 | end 81 | end 82 | end 83 | 84 | describe '#load_vies_data' do 85 | it 'loads vies data into an invoice' do 86 | VCR.use_cassette('load_vies_data') do 87 | invoice = Invoice.create(customer_vat_number: 'LU21416127') 88 | 89 | service.load_vies_data(invoice: invoice) 90 | 91 | invoice = invoice.reload 92 | 93 | invoice.vies_company_name.must_equal 'EBAY EUROPE S.A R.L.' 94 | invoice.vies_address.must_equal "22, BOULEVARD ROYAL\nL-2449 LUXEMBOURG" 95 | invoice.vies_request_identifier.wont_be_nil 96 | end 97 | end 98 | end 99 | 100 | def example amount, country_code, company 101 | service.calculate( 102 | amount: amount, country_code: country_code, vat_registered: company) 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /config/configuration.rb: -------------------------------------------------------------------------------- 1 | class Configuration 2 | REQUIRED = [ 3 | :api_token, 4 | :stripe_secret_key, 5 | :invoice_number_format, 6 | :database_url, 7 | :primary_country, 8 | :due_days 9 | ] 10 | 11 | SERVICE = ConfigurationService.new 12 | 13 | class << self 14 | # Handle to the Rack app 15 | attr_accessor :app 16 | 17 | # Billbo host 18 | attr_accessor :host 19 | 20 | # Security token used to access the API of the Billbo instance 21 | attr_accessor :api_token 22 | 23 | # Secret Stripe API key 24 | attr_accessor :stripe_secret_key 25 | 26 | # Segment.io write key (revenue analytics) 27 | attr_accessor :segmentio_write_key 28 | 29 | # Sentry endpoint to send errors to 30 | attr_accessor :sentry_dsn 31 | 32 | # String format for invoice numbers (printf), default: %{year}%06d 33 | attr_accessor :invoice_number_format 34 | 35 | # SQL database url 36 | attr_accessor :database_url 37 | 38 | # Primary selling country 39 | attr_accessor :primary_country 40 | 41 | # Default currency of the seller 42 | attr_accessor :default_currency 43 | 44 | # Address of the seller 45 | attr_accessor :seller_address_line1 46 | attr_accessor :seller_address_line2 47 | attr_accessor :seller_street, :seller_zip, :seller_city 48 | 49 | # VAT number of the seller 50 | attr_accessor :seller_vat_number 51 | 52 | # Other information of the seller that the invoice must contain 53 | attr_accessor :seller_other_info 54 | 55 | # Bank name of the seller 56 | attr_accessor :seller_bank_name 57 | 58 | # BIC code of the seller's bank 59 | attr_accessor :seller_bic 60 | 61 | # IBAN number of the seller's bank account 62 | attr_accessor :seller_iban 63 | 64 | # Seller company name 65 | attr_accessor :seller_company_name 66 | 67 | # Seller logo 68 | attr_accessor :seller_logo_url 69 | 70 | # Seller email 71 | attr_accessor :seller_email 72 | 73 | # Segment.io analytics handle 74 | attr_accessor :segmentio 75 | 76 | # Database handle 77 | attr_accessor :db 78 | 79 | # Amount of days until invoice is due 80 | attr_accessor :due_days 81 | 82 | # configuration for IC listing 83 | attr_accessor :ic_email, 84 | :ic_phone, 85 | :ic_reference 86 | 87 | # S3 invoice storage 88 | attr_accessor :s3_key_id 89 | attr_accessor :s3_secret_key 90 | attr_accessor :s3_bucket 91 | attr_accessor :s3_region 92 | 93 | def due_days 94 | @due_days.to_i 95 | end 96 | 97 | def from_env(env = ENV) 98 | env.each do |key, value| 99 | method = "#{key.downcase}=" 100 | send(method, value) if respond_to?(method) 101 | end 102 | 103 | self 104 | end 105 | 106 | def primary_country 107 | @primary_country ||= account.country 108 | end 109 | 110 | # TK extend to additional countries next to primary 111 | def registered_countries 112 | [primary_country] 113 | end 114 | 115 | def default_currency 116 | @default_currency ||= account.default_currency 117 | end 118 | 119 | def preload 120 | primary_country 121 | 122 | self 123 | end 124 | 125 | def valid? 126 | REQUIRED.none? { |field| send(field).nil? } 127 | end 128 | 129 | def sentry? 130 | !sentry_dsn.nil? 131 | end 132 | 133 | def segmentio? 134 | !segmentio_write_key.nil? 135 | end 136 | 137 | def s3? 138 | !s3_key_id.nil? 139 | end 140 | 141 | def uploader 142 | if s3? 143 | InvoiceCloudUploader.new 144 | else 145 | InvoiceFileUploader.new 146 | end 147 | end 148 | 149 | private 150 | 151 | def account 152 | @_account ||= SERVICE.account 153 | end 154 | end 155 | end 156 | -------------------------------------------------------------------------------- /accounting/exact: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require './config/environment' 3 | 4 | quarter = ARGV[0].to_i 5 | year = (ARGV[1] || Time.now.year).to_i 6 | 7 | from = Date.new(year, (quarter-1)*3+1, 1) 8 | to = from+3.months 9 | 10 | conditions = [ 11 | 'finalized_at IS NOT NULL', 12 | 'reserved_at is NULL', 13 | "finalized_at >= '#{from.strftime}'", 14 | "finalized_at < '#{to.strftime}'" 15 | ].join(' AND ') 16 | 17 | def vat_code(invoice) 18 | # Check info on the original invoice if this is a refund. 19 | invoice = invoice.reference || invoice 20 | 21 | # The vat code is calculated as follows: 22 | # E - Customers outside the EU - no vat charged 23 | # F - Businesses in the EU (not belgium) - reverse charged 24 | # 5 - Customers in Belgium 25 | # M** - Individuals in the EU (not belgium) - vat charged 26 | vat_code = if !invoice.eu? 27 | 'E' 28 | elsif invoice.customer_vat_number && invoice.eu? && invoice.customer_country_code != 'BE' 29 | 'F' 30 | elsif invoice.customer_country_code == 'BE' 31 | '5' 32 | else 33 | "M#{invoice.customer_country_code}" 34 | end 35 | end 36 | 37 | # Get all customer ids that have a paid invoice in this quarter for the first time. 38 | # We order all invoices chronologically and check for which customers (accounting id) their first invoice was in this quarter. 39 | customer_ids = Configuration.db[:invoices] \ 40 | .where('finalized_at IS NOT NULL AND reserved_at is NULL') \ 41 | .order(Sequel.asc(:finalized_at)) \ 42 | .to_a \ 43 | .group_by { |r| r[:customer_accounting_id] } \ 44 | .select { |accounting_id, rs| rs.first[:finalized_at] > from && rs.first[:finalized_at] < to } \ 45 | .values \ 46 | .map(&:first) \ 47 | .map { |r| r[:stripe_customer_id] } \ 48 | .compact 49 | 50 | vat = [] 51 | no_vat = [] 52 | 53 | customer_ids.each do |customer_id| 54 | # Get a random invoice of the customer. 55 | invoice = Invoice.where(stripe_customer_id: customer_id).where(conditions).limit(1).first 56 | vat_code = vat_code(invoice) 57 | 58 | if invoice.customer_vat_number 59 | # If a company has a VAT number (could be from code F or S) add it to vat 60 | vat << "#{invoice.customer_company_name || invoice.customer_name}, #{invoice.customer_accounting_id}, 400000, 700000, #{vat_code}, #{invoice.customer_vat_number}" 61 | else 62 | # Add companies without vat number to another array 63 | no_vat << "#{invoice.customer_name}, #{invoice.customer_accounting_id}, 400000, 700000, #{vat_code}" 64 | end 65 | end 66 | 67 | invoices = [] 68 | 69 | Invoice \ 70 | .where(conditions) \ 71 | .order(Sequel.asc(:number)) \ 72 | .each do |invoice| 73 | begin 74 | date = invoice.finalized_at.strftime('%d-%m-%Y') 75 | 76 | # Negative amounts if it is a refund. 77 | amount, vat_amount_eur, accounting_id = if invoice.credit_note? 78 | reference = invoice.reference if invoice.credit_note 79 | 80 | [-reference.total_eur+reference.vat_amount_eur, -reference.vat_amount_eur, reference.customer_accounting_id] 81 | else 82 | [invoice.total_eur-invoice.vat_amount_eur, invoice.vat_amount_eur, invoice.customer_accounting_id] 83 | end 84 | 85 | invoices << "0| #{accounting_id}| 700| #{date}| #{invoice.number}" 86 | invoices << "1| 700000| #{vat_code(invoice)}| #{amount/100.0}| #{vat_amount_eur/100.0}| #{invoice.number}" 87 | 88 | rescue StandardError => e 89 | puts "invoice #{invoice} could not be exported" 90 | puts e.message, *e.backtrace 91 | end 92 | end 93 | 94 | # Print customers. 95 | puts 'VAT CUSTOMERS' 96 | puts 'Naam, Code, Klantenrekening, Grootboekrekening: Verkoop, BTW-code: Verkoop, BTW / Ondernemingsnummer' 97 | puts vat.join("\n") 98 | puts 99 | puts 100 | puts 'NO VAT CUSTOMERS' 101 | puts 'Naam, Code, Klantenrekening, Grootboekrekening: Verkoop, BTW-code: Verkoop' 102 | puts no_vat.join("\n") 103 | puts 104 | puts 105 | puts 'INVOICES' 106 | puts invoices.join("\n") 107 | -------------------------------------------------------------------------------- /spec/stripe_service_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe StripeService do 4 | 5 | let(:metadata) {{ 6 | country_code: 'NL', 7 | vat_registered: 'false', 8 | other: 'random' 9 | }} 10 | 11 | let(:trial_plan) do 12 | begin 13 | Stripe::Plan.retrieve('trial_plan') 14 | rescue 15 | Stripe::Plan.create \ 16 | id: 'trial_plan', 17 | name: 'Trial Plan', 18 | amount: 1499, 19 | currency: 'usd', 20 | interval: 'month', 21 | trial_period_days: 10 22 | end 23 | end 24 | 25 | let(:plan) do 26 | begin 27 | Stripe::Plan.retrieve('test') 28 | rescue 29 | Stripe::Plan.create \ 30 | id: 'test', 31 | name: 'Test Plan', 32 | amount: 1499, 33 | currency: 'usd', 34 | interval: 'month' 35 | end 36 | end 37 | 38 | let(:card) { '4242424242424242' } 39 | 40 | let(:customer) do 41 | Stripe::Customer.create \ 42 | card: { 43 | number: card, 44 | exp_month: '12', 45 | exp_year: '30', 46 | cvc: '222' 47 | }, 48 | metadata: metadata 49 | end 50 | 51 | let(:coupon) do 52 | begin 53 | Stripe::Coupon.retrieve('25OFF') 54 | rescue 55 | Stripe::Coupon.create( 56 | percent_off: 25, 57 | duration: 'repeating', 58 | duration_in_months: 3, 59 | id: '25OFF' 60 | ) 61 | end 62 | end 63 | 64 | let(:stripe_invoice) { Stripe::Invoice.create(customer: customer.id) } 65 | 66 | let(:service) { StripeService.new(customer_id: customer.id) } 67 | 68 | describe '#create_subscription' do 69 | it 'creates a subscription and adds VAT to the first invoice' do 70 | VCR.use_cassette('create_subscription_success') do 71 | subscription = service.create_subscription(plan: plan.id) 72 | 73 | invoices = customer.invoices 74 | invoices.to_a.size.must_equal 1 75 | invoice = invoices.first 76 | 77 | invoice.must_be_kind_of(Stripe::Invoice) 78 | invoice.id.wont_be_nil 79 | 80 | invoice.total.must_equal 1814 81 | invoice.tax.must_equal 315 82 | invoice.lines.to_a.size.must_equal 1 83 | 84 | upcoming = customer.upcoming_invoice 85 | upcoming.total.must_equal 1814 86 | upcoming.tax.must_equal 315 87 | upcoming.lines.to_a.size.must_equal 1 88 | end 89 | end 90 | 91 | describe 'the customer has a VAT number' do 92 | let(:metadata) {{ 93 | country_code: 'NL', 94 | vat_number: 'GB1234', 95 | vat_registered: 'false', 96 | other: 'random' 97 | }} 98 | 99 | it 'uses the VAT country code' do 100 | VCR.use_cassette('create_subscription_vat_success') do 101 | subscription = service.create_subscription(plan: plan.id) 102 | invoice = customer.invoices.first 103 | invoice.tax.must_equal 300 104 | end 105 | end 106 | end 107 | 108 | describe 'the plan has a trial period' do 109 | it 'creates a subscription but does not add VAT' do 110 | VCR.use_cassette('create_subscription_trial_success') do 111 | subscription = service.create_subscription(plan: trial_plan.id) 112 | 113 | invoices = customer.invoices 114 | invoices.to_a.size.must_equal 1 115 | invoice = invoices.first 116 | 117 | invoice.must_be_kind_of(Stripe::Invoice) 118 | invoice.id.wont_be_nil 119 | 120 | invoice.total.must_equal 0 121 | invoice.lines.to_a.size.must_equal 1 122 | 123 | upcoming = customer.upcoming_invoice 124 | upcoming.total.must_equal 1814 125 | upcoming.tax.must_equal 315 126 | upcoming.lines.to_a.size.must_equal 1 127 | end 128 | end 129 | end 130 | 131 | describe 'the subscription could not be created' do 132 | let(:card) { '4000000000000341' } 133 | 134 | it 'does not charge VAT' do 135 | VCR.use_cassette('create_subscription_fail') do 136 | proc do 137 | service.create_subscription(plan: plan.id) 138 | end.must_raise Stripe::CardError 139 | 140 | customer.invoices.to_a.must_be_empty 141 | Stripe::InvoiceItem.all(customer: customer.id).to_a.must_be_empty 142 | end 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /accounting/ic: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require './config/environment' 3 | 4 | quarter = ARGV[0].to_i 5 | year = (ARGV[1] || Time.now.year).to_i 6 | 7 | query = %{ 8 | WITH complete AS ( 9 | SELECT *, COALESCE(invoices.reference_number, invoices.number) AS original_number, CASE invoices.credit_note WHEN true THEN -1 ELSE 1 END AS multiplier 10 | FROM invoices 11 | ) 12 | SELECT i.customer_vat_number AS vat_number, SUM(i.total_eur*c.multiplier) AS total 13 | FROM complete c 14 | LEFT JOIN invoices AS i ON c.original_number = i.number 15 | WHERE i.customer_vat_number IS NOT NULL 16 | AND c.finalized_at >= ? 17 | AND c.finalized_at < ? 18 | AND i.customer_vat_number NOT LIKE 'BE%' 19 | GROUP BY i.customer_vat_number 20 | HAVING SUM(i.total_eur*c.multiplier) > 0 21 | ORDER BY total DESC 22 | } 23 | 24 | from = Date.new(year, (quarter-1)*3+1, 1) 25 | to = from + 3.months 26 | 27 | customers = Configuration.db[query, from, to] 28 | 29 | template = Tox.dsl do 30 | el('IntraConsignment', { 31 | representative: el('Representative', { 32 | id: el('ic:RepresentativeID', { 33 | country_code: at('issuedBy'), 34 | type: at('identificationType'), 35 | vat_number: text 36 | }), 37 | name: el('ic:Name', text), 38 | street: el('ic:Street', text), 39 | zip: el('ic:PostCode', text), 40 | city: el('ic:City', text), 41 | country_code: el('ic:CountryCode', text), 42 | email: el('ic:EmailAddress', text), 43 | phone: el('ic:Phone', text) 44 | }), 45 | reference: el('RepresentativeReference', text), 46 | listing: el('IntraListing', { 47 | sequence: at('SequenceNumber'), 48 | reference: at('DeclarantReference'), 49 | count: at('ClientsNbr'), 50 | sum: at('AmountSum'), 51 | declarant: el('Declarant', { 52 | vat_number: el('ic:VATNumber', text), 53 | name: el('ic:Name', text), 54 | street: el('ic:Street', text), 55 | zip: el('ic:PostCode', text), 56 | city: el('ic:City', text), 57 | country_code: el('ic:CountryCode', text), 58 | email: el('ic:EmailAddress', text), 59 | phone: el('ic:Phone', text) 60 | }), 61 | period: el('Period', { 62 | quarter: el('Quarter', text), 63 | year: el('Year', text) 64 | }), 65 | clients: mel('IntraClient', { 66 | sequence: at('SequenceNumber'), 67 | vat: el('CompanyVATNumber', { 68 | country_code: at('issuedBy'), 69 | number: text 70 | }), 71 | code: el('Code', text), 72 | amount: el('Amount', text) 73 | }) 74 | }) 75 | }, { 76 | '' => 'http://www.minfin.fgov.be/IntraConsignment', 77 | 'ic' => 'http://www.minfin.fgov.be/InputCommon' 78 | }) 79 | end 80 | 81 | xml = template.render({ 82 | representative: { 83 | id: { 84 | type: 'NVAT', 85 | country_code: Configuration.primary_country, 86 | vat_number: Configuration.seller_vat_number[2..-1] 87 | }, 88 | name: Configuration.seller_company_name, 89 | street: Configuration.seller_street, 90 | zip: Configuration.seller_zip, 91 | city: Configuration.seller_city, 92 | country_code: Configuration.primary_country, 93 | email: Configuration.ic_email, 94 | phone: Configuration.ic_phone 95 | }, 96 | reference: Configuration.ic_reference, 97 | listing: { 98 | sequence: '1', 99 | reference: Configuration.ic_reference, 100 | count: customers.count.to_s, 101 | sum: (customers.map { |i| i[:total] }.inject(&:+).to_f/100).to_s, 102 | declarant: { 103 | vat_number: Configuration.seller_vat_number[2..-1], 104 | name: Configuration.seller_company_name, 105 | street: Configuration.seller_street, 106 | zip: Configuration.seller_zip, 107 | city: Configuration.seller_city, 108 | country_code: Configuration.primary_country, 109 | email: Configuration.ic_email, 110 | phone: Configuration.ic_phone, 111 | }, 112 | period: { 113 | year: year.to_s, 114 | quarter: quarter.to_s 115 | }, 116 | clients: customers.each_with_index.map { |invoice, i| 117 | { 118 | sequence: i.to_s, 119 | vat: { 120 | country_code: invoice[:vat_number][0..1], 121 | number: invoice[:vat_number][2..-1] 122 | }, 123 | code: 'S', 124 | amount: (invoice[:total].to_f/100).to_s 125 | } 126 | } 127 | } 128 | }) 129 | 130 | puts %{} 131 | puts xml 132 | -------------------------------------------------------------------------------- /spec/cassettes/process_refund_orphan.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://api.stripe.com/v1/customers 6 | body: 7 | encoding: UTF-8 8 | string: card[cvc]=222&card[exp_month]=12&card[exp_year]=30&card[number]=4242424242424242&metadata[accounting_id]=10001&metadata[country_code]=NL&metadata[other]=random&metadata[vat_number]=NL123&metadata[vat_registered]=false 9 | headers: 10 | Accept: 11 | - "*/*" 12 | Accept-Encoding: 13 | - gzip, deflate 14 | User-Agent: 15 | - Stripe/v1 RubyBindings/1.48.0 16 | Authorization: 17 | - "" 18 | Content-Type: 19 | - application/x-www-form-urlencoded 20 | Stripe-Version: 21 | - '2015-10-16' 22 | X-Stripe-Client-User-Agent: 23 | - '{"bindings_version":"1.48.0","lang":"ruby","lang_version":"2.3.1 p112 (2016-04-26)","platform":"x86_64-darwin14","engine":"ruby","publisher":"stripe","uname":"Darwin 24 | Mattiass-MacBook-Pro.local 14.5.0 Darwin Kernel Version 14.5.0: Wed Jul 29 25 | 02:26:53 PDT 2015; root:xnu-2782.40.9~1/RELEASE_X86_64 x86_64","hostname":"Mattiass-MacBook-Pro.local"}' 26 | Content-Length: 27 | - '217' 28 | response: 29 | status: 30 | code: 200 31 | message: OK 32 | headers: 33 | Server: 34 | - nginx 35 | Date: 36 | - Wed, 13 Jul 2016 09:48:35 GMT 37 | Content-Type: 38 | - application/json 39 | Content-Length: 40 | - '1519' 41 | Connection: 42 | - keep-alive 43 | Access-Control-Allow-Credentials: 44 | - 'true' 45 | Access-Control-Allow-Methods: 46 | - GET, POST, HEAD, OPTIONS, DELETE 47 | Access-Control-Allow-Origin: 48 | - "*" 49 | Access-Control-Max-Age: 50 | - '300' 51 | Cache-Control: 52 | - no-cache, no-store 53 | Request-Id: 54 | - req_8oMinePdDbzQyp 55 | Stripe-Version: 56 | - '2015-10-16' 57 | Strict-Transport-Security: 58 | - max-age=31556926; includeSubDomains 59 | body: 60 | encoding: UTF-8 61 | string: | 62 | { 63 | "id": "cus_8oMi89hIbaHCOb", 64 | "object": "customer", 65 | "account_balance": 0, 66 | "created": 1468403314, 67 | "currency": null, 68 | "default_source": "card_18Wjyo2nHroS7mLXZKx2YhfZ", 69 | "delinquent": false, 70 | "description": null, 71 | "discount": null, 72 | "email": null, 73 | "livemode": false, 74 | "metadata": { 75 | "accounting_id": "10001", 76 | "country_code": "NL", 77 | "other": "random", 78 | "vat_number": "NL123", 79 | "vat_registered": "false" 80 | }, 81 | "shipping": null, 82 | "sources": { 83 | "object": "list", 84 | "data": [ 85 | { 86 | "id": "card_18Wjyo2nHroS7mLXZKx2YhfZ", 87 | "object": "card", 88 | "address_city": null, 89 | "address_country": null, 90 | "address_line1": null, 91 | "address_line1_check": null, 92 | "address_line2": null, 93 | "address_state": null, 94 | "address_zip": null, 95 | "address_zip_check": null, 96 | "brand": "Visa", 97 | "country": "US", 98 | "customer": "cus_8oMi89hIbaHCOb", 99 | "cvc_check": "pass", 100 | "dynamic_last4": null, 101 | "exp_month": 12, 102 | "exp_year": 2030, 103 | "fingerprint": "0K7oMWAQAFG7TEob", 104 | "funding": "credit", 105 | "last4": "4242", 106 | "metadata": {}, 107 | "name": null, 108 | "tokenization_method": null 109 | } 110 | ], 111 | "has_more": false, 112 | "total_count": 1, 113 | "url": "/v1/customers/cus_8oMi89hIbaHCOb/sources" 114 | }, 115 | "subscriptions": { 116 | "object": "list", 117 | "data": [], 118 | "has_more": false, 119 | "total_count": 0, 120 | "url": "/v1/customers/cus_8oMi89hIbaHCOb/subscriptions" 121 | } 122 | } 123 | http_version: 124 | recorded_at: Wed, 13 Jul 2016 09:48:35 GMT 125 | recorded_with: VCR 2.9.2 126 | -------------------------------------------------------------------------------- /client/billbo.rb: -------------------------------------------------------------------------------- 1 | require 'billbo/version' 2 | require 'stripe' 3 | require 'multi_json' 4 | require 'uri' 5 | require 'billbo/stripe_like' 6 | require 'billbo/json_util' 7 | 8 | module Billbo 9 | Invoice = Class.new(OpenStruct) 10 | 11 | class << self 12 | attr_accessor :host, :token 13 | 14 | # Fetches a preview breakdown of the costs of a subscription. 15 | # 16 | # plan - Stripe plan ID. 17 | # country_code - Country code of the customer (ISO 3166-1 alpha-2 standard) 18 | # vat_registered - Whether the customer is VAT registered. 19 | # 20 | # Returns something like 21 | # { 22 | # subtotal: 10, 23 | # currency: 'eur', 24 | # vat: 2.1, 25 | # vat_rate: '21' 26 | # } 27 | def preview(options) 28 | [:plan, :country_code, :vat_registered].each do |key| 29 | raise ArgumentError, "#{key} not provided" if options[key].nil? 30 | end 31 | 32 | get("/preview/#{options[:plan]}", params: { 33 | country_code: options[:country_code], 34 | vat_registered: options[:vat_registered], 35 | quantity: options[:quantity] 36 | }.compact) 37 | end 38 | 39 | # validates a VAT number. 40 | # 41 | # number - the VAT number. 42 | # 43 | # Returns nil if the VAT number does not exist. 44 | def vat(number) 45 | get("/vat/#{number}") 46 | { number: number } 47 | rescue RestClient::ResourceNotFound 48 | nil 49 | end 50 | 51 | # gets details about a VAT number. 52 | # 53 | # number - the VAT number. 54 | # own_vat - own VAT number (to get a request identifier) 55 | # 56 | # Returns nil if the VAT number does not exist. 57 | def vat_details(number, own_vat = nil) 58 | get("/vat/#{number}/details", params: { 59 | own_vat: own_vat 60 | }.compact) 61 | rescue RestClient::ResourceNotFound 62 | nil 63 | end 64 | 65 | # Possibility to reserve an empty slot in the invoices 66 | # (for legacy invoice systems and manual invoicing). 67 | # 68 | # Returns something like 69 | # { 70 | # year: 2014, 71 | # sequence_number: 1, 72 | # number: 2014.1, 73 | # finalized_at: '2014-07-30 17:16:35 +0200', 74 | # reserved_at: '2014-07-30 17:16:35 +0200' 75 | # } 76 | def reserve 77 | Invoice.new(post("/reserve", {})) 78 | end 79 | 80 | # Creates a new subscription with VAT. 81 | # 82 | # customer - ID of Stripe customer. 83 | # plan - ID of plan to subscribe on. 84 | # any other stripe options. 85 | # 86 | # Returns the Stripe::Subscription if succesful. 87 | def create_subscription(options) 88 | [:plan, :customer].each do |key| 89 | raise ArgumentError, "#{key} not provided" if options[key].nil? 90 | end 91 | 92 | body = StripeLike.request \ 93 | method: :post, 94 | url: "https://#{Billbo.host}/subscriptions", 95 | payload: options, 96 | content_type: 'application/x-www-form-urlencoded', 97 | user: 'X', 98 | password: Billbo.token 99 | 100 | Stripe::Subscription.construct_from(body) 101 | end 102 | 103 | # List invoices 104 | # 105 | # by_account_id - customer account identifier 106 | # finalized_before - finalized before given timestamp 107 | # finalized_after - finalized after given timestamp 108 | # 109 | # Returns an array of {number: .., finalized_at: ..}. 110 | def invoices(options) 111 | get("/invoices", params: options).map do |invoice| 112 | Invoice.new(invoice) 113 | end 114 | end 115 | 116 | # Return PDF data of given invoice by number. 117 | def pdf(number) 118 | get("/invoices/#{number}.pdf") 119 | end 120 | 121 | private 122 | 123 | [:get, :post, :put, :patch, :delete].each do |verb| 124 | define_method(verb) do |path, *args| 125 | raise 'Billbo host is not configured' unless Billbo.host 126 | 127 | response = RestClient.public_send( 128 | verb, 129 | "https://X:#{Billbo.token}@#{Billbo.host}#{path}", 130 | *args 131 | ) 132 | 133 | if response.body.present? && response.headers[:content_type] == 'application/json' 134 | JsonUtil.parse_attributes( 135 | MultiJson.decode(response.body, symbolize_keys: true) 136 | ) 137 | else 138 | response 139 | end 140 | end 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /assets/default.css: -------------------------------------------------------------------------------- 1 | #invoice p, #invoice h2, #invoice h3{margin:0} 2 | #invoice {font-family: Roboto, sans-serif; color: #888;line-height:1.5} 3 | #invoice h1 {margin: 0;color: #000;text-transform: uppercase;} 4 | #invoice h2, #invoice h3 {color:#000; text-transform: none; line-height:1.2;margin-top:10px} 5 | 6 | #invoice #invoice-info h3, #payment-due {margin:0; font-size:9pt; font-weight:normal; font-style:italic; color:#888} 7 | #invoice #invoice-info h3{padding-right:8px} 8 | #invoice #invoice-info h2 {margin:0 0 3px 0; color:#000; font-size:13pt; font-weight:bold } 9 | #invoice-info #payment-total{font-size:13pt; color:#159ac4; font-weight:bold; line-height:1.3} 10 | #invoice h2 {font-size:12pt} 11 | 12 | #company-address, #client-details, #payment-details, #invoice-other, #invoice-amount td, th, #invoice-info {font-size:9pt} 13 | #company-address{text-align:right} 14 | #invoice .fn, #invoice .org{color:#000} 15 | 16 | #invoice #invoice-terms h2{margin-bottom:3px} 17 | #invoice #invoice-terms h3{margin-top:0;margin-bottom:5px;font-weight:normal;font-style:italic; color:#666; font-size:9pt} 18 | 19 | #invoice .total{font-size:12pt;} 20 | #comments {margin-top:15px;line-height:1.2; color:#646464; font-size:10pt; 21 | } 22 | 23 | #invoice-other{text-align:right} 24 | #payment-details strong, #invoice-other strong{font-weight:normal} 25 | 26 | .fn, .org { 27 | font-weight:bold; 28 | } 29 | 30 | #invoice{padding:0 1cm 1cm 1cm;background-color:#fff} 31 | #invoice-header #company-address {float:right; width:250px;clear:right; margin-top:10px;} 32 | #invoice-header h1{float:left; width:350px; color:#000; margin:5px 0 0 0} 33 | #invoice-header{padding-top:1cm; } 34 | #invoice-header .logo {float:right; width:200px; } 35 | #invoice-blank-header {margin-top: 1.6cm;} 36 | #client-details{float:left; width:330px; margin-top:0.6cm;margin-bottom:20px } 37 | #invoice-info { 38 | width:340px; padding:10px; background:#eee; border:0px; z-index:10; 39 | -webkit-border-radius: 5px; 40 | -moz-border-radius: 5px; 41 | border-radius: 5px; 42 | } 43 | #invoice-info th{text-align:right} 44 | #payment-due, #invoice-info h3 {display:inline} 45 | #payment-terms{display:none} 46 | #invoice-amount {margin: 2em 0 2em 0; clear: both;} 47 | 48 | #payment-details{width:300px; float:left; margin-bottom:5mm;} 49 | #invoice-other{width:330px;clear:both; float:right;margin-bottom:5mm;} 50 | #invoice-other h2, #payment-details h2 {margin-bottom: 1mm;} 51 | 52 | #footnote{ 53 | clear:both; padding:0px; border:0px; vertical-align:bottom; line-height:1.2;zoom:1; 54 | } 55 | 56 | #footnote p {padding:10px 0; font-size: 9px;} 57 | 58 | #invoice table { border-collapse:collapse; width:100%; clear:both;} 59 | #invoice table th {text-align:left; padding:10px 4px;} 60 | #invoice #header_row th{background:#159ac4; color:#fff; border-top:0;} 61 | #invoice-amount td.action{border-bottom:0;} 62 | #invoice-amount tr.odd td{background:#fff;} 63 | 64 | #invoice-amount .details_th{ 65 | -webkit-border-radius: 5px 0 0 5px; 66 | -moz-border-radius: 5px 0 0 5px; 67 | border-radius: 5px 0 0 5px; 68 | padding-left:7px; 69 | } 70 | #invoice-amount .subtotal_th{ 71 | -webkit-border-radius: 0 5px 5px 0; 72 | -moz-border-radius: 0 5px 5px 0; 73 | border-radius: 0 5px 5px 0; 74 | padding-right:7px; 75 | } 76 | 77 | #invoice-amount #total_tr td{border-bottom:0} 78 | #invoice-amount #vat_tr td{border-bottom:1px solid #999} 79 | #invoice-amount tbody td {white-space: nowrap;margin: 0;vertical-align: center; border-bottom:1px solid #C9C9C9;} 80 | 81 | #invoice-amount td {margin:0; padding: 10px 5px; vertical-align: top; } 82 | #invoice-amount td.item_r{text-align: right;} 83 | #invoice-amount td.item_l{text-align: left;white-space: normal; } 84 | #invoice-amount td.total{text-align: right;font-weight: bold; font-size:12pt; color:#159ac4;} 85 | 86 | #invoice-amount tfoot td{padding:15px 4px 0px 4px; color:#000} 87 | #vat_tr td {padding-bottom:20px!important;} 88 | #discount_tr td, #vat_tr td, #net_total_tr td {font-weight:bold; color:#555!important;}; 89 | 90 | #invoice-amount .details_th{width:44%} 91 | #invoice-amount .details_notax_th{width:52%} 92 | #invoice-amount .quantity_th{width:13%} 93 | #invoice-amount .subtotal_th{width:18%; text-align:right} 94 | #invoice-amount .unitprice_th{width:17%; text-align:right} 95 | #invoice-amount .salestax_th{width:8%; text-align:center} 96 | 97 | body {margin: 0;padding: 0;font:62.5%/1.5 Helvetica, Arial, Verdana, sans-serif;background: #eee;} 98 | ul, ul li, p, div, ol {margin:0;padding:0;list-style:none;} 99 | 100 | #invoice img { border: none; } 101 | -------------------------------------------------------------------------------- /lib/invoice_service.rb: -------------------------------------------------------------------------------- 1 | class InvoiceService 2 | OrphanRefund = Class.new(StandardError) 3 | 4 | # customer_id - Stripe customer id. 5 | def initialize(customer_id:) 6 | @customer_id = customer_id 7 | end 8 | 9 | # An internal invoice is not created, we only do this when 10 | # the invoice gets paid (#process_payment) 11 | def create_subscription(options) 12 | stripe_service.create_subscription(options) 13 | end 14 | 15 | def process_payment(stripe_event_id:, stripe_invoice_id:) 16 | stripe_invoice = Stripe::Invoice.retrieve(stripe_invoice_id) 17 | 18 | # If the invoice only has zero total invoice lines, do not include it for 19 | # bookkeeping. This happens when a customer subscribes when still in trial. 20 | return if stripe_invoice.lines.map(&:amount).all?(&:zero?) 21 | 22 | # Get/create an internal invoice and a Stripe invoice. 23 | invoice = ensure_invoice( 24 | stripe_event_id, 25 | stripe_invoice_id 26 | ) 27 | 28 | invoice.finalize! 29 | 30 | # Take snapshots for immutable invoice. 31 | snapshot_invoice(stripe_invoice, invoice) 32 | invoice.update(customer_metadata(invoice)) 33 | 34 | # Take a snapshot of the card used to make payment. 35 | # Note: There will be no charge when the invoice was 36 | # paid with the balance. 37 | if stripe_invoice.charge 38 | charge = Stripe::Charge.retrieve(stripe_invoice.charge) 39 | # Old subscription will have a source, however, new subscriptions use payment methods instead 40 | card = charge.source || charge.payment_method_details.card 41 | snapshot_card(card, invoice) 42 | end 43 | 44 | invoice 45 | rescue Invoice::AlreadyFinalized 46 | end 47 | 48 | def process_refund(stripe_event_id:, stripe_invoice_id:) 49 | invoice = Invoice.first(stripe_id: stripe_invoice_id) 50 | 51 | if invoice 52 | Invoice.create( 53 | customer_metadata(invoice). 54 | merge( 55 | stripe_event_id: stripe_event_id, 56 | reference_number: invoice.number, 57 | credit_note: true 58 | ) 59 | ).finalize! 60 | else 61 | raise OrphanRefund 62 | end 63 | end 64 | 65 | def last_stripe_invoice 66 | Stripe::Invoice.all(customer: @customer_id, limit: 1).first 67 | end 68 | 69 | private 70 | 71 | def customer_metadata(invoice) 72 | metadata = stripe_service.customer_metadata.slice( 73 | :email, :name, :company_name, :country_code, :address, :vat_registered, :vat_number, :accounting_id, :ip_address) 74 | 75 | # Transform vat_registered into boolean 76 | metadata[:vat_registered] = metadata[:vat_registered] == 'true' 77 | 78 | # Prepend :customer 79 | customer_metadata = metadata.map do |k,v| 80 | ["customer_#{k}".to_sym, v] 81 | end.to_h 82 | 83 | # Add the customer id 84 | customer_metadata[:stripe_customer_id] = @customer_id 85 | 86 | customer_metadata 87 | end 88 | 89 | def snapshot_invoice(stripe_invoice, invoice) 90 | # In Stripe: total = subtotal - discount + tax 91 | invoice.total = stripe_invoice.total.to_i 92 | invoice.subtotal = stripe_invoice.subtotal.to_i 93 | invoice.subtotal_after_discount = stripe_invoice.total.to_i - stripe_invoice.tax.to_i 94 | invoice.discount_amount = stripe_invoice.tax.to_i + stripe_invoice.subtotal.to_i - stripe_invoice.total.to_i 95 | invoice.vat_amount = stripe_invoice.tax.to_i 96 | invoice.vat_rate = stripe_invoice.tax_percent 97 | invoice.currency = stripe_invoice.currency 98 | 99 | # The invoice interval (month/year) is the current interval 100 | # of the subscription attached to the invoice. 101 | if subscription_id = stripe_invoice.subscription 102 | subscription = Stripe::Subscription.retrieve(subscription_id) 103 | 104 | invoice.interval = subscription.plan.interval 105 | invoice.stripe_subscription_id = subscription.id 106 | end 107 | end 108 | 109 | def snapshot_card(card, invoice) 110 | invoice.update( 111 | card_brand: card.brand, 112 | card_last4: card.last4, 113 | card_country_code: card.country 114 | ) 115 | end 116 | 117 | def ensure_invoice(stripe_event_id, stripe_invoice_id) 118 | if invoice = Invoice.find(stripe_id: stripe_invoice_id) 119 | invoice 120 | else 121 | Invoice.create( 122 | stripe_id: stripe_invoice_id, 123 | stripe_event_id: stripe_event_id 124 | ) 125 | end 126 | end 127 | 128 | def stripe_service 129 | @stripe_service ||= StripeService.new(customer_id: @customer_id) 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /app/invoice.rb: -------------------------------------------------------------------------------- 1 | class Invoice < Sequel::Model 2 | AlreadyFinalized = Class.new(StandardError) 3 | 4 | def self.find_or_create_by_stripe_id(stripe_id) 5 | transaction(isolation: :serializable, 6 | retry_on: [Sequel::SerializationFailure]) do 7 | find_or_create(stripe_id: stripe_id) 8 | end 9 | end 10 | 11 | def self.quarter(year, quarter) 12 | from = Date.new(year, (quarter-1)*3+1, 1) 13 | 14 | between(from, from+3.months) 15 | end 16 | 17 | def_dataset_method(:finalized) do 18 | exclude(finalized_at: nil) 19 | end 20 | 21 | def_dataset_method(:not_reserved) do 22 | where(reserved_at: nil) 23 | end 24 | 25 | def_dataset_method(:newest_first) do 26 | order(:finalized_at).reverse 27 | end 28 | 29 | def_dataset_method(:by_accounting_id) do |accounting_id| 30 | where(customer_accounting_id: accounting_id) 31 | end 32 | 33 | def_dataset_method(:finalized_before) do |before| 34 | where{finalized_at < before} 35 | end 36 | 37 | def_dataset_method(:finalized_after) do |after| 38 | where{finalized_at >= after} 39 | end 40 | 41 | def_dataset_method(:with_pdf_generated) do 42 | exclude(pdf_generated_at: nil) 43 | end 44 | 45 | # Returns all finalized invoices from a given period. 46 | def self.between(from, to) 47 | finalized. 48 | not_reserved. 49 | finalized_after(from). 50 | finalized_before(to) 51 | end 52 | 53 | def finalize! 54 | raise AlreadyFinalized if finalized? 55 | 56 | self.class.with_next_sequence do |next_sequence| 57 | update_always(next_sequence) 58 | end 59 | 60 | self 61 | end 62 | 63 | def self.reserve! 64 | with_next_sequence do |next_sequence| 65 | create.tap do |invoice| 66 | invoice.update_always(next_sequence.merge(reserved_at: Time.now)) 67 | end 68 | end 69 | end 70 | 71 | def added_vat! 72 | update added_vat: true 73 | self 74 | end 75 | 76 | def pdf_generated! 77 | update pdf_generated_at: Time.now 78 | self 79 | end 80 | 81 | def added_vat? 82 | !!added_vat 83 | end 84 | 85 | def finalized? 86 | !finalized_at.nil? 87 | end 88 | alias :paid? :finalized? 89 | 90 | def credit_note? 91 | !!credit_note 92 | end 93 | 94 | def due_at 95 | finalized_at + Configuration.due_days.days 96 | end 97 | 98 | def customer_vat_registered? 99 | !!customer_vat_registered 100 | end 101 | 102 | def discount? 103 | discount_amount && discount_amount != 0 104 | end 105 | 106 | def vat? 107 | vat_amount && vat_amount != 0 108 | end 109 | 110 | def eu? 111 | Valvat::Utils::EU_COUNTRIES.include?(customer_country_code) 112 | end 113 | 114 | def customer_name 115 | super || customer_email 116 | end 117 | 118 | def customer_company_name 119 | super || vies_company_name 120 | end 121 | 122 | def customer_address 123 | super || vies_address 124 | end 125 | 126 | def reference 127 | reference_number && Invoice.where(number: reference_number).first 128 | end 129 | 130 | # Update record with given attributes regardless of the current 131 | # values thus overriding the sequel model logic which only saves 132 | # "changes". 133 | def update_always(attrs) 134 | attrs.keys.each{|k| modified!(k)} 135 | update(attrs) 136 | end 137 | 138 | def self.transaction(options = {}, &block) 139 | Configuration.db.transaction(options, &block) 140 | end 141 | 142 | def self.with_next_sequence(&block) 143 | transaction(isolation: :serializable, 144 | retry_on: [Sequel::UniqueConstraintViolation, 145 | Sequel::SerializationFailure], 146 | num_retries: nil) do 147 | block.call(next_sequence) 148 | end 149 | end 150 | 151 | def self.next_sequence 152 | year = Time.now.year 153 | sequence_number = next_sequence_number(year) 154 | 155 | # Number is a formatted version of this. 156 | number = Configuration.invoice_number_format % { year: year, sequence: sequence_number } 157 | { 158 | year: year, 159 | sequence_number: sequence_number, 160 | number: number, 161 | finalized_at: Time.now 162 | } 163 | end 164 | 165 | def self.next_sequence_number(year) 166 | last_invoice = Invoice 167 | .where('number IS NOT NULL') 168 | .where(year: year) 169 | .order(Sequel.desc(:sequence_number)) 170 | .limit(1) 171 | .first 172 | 173 | if last_invoice 174 | last_invoice.sequence_number + 1 175 | else 176 | 1 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /app/templates/default.html.slim: -------------------------------------------------------------------------------- 1 | doctype Strict 2 | html lang="en" xml:lang="en" xmlns="http://www.w3.org/1999/xhtml" 3 | head 4 | title #{document_type} #{number} 5 | meta content=("text/html; charset=iso-8859-1") http-equiv="Content-Type" / 6 | link href="/assets/default.css" rel="stylesheet" type="text/css" 7 | link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet" 8 | body 9 | #invoice 10 | #invoice-header 11 | img.logo.screen alt="Mainlogo_large" src=(seller_logo_url || "/assets/logo.png") / 12 | 13 | #company-address.vcard 14 | .fn.org 15 | strong = seller_company_name 16 | .adr 17 | .street-address 18 | = seller_address_line1 19 | br / 20 | = seller_address_line2 21 | .locality = country_name(primary_country) 22 | .email = seller_email 23 | p 24 | br / 25 | strong 26 | ' VAT 27 | = seller_vat_number 28 | br / 29 | = seller_other_info 30 | 31 | #invoice-info 32 | h2 33 | ' #{document_type} 34 | 35 | - if credit_note? 36 | strong = credit_note.number 37 | | (of #{number}) 38 | - else 39 | strong = number 40 | 41 | p = format_date(finalized_at) 42 | - if paid? 43 | p 44 | b Paid with #{card_brand} #{card_last4} 45 | - else 46 | p#payment-due Payment due by #{format_date(due_at)} 47 | p#payment-total = format_money(total, currency) 48 | 49 | 50 | #client-details.vcard 51 | .fn = customer_name 52 | .org = customer_company_name 53 | .adr 54 | .street-address 55 | = customer_address 56 | 57 | .locality = country_name(customer_country_code) 58 | p 59 | br / 60 | 61 | - if customer_vat_registered? 62 | strong 63 | ' VAT 64 | = customer_vat_number 65 | 66 | 67 | br / 68 | table#invoice-amount 69 | thead 70 | tr#header_row 71 | th.left.details_th Details 72 | th.unitprice_th Price (#{currency.upcase}) 73 | th.subtotal_th Price (EUR) 74 | tfoot 75 | - if discount? 76 | tr#discount_tr 77 | td.item_r 78 | - if percent_off 79 | ' #{percent_off}% 80 | | Discount 81 | td.item_r = format_money(discount_amount, currency) 82 | td.item_r 83 | tr#net_total_tr 84 | td.item_r Net Total 85 | td.item_r = format_money(subtotal_after_discount, currency) 86 | td.item_r 87 | tr#vat_tr 88 | td.item_r 89 | - if vat? 90 | = "VAT (#{vat_rate.to_i}%)" 91 | - else 92 | | VAT* 93 | td.item_r = format_money(vat_amount, currency) 94 | td.item_r = format_money(vat_amount_eur, :eur) 95 | tr#total_tr 96 | td#total_currency.total 97 | span.currency Total 98 | td.total = format_money(total, currency) 99 | td.total = format_money(total_eur, :eur) 100 | tbody 101 | - lines.each_with_index do |line, i| 102 | tr.item class=('odd' if i % 2 == 0) 103 | - if line.type == 'subscription' 104 | td.item_l = subscription_line_description(line) 105 | td.item_r = format_money(line.amount, line.currency) 106 | td.item_r 107 | - elsif line.type == 'invoiceitem' && line.metadata[:type] != 'vat' 108 | td.item_l = line.description 109 | td.item_r = format_money(line.amount, line.currency) 110 | td.item_r 111 | 112 | 113 | #payment-details 114 | h2 Payment Details 115 | #bank_name = seller_bank_name 116 | #sort-code 117 | strong 118 | ' BIC Code: 119 | = seller_bic 120 | #account-number 121 | strong 122 | ' IBAN Number: 123 | = seller_iban 124 | #payment-reference 125 | strong 126 | ' Payment Reference: 127 | = number 128 | 129 | #footnote 130 | p 131 | - if !vat? 132 | // we need to show a reason for not charging vat. 133 | - if eu? 134 | | * VAT Reverse-charged 135 | - else 136 | | * No EU VAT applicable - Article 59 Directive 2006/112/EC 137 | -------------------------------------------------------------------------------- /spec/cassettes/preview_success.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://api.stripe.com/v1/plans/test 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - "*/*; q=0.5, application/xml" 12 | Accept-Encoding: 13 | - gzip, deflate 14 | User-Agent: 15 | - Stripe/v1 RubyBindings/1.14.0 16 | Authorization: 17 | - "" 18 | Content-Type: 19 | - application/x-www-form-urlencoded 20 | Stripe-Version: 21 | - '2015-10-16' 22 | X-Stripe-Client-User-Agent: 23 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 24 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 25 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 26 | response: 27 | status: 28 | code: 200 29 | message: OK 30 | headers: 31 | Server: 32 | - nginx 33 | Date: 34 | - Fri, 15 Apr 2016 14:48:49 GMT 35 | Content-Type: 36 | - application/json 37 | Content-Length: 38 | - '272' 39 | Connection: 40 | - keep-alive 41 | Access-Control-Allow-Credentials: 42 | - 'true' 43 | Access-Control-Allow-Methods: 44 | - GET, POST, HEAD, OPTIONS, DELETE 45 | Access-Control-Allow-Origin: 46 | - "*" 47 | Access-Control-Max-Age: 48 | - '300' 49 | Cache-Control: 50 | - no-cache, no-store 51 | Request-Id: 52 | - req_8H6SMwT76nsxa2 53 | Stripe-Version: 54 | - '2015-10-16' 55 | Strict-Transport-Security: 56 | - max-age=31556926; includeSubDomains 57 | body: 58 | encoding: UTF-8 59 | string: | 60 | { 61 | "id": "test", 62 | "object": "plan", 63 | "amount": 1499, 64 | "created": 1406556583, 65 | "currency": "usd", 66 | "interval": "month", 67 | "interval_count": 1, 68 | "livemode": false, 69 | "metadata": {}, 70 | "name": "Test Plan", 71 | "statement_descriptor": null, 72 | "trial_period_days": null 73 | } 74 | http_version: 75 | recorded_at: Wed, 20 Jan 2016 00:00:00 GMT 76 | - request: 77 | method: get 78 | uri: https://api.stripe.com/v1/plans/test 79 | body: 80 | encoding: US-ASCII 81 | string: '' 82 | headers: 83 | Accept: 84 | - "*/*; q=0.5, application/xml" 85 | Accept-Encoding: 86 | - gzip, deflate 87 | User-Agent: 88 | - Stripe/v1 RubyBindings/1.14.0 89 | Authorization: 90 | - "" 91 | Content-Type: 92 | - application/x-www-form-urlencoded 93 | Stripe-Version: 94 | - '2015-10-16' 95 | X-Stripe-Client-User-Agent: 96 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 97 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 98 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 99 | response: 100 | status: 101 | code: 200 102 | message: OK 103 | headers: 104 | Server: 105 | - nginx 106 | Date: 107 | - Fri, 15 Apr 2016 14:48:49 GMT 108 | Content-Type: 109 | - application/json 110 | Content-Length: 111 | - '272' 112 | Connection: 113 | - keep-alive 114 | Access-Control-Allow-Credentials: 115 | - 'true' 116 | Access-Control-Allow-Methods: 117 | - GET, POST, HEAD, OPTIONS, DELETE 118 | Access-Control-Allow-Origin: 119 | - "*" 120 | Access-Control-Max-Age: 121 | - '300' 122 | Cache-Control: 123 | - no-cache, no-store 124 | Request-Id: 125 | - req_8H6S46wXd34cfn 126 | Stripe-Version: 127 | - '2015-10-16' 128 | Strict-Transport-Security: 129 | - max-age=31556926; includeSubDomains 130 | body: 131 | encoding: UTF-8 132 | string: | 133 | { 134 | "id": "test", 135 | "object": "plan", 136 | "amount": 1499, 137 | "created": 1406556583, 138 | "currency": "usd", 139 | "interval": "month", 140 | "interval_count": 1, 141 | "livemode": false, 142 | "metadata": {}, 143 | "name": "Test Plan", 144 | "statement_descriptor": null, 145 | "trial_period_days": null 146 | } 147 | http_version: 148 | recorded_at: Wed, 20 Jan 2016 00:00:00 GMT 149 | recorded_with: VCR 2.9.2 150 | -------------------------------------------------------------------------------- /spec/invoice_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe Invoice do 4 | let(:year) { Time.now.year } 5 | 6 | describe '#find_or_create' do 7 | it 'does not create duplicate invoices' do 8 | invoice1 = Invoice.find_or_create_by_stripe_id('1') 9 | Invoice.count.must_equal 1 10 | 11 | invoice2 = Invoice.find_or_create_by_stripe_id('1') 12 | invoice2.must_equal invoice1 13 | Invoice.count.must_equal 1 14 | end 15 | 16 | it 'accepts extra attributes' do 17 | end 18 | end 19 | 20 | describe '#finalize!' do 21 | let(:invoice) { Invoice.new } 22 | 23 | it 'assigns a number to the invoice and saves it' do 24 | invoice.finalize! 25 | Invoice.count.must_equal 1 26 | invoice.year.must_equal year 27 | invoice.sequence_number.must_equal 1 28 | invoice.number.must_equal "#{year}000001" 29 | invoice.finalized_at.must_be :>, Time.now - 10 30 | 31 | invoice = Invoice.new.finalize! 32 | invoice.year.must_equal year 33 | invoice.sequence_number.must_equal 2 34 | invoice.number.must_equal "#{year}000002" 35 | invoice.finalized_at.must_be :>, Time.now - 10 36 | end 37 | 38 | it 'can not be finalized twice (idempotent)' do 39 | proc do 40 | invoice.finalize!.finalize! 41 | end.must_raise Invoice::AlreadyFinalized 42 | end 43 | 44 | it 'does not create two invoices with the same number' do 45 | Invoice.stubs(:next_sequence_number).returns(1).then.returns(1).then.returns(2) 46 | 47 | invoice1 = Invoice.new.finalize! 48 | invoice2 = Invoice.new.finalize! 49 | 50 | invoice1.number.must_equal "#{year}000001" 51 | invoice2.number.must_equal "#{year}000002" 52 | end 53 | 54 | it 'handles concurrency' do 55 | invoices = 25.times.map { Invoice.create } 56 | 57 | invoices.map do |invoice| 58 | Thread.new { invoice.finalize! } 59 | end.each(&:join) 60 | 61 | invoices.map(&:number).uniq.size.must_equal invoices.size 62 | invoices.map(&:year).uniq.size.must_equal 1 63 | end 64 | end 65 | 66 | describe '#reserve!' do 67 | let(:invoice) { Invoice.new } 68 | 69 | it 'reserves an empty slot for an invoice' do 70 | invoice1 = Invoice.reserve! 71 | Invoice.count.must_equal 1 72 | invoice1.year.must_equal year 73 | invoice1.sequence_number.must_equal 1 74 | invoice1.number.must_equal "#{year}000001" 75 | invoice1.finalized_at.must_be :>, Time.now - 10 76 | invoice1.reserved_at.must_be :>, Time.now - 10 77 | 78 | invoice2 = Invoice.reserve! 79 | invoice2.year.must_equal year 80 | invoice2.sequence_number.must_equal 2 81 | invoice2.number.must_equal "#{year}000002" 82 | invoice2.finalized_at.must_be :>, invoice1.finalized_at 83 | invoice2.reserved_at.must_be :>, invoice1.reserved_at 84 | 85 | invoice3 = Invoice.reserve! 86 | invoice3.year.must_equal year 87 | invoice3.sequence_number.must_equal 3 88 | invoice3.number.must_equal "#{year}000003" 89 | invoice3.finalized_at.must_be :>, invoice2.finalized_at 90 | invoice3.reserved_at.must_be :>, invoice2.reserved_at 91 | end 92 | 93 | it 'does not reserve two invoices with the same number' do 94 | Invoice.stubs(:next_sequence_number).returns(3).then.returns(3).then.returns(4) 95 | 96 | invoice1 = Invoice.reserve! 97 | invoice2 = Invoice.reserve! 98 | 99 | invoice1.number.must_equal "#{year}000003" 100 | invoice2.number.must_equal "#{year}000004" 101 | end 102 | 103 | it 'always computes the correct next sequence number' do 104 | # 2 invoices, finalized at the same time. 105 | time = Time.now 106 | Invoice.create(year: year, sequence_number: 1, number: "#{year}000002", finalized_at: time) 107 | Invoice.create(year: year, sequence_number: 2, number: "#{year}000001", finalized_at: time) 108 | Invoice.create.finalize!.number.must_equal "#{year}000003" 109 | end 110 | 111 | it 'finalizes an invoice and reserves an empty slot for an invoice' do 112 | invoice.finalize! 113 | Invoice.count.must_equal 1 114 | invoice.year.must_equal year 115 | invoice.sequence_number.must_equal 1 116 | invoice.number.must_equal "#{year}000001" 117 | invoice.finalized_at.must_be :>, Time.now - 10 118 | 119 | invoice = Invoice.reserve! 120 | invoice.year.must_equal year 121 | invoice.sequence_number.must_equal 2 122 | invoice.number.must_equal "#{year}000002" 123 | invoice.finalized_at.must_be :>, Time.now - 10 124 | invoice.reserved_at.must_be :>, Time.now - 10 125 | end 126 | 127 | end 128 | 129 | end 130 | -------------------------------------------------------------------------------- /client/billbo/stripe_like.rb: -------------------------------------------------------------------------------- 1 | module Billbo 2 | module StripeLike 3 | 4 | # From https://github.com/stripe/stripe-ruby/blob/master/lib/stripe.rb#L111. 5 | def self.request(options) 6 | begin 7 | response = execute_request(options) 8 | rescue SocketError => e 9 | handle_restclient_error(e) 10 | rescue NoMethodError => e 11 | # Work around RestClient bug 12 | if e.message =~ /\WRequestFailed\W/ 13 | e = APIConnectionError.new('Unexpected HTTP response code') 14 | handle_restclient_error(e) 15 | else 16 | raise 17 | end 18 | rescue RestClient::ExceptionWithResponse => e 19 | if rcode = e.http_code and rbody = e.http_body 20 | handle_api_error(rcode, rbody) 21 | else 22 | handle_restclient_error(e) 23 | end 24 | rescue RestClient::Exception, Errno::ECONNREFUSED => e 25 | handle_restclient_error(e) 26 | end 27 | 28 | parse(response) 29 | end 30 | 31 | def self.execute_request(opts) 32 | RestClient::Request.execute(opts) 33 | end 34 | 35 | def self.parse(response) 36 | begin 37 | # Would use :symbolize_names => true, but apparently there is 38 | # some library out there that makes symbolize_names not work. 39 | response = JSON.parse(response.body) 40 | rescue JSON::ParserError 41 | raise general_api_error(response.code, response.body) 42 | end 43 | 44 | Stripe::Util.symbolize_names(response) 45 | end 46 | 47 | def self.general_api_error(rcode, rbody) 48 | Stripe::APIError.new("Invalid response object from API: #{rbody.inspect} " + 49 | "(HTTP response code was #{rcode})", rcode, rbody) 50 | end 51 | 52 | def self.handle_api_error(rcode, rbody) 53 | begin 54 | error_obj = JSON.parse(rbody) 55 | error_obj = Stripe::Util.symbolize_names(error_obj) 56 | error = error_obj[:error] or raise Stripe::StripeError.new # escape from parsing 57 | 58 | rescue JSON::ParserError, Stripe::StripeError => e 59 | raise general_api_error(rcode, rbody) 60 | end 61 | 62 | case rcode 63 | when 400, 404 64 | raise invalid_request_error error, rcode, rbody, error_obj 65 | when 401 66 | raise authentication_error error, rcode, rbody, error_obj 67 | when 402 68 | raise card_error error, rcode, rbody, error_obj 69 | else 70 | raise api_error error, rcode, rbody, error_obj 71 | end 72 | 73 | end 74 | 75 | def self.invalid_request_error(error, rcode, rbody, error_obj) 76 | Stripe::InvalidRequestError.new(error[:message], error[:param], rcode, 77 | rbody, error_obj) 78 | end 79 | 80 | def self.authentication_error(error, rcode, rbody, error_obj) 81 | Stripe::AuthenticationError.new(error[:message], rcode, rbody, error_obj) 82 | end 83 | 84 | def self.card_error(error, rcode, rbody, error_obj) 85 | Stripe::CardError.new(error[:message], error[:param], error[:code], 86 | rcode, rbody, error_obj) 87 | end 88 | 89 | def self.api_error(error, rcode, rbody, error_obj) 90 | Stripe::APIError.new(error[:message], rcode, rbody, error_obj) 91 | end 92 | 93 | def self.handle_restclient_error(e) 94 | case e 95 | when RestClient::ServerBrokeConnection, RestClient::RequestTimeout 96 | message = "Could not connect to Stripe (#{@api_base}). " + 97 | "Please check your internet connection and try again. " + 98 | "If this problem persists, you should check Stripe's service status at " + 99 | "https://twitter.com/stripestatus, or let us know at support@stripe.com." 100 | 101 | when RestClient::SSLCertificateNotVerified 102 | message = "Could not verify Stripe's SSL certificate. " + 103 | "Please make sure that your network is not intercepting certificates. " + 104 | "(Try going to https://api.stripe.com/v1 in your browser.) " + 105 | "If this problem persists, let us know at support@stripe.com." 106 | 107 | when SocketError 108 | message = "Unexpected error communicating when trying to connect to Stripe. " + 109 | "You may be seeing this message because your DNS is not working. " + 110 | "To check, try running 'host stripe.com' from the command line." 111 | 112 | else 113 | message = "Unexpected error communicating with Stripe. " + 114 | "If this problem persists, let us know at support@stripe.com." 115 | 116 | end 117 | 118 | raise Stripe::APIConnectionError.new(message + "\n\n(Network error: #{e.message})") 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/vat_service.rb: -------------------------------------------------------------------------------- 1 | class VatService 2 | ViesDown = Class.new(StandardError) 3 | VatCharge = Struct.new(:amount, :rate) 4 | 5 | # http://ec.europa.eu/taxation_customs/resources/documents/taxation/vat/how_vat_works/rates/vat_rates_en.pdf 6 | VAT_RATES = { 7 | 'BE' => 21, # Belgium 8 | 'BG' => 20, # Bulgaria 9 | 'CZ' => 21, # Czech Republic 10 | 'DK' => 25, # Denmark 11 | 'DE' => 19, # Germany 12 | 'EE' => 20, # Estonia 13 | 'EL' => 23, # Greece 14 | 'ES' => 21, # Spain 15 | 'FR' => 20, # France 16 | 'HR' => 25, # Croatia 17 | 'IE' => 23, # Ireland 18 | 'IT' => 22, # Italy 19 | 'CY' => 19, # Cryprus 20 | 'LV' => 21, # Latvia 21 | 'LT' => 21, # Lithuania 22 | 'LU' => 17, # Luxembourg 23 | 'HU' => 27, # Hungary 24 | 'MT' => 18, # Malta 25 | 'NL' => 21, # Netherlands 26 | 'AT' => 20, # Austria 27 | 'PL' => 23, # Poland 28 | 'PT' => 23, # Portugal 29 | 'RO' => 24, # Romania 30 | 'SI' => 22, # Slovenia 31 | 'SK' => 20, # Slovakia 32 | 'FI' => 24, # Finland 33 | 'SE' => 25, # Sweden 34 | 'GB' => 20 # United Kingdom 35 | } 36 | 37 | # Calculates VAT amount based on country and whether the customer 38 | # is a company or not. 39 | # 40 | # amount - Base amount that is VAT taxable in cents (or not). 41 | # country_code - ISO country code. 42 | # vat_registered - true if a customer is vat registered 43 | # 44 | # Returns amount of VAT payable, in cents (rounded down). 45 | def calculate(amount:, country_code:, vat_registered:) 46 | rate = vat_rate(country_code: country_code, 47 | vat_registered: vat_registered) 48 | VatCharge.new((amount*rate/100.0).round, rate) 49 | end 50 | 51 | # Checks if given VAT number is valid. 52 | # 53 | # vat_number - VAT number to be validated 54 | # 55 | # Returns true or false 56 | def valid?(vat_number:) 57 | vies_valid = Valvat::Lookup.validate(vat_number) 58 | if vies_valid.nil? 59 | Valvat.new(vat_number).valid_checksum? 60 | else 61 | vies_valid 62 | end 63 | rescue Savon::Error => e 64 | puts "Failed checking VAT validity of #{var_number}: #{e.to_s}" 65 | 66 | Valvat.new(vat_number).valid_checksum? 67 | end 68 | 69 | # returns extra info about the given vat_number 70 | # 71 | # vat_number - VAT number to be validated 72 | # own_vat - own VAT number to additionally get a request identifier 73 | # 74 | # Raises ViesDown if the VIES service is down. 75 | # 76 | # Returns details or false if the number does not exist. 77 | def details(vat_number:, own_vat: nil) 78 | details = if own_vat 79 | Valvat.new(vat_number).exists?(requester_vat: own_vat, raise_error: true) 80 | else 81 | Valvat.new(vat_number).exists?(detail: true) 82 | end 83 | 84 | raise ViesDown if details.nil? 85 | 86 | details 87 | end 88 | 89 | # Loads VIES data into the invoice model. 90 | # 91 | # Raises VatService::ViesDown if the VIES service is down. 92 | def load_vies_data(invoice:) 93 | details = self.details(vat_number: invoice.customer_vat_number, 94 | own_vat: Configuration.seller_vat_number) 95 | 96 | # details can still be nil here if a VAT number does not exist. 97 | # This case can still happen here because the VIES service 98 | # was down earlier and we used only checksum to pass the VAT number. 99 | if details 100 | invoice.update \ 101 | vies_company_name: details[:name].try(:strip).presence, 102 | vies_address: details[:address].try(:strip).presence, 103 | vies_request_identifier: details[:request_identifier] 104 | end 105 | end 106 | 107 | # Calculates VAT rate. 108 | # 109 | # country_code - ISO country code. 110 | # vat_registered - true if customer is vat registered 111 | # 112 | # Returns an integer (rate). 113 | def vat_rate(country_code:, vat_registered:) 114 | # VAT Rate is zero if country code is nil. 115 | return 0 if country_code.nil? 116 | 117 | # Companies pay VAT in the origin country when you are VAT registered there. 118 | # Individuals always need to pay the VAT rate set in their origin country. 119 | if registered?(country_code) || (eu?(country_code) && !vat_registered) 120 | VAT_RATES[country_code] 121 | 122 | # Companies in other EU countries don't need to pay VAT. 123 | # All non-EU customers don't need to pay VAT. 124 | else 125 | 0 126 | end 127 | end 128 | 129 | private 130 | 131 | # Whether the seller is registered in the given country. 132 | def registered?(country_code) 133 | Configuration.registered_countries.include?(country_code) 134 | end 135 | 136 | def eu?(country_code) 137 | Valvat::Utils::EU_COUNTRIES.include?(country_code) 138 | end 139 | end 140 | -------------------------------------------------------------------------------- /accounting/yuki: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require './config/environment' 3 | 4 | $debug = !!ARGV[4] 5 | 6 | now = Time.now 7 | year = ARGV[0].to_i 8 | month = ARGV[1].to_i 9 | day = ARGV[2].to_i 10 | days = (ARGV[3] || 1).to_i 11 | 12 | TEMPLATE = Tox.dsl do 13 | el('SalesInvoices', 14 | mel('SalesInvoice', { 15 | reference: el('Reference', text), 16 | subject: el('Subject', text), 17 | process: el('Process', text), 18 | date: el('Date', text), 19 | due_date: el('DueDate', text), 20 | contact_code: el('Contact', el('ContactCode', text)), 21 | lines: el('InvoiceLines', mel('InvoiceLine', { 22 | description: el('Description', text), 23 | quantity: el('ProductQuantity', text), 24 | product: el('Product', { 25 | description: el('Description', text), 26 | reference: el('Reference', text), 27 | category: el('Category', text), 28 | amount: el('SalesPrice', text), 29 | vat_rate: el('VATPercentage', text), 30 | vat_included: el('VATIncluded', text), 31 | vat_type: el('VATType', text), 32 | vat_description: el('VATDescription', text), 33 | account_code: el('GLAccountCode', text), 34 | remarks: el('Remarks', text) 35 | }) 36 | })) 37 | }) 38 | ) 39 | end 40 | 41 | def vat_code(invoice) 42 | vat_code = if !invoice.eu? 43 | ['TRMExport outside the EU', '6'] 44 | elsif invoice.customer_vat_number && invoice.eu? && invoice.customer_vat_number[0..1] != 'BE' 45 | ['TRMVAT reverse-charged', '17'] 46 | elsif invoice.customer_country_code == 'BE' 47 | ['TRMVAT 21%', '1'] 48 | else 49 | country_name = ISO3166::Country.new(invoice.customer_country_code).name 50 | ["MOSS #{country_name.upcase}", '8'] 51 | end 52 | end 53 | 54 | def yuki_line(invoice) 55 | original_invoice = invoice.reference || invoice 56 | vat_desc, vat_type = vat_code(original_invoice) 57 | 58 | total = (original_invoice.total_eur.to_f / 100).round(2) 59 | 60 | total = -total if invoice.credit_note? 61 | 62 | { 63 | description: "#{invoice.number} - #{original_invoice.stripe_subscription_id}", 64 | quantity: '1', 65 | product: { 66 | description: 'invoice', 67 | reference: '', 68 | category: '13', 69 | amount: total.to_s, 70 | vat_rate: (original_invoice.vat_rate || 0).round(2).to_f.to_s, 71 | vat_included: 'true', 72 | vat_type: vat_type, 73 | vat_description: vat_desc, 74 | account_code: '8000', 75 | remarks: 'none' 76 | } 77 | } 78 | end 79 | 80 | def upload_day!(client, session_id, administration_id, date) 81 | year, month, day, yday = date.year, date.month, date.day, date.yday 82 | xml_date = '%d-%02d-%02d' % [year, month, day] 83 | 84 | invoices = Invoice.between(date, date + 1.day).order_by(:number) 85 | 86 | categories = { 87 | 'M' => invoices.where('NOT credit_note').where("interval = 'month' or interval is null"), 88 | 'Y' => invoices.where('NOT credit_note').where(interval: 'year'), 89 | 'C' => invoices.where('credit_note') 90 | }.reject { |k, v| v.empty? } 91 | 92 | categories.each do |code, invoices| 93 | lines = invoices.map do |invoice| 94 | yuki_line(invoice) 95 | end 96 | 97 | xml = TEMPLATE.render([{ 98 | reference: '%s%s%03d' % [code, year, yday], 99 | subject: 'Dagtotaal (%s) %02d/%02d/%d' % [code, day, month, year], 100 | process: 'true', 101 | date: xml_date, 102 | due_date: xml_date, 103 | contact_code: '1', 104 | lines: lines 105 | }]) 106 | 107 | response = client.call(:process_sales_invoices, message: { 108 | 'sessionId' => session_id, 109 | 'administrationId' => administration_id, 110 | "xmlDoc!" => xml 111 | }) 112 | 113 | result = response.body[:process_sales_invoices_response][:process_sales_invoices_result][:sales_invoices_import_response][:invoice] 114 | 115 | if result[:succeeded] 116 | puts "\033[32m✓\033[0m #{result[:subject]} succeeded" 117 | else 118 | puts "\033[31m✗\033[0m #{result[:subject]} failed: #{result[:message]}" 119 | puts xml if $debug 120 | end 121 | end 122 | end 123 | 124 | client = Savon.client( 125 | wsdl: 'https://api.yukiworks.nl/ws/Sales.asmx?wsdl' 126 | ) 127 | 128 | session_id = client.call(:authenticate_by_user_name, message: { 129 | userName: ENV['YUKI_USERNAME'], 130 | password: ENV['YUKI_PASSWORD'] 131 | }).body[:authenticate_by_user_name_response][:authenticate_by_user_name_result] 132 | 133 | administration_id = client.call(:administration_id, message: { 134 | sessionID: session_id, 135 | administrationName: ENV['YUKI_ADMINISTRATION'] 136 | }).body[:administration_id_response][:administration_id_result] 137 | 138 | start = Date.new(year, month, day) 139 | 140 | days.times.each do |i| 141 | upload_day!(client, session_id, administration_id, start + i.days) 142 | end 143 | -------------------------------------------------------------------------------- /app/app.rb: -------------------------------------------------------------------------------- 1 | require 'template_view_model' 2 | 3 | class App < Base 4 | TEMPLATE = Tilt.new(File.expand_path('../templates/default.html.slim', __FILE__)) 5 | 6 | # Creates a new subscription with VAT. 7 | # 8 | # customer - ID of Stripe customer. 9 | # plan - ID of plan to subscribe on. 10 | # any other stripe options. 11 | # 12 | # Returns 200 if succesful 13 | post '/subscriptions' do 14 | customer = params.delete('customer') 15 | 16 | # Create subscription. 17 | subscription = invoice_service(customer_id: customer) 18 | .create_subscription(params) 19 | 20 | json(subscription) 21 | end 22 | 23 | get '/invoices/:number.pdf' do 24 | invoice = Invoice.where(number: params[:number]).first 25 | 26 | halt 404 unless invoice && invoice.pdf_generated_at 27 | 28 | pdf = pdf_service.retrieve_pdf(invoice) 29 | 30 | content_type pdf.content_type 31 | body pdf.read 32 | end 33 | 34 | # Populates a template with all invoice data. 35 | # This should never be used to show to customers, it should 36 | # only be used to generate PDF's, as information on the invoice 37 | # could change between different calls. 38 | # 39 | # number - The invoice number. 40 | # 41 | # Returns the html and status 200 if successful 42 | get '/invoices/:number' do 43 | content_type 'text/html' 44 | 45 | invoice = Invoice.where(number: params[:number]).first 46 | 47 | if invoice.credit_note? 48 | credit_note = invoice 49 | invoice = Invoice.where(number: invoice.reference_number).first 50 | end 51 | 52 | halt 404 unless invoice 53 | 54 | stripe_invoice = Stripe::Invoice.retrieve(invoice.stripe_id) 55 | 56 | TEMPLATE.render(TemplateViewModel.new( 57 | invoice: invoice, 58 | stripe_invoice: stripe_invoice, 59 | stripe_coupon: stripe_invoice.discount && stripe_invoice.discount.coupon, 60 | credit_note: credit_note, 61 | )) 62 | end 63 | 64 | # List invoices 65 | # 66 | # by_accounting_id - customer account identifier 67 | # finalized_before - finalized before given timestamp 68 | # finalized_after - finalized after given timestamp 69 | # 70 | # Returns a JSON array of {number: .., finalized_at: ..}. 71 | get '/invoices' do 72 | invoices = %w(by_accounting_id finalized_before finalized_after).reduce( 73 | Invoice.finalized.with_pdf_generated.newest_first 74 | ) do |scope,key| 75 | params.has_key?(key) ? scope.public_send(key, params[key]) : scope 76 | end 77 | 78 | json(invoices.all) 79 | end 80 | 81 | # Fetches a preview breakdown of the costs of a subscription. 82 | # 83 | # plan - Stripe plan ID. 84 | # country_code - Country code of the customer (ISO 3166-1 alpha-2 standard) 85 | # vat_registered - Whether the customer is vat registered (default: false). 86 | # 87 | # Returns 200 and 88 | # { 89 | # subtotal: 10, 90 | # currency: 'eur', 91 | # vat: 2.1, 92 | # vat_rate: '21' 93 | # } 94 | get '/preview/:plan' do 95 | plan = Stripe::Plan.retrieve(params[:plan]) 96 | quantity = (params[:quantity] && params[:quantity].to_i) || 1 97 | amount = plan.amount * quantity 98 | 99 | vat = vat_service.calculate \ 100 | amount: amount, 101 | country_code: params[:country_code], 102 | vat_registered: (params[:vat_registered] == 'true') 103 | 104 | json({ 105 | subtotal: amount, 106 | currency: plan.currency, 107 | vat: vat.amount, 108 | vat_rate: vat.rate 109 | }) 110 | end 111 | 112 | # Checks if given VAT number is valid. 113 | # 114 | # vat_number - VAT number to be validated 115 | # 116 | # Returns true or false 117 | get '/vat/:number' do 118 | if vat_service.valid?(vat_number: params[:number]) 119 | status 200 120 | else 121 | status 404 122 | end 123 | end 124 | 125 | # returns extra info about the given vat_number 126 | # 127 | # vat_number - VAT number to be validated 128 | # own_vat - own VAT number to additionally get a request identifier 129 | # 130 | # Returns details or false/nil 131 | get '/vat/:number/details' do 132 | begin 133 | request = { vat_number: params[:number] } 134 | request.merge!(own_vat: params[:own_vat]) if params[:own_vat] 135 | 136 | vat_service.details(request) || status(404) 137 | 138 | rescue VatService::ViesDown 139 | status(504) 140 | end 141 | end 142 | 143 | get '/ping' do 144 | status 200 145 | end 146 | 147 | # Possibility to reserve an empty slot in the invoices 148 | # (for legacy invoice systems and manual invoicing). 149 | # 150 | # Returns 200 and 151 | # { 152 | # year: 2014, 153 | # sequence_number: 1, 154 | # number: '2014.1' 155 | # finalized_at: '2014-07-30 17:16:35 +0200', 156 | # reserved_at: '2014-07-30 17:16:35 +0200' 157 | # } 158 | post '/reserve' do 159 | json(Invoice.reserve!) 160 | end 161 | end 162 | -------------------------------------------------------------------------------- /spec/cassettes/preview_quantity_success.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://api.stripe.com/v1/plans/test 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - "*/*" 12 | Accept-Encoding: 13 | - gzip, deflate 14 | User-Agent: 15 | - Stripe/v1 RubyBindings/1.48.0 16 | Authorization: 17 | - "" 18 | Content-Type: 19 | - application/x-www-form-urlencoded 20 | Stripe-Version: 21 | - '2015-10-16' 22 | X-Stripe-Client-User-Agent: 23 | - '{"bindings_version":"1.48.0","lang":"ruby","lang_version":"2.3.1 p112 (2016-04-26)","platform":"x86_64-darwin14","engine":"ruby","publisher":"stripe","uname":"Darwin 24 | Mattiass-MacBook-Pro.local 14.5.0 Darwin Kernel Version 14.5.0: Wed Jul 29 25 | 02:26:53 PDT 2015; root:xnu-2782.40.9~1/RELEASE_X86_64 x86_64","hostname":"Mattiass-MacBook-Pro.local"}' 26 | response: 27 | status: 28 | code: 200 29 | message: OK 30 | headers: 31 | Server: 32 | - nginx 33 | Date: 34 | - Wed, 18 Oct 2017 10:20:51 GMT 35 | Content-Type: 36 | - application/json 37 | Content-Length: 38 | - '272' 39 | Connection: 40 | - keep-alive 41 | Access-Control-Allow-Credentials: 42 | - 'true' 43 | Access-Control-Allow-Methods: 44 | - GET, POST, HEAD, OPTIONS, DELETE 45 | Access-Control-Allow-Origin: 46 | - "*" 47 | Access-Control-Expose-Headers: 48 | - X-Stripe-Privileged-Session-Required,stripe-manage-version,X-Stripe-External-Auth-Required 49 | Access-Control-Max-Age: 50 | - '300' 51 | Cache-Control: 52 | - no-cache, no-store 53 | Request-Id: 54 | - req_vz2aGLGyyHPAwI 55 | Stripe-Version: 56 | - '2015-10-16' 57 | Strict-Transport-Security: 58 | - max-age=31556926; includeSubDomains 59 | body: 60 | encoding: UTF-8 61 | string: | 62 | { 63 | "id": "test", 64 | "object": "plan", 65 | "amount": 1499, 66 | "created": 1481892395, 67 | "currency": "usd", 68 | "interval": "month", 69 | "interval_count": 1, 70 | "livemode": false, 71 | "metadata": {}, 72 | "name": "Test Plan", 73 | "statement_descriptor": null, 74 | "trial_period_days": null 75 | } 76 | http_version: 77 | recorded_at: Wed, 20 Jan 2016 00:00:00 GMT 78 | - request: 79 | method: get 80 | uri: https://api.stripe.com/v1/plans/test 81 | body: 82 | encoding: US-ASCII 83 | string: '' 84 | headers: 85 | Accept: 86 | - "*/*" 87 | Accept-Encoding: 88 | - gzip, deflate 89 | User-Agent: 90 | - Stripe/v1 RubyBindings/1.48.0 91 | Authorization: 92 | - "" 93 | Content-Type: 94 | - application/x-www-form-urlencoded 95 | Stripe-Version: 96 | - '2015-10-16' 97 | X-Stripe-Client-User-Agent: 98 | - '{"bindings_version":"1.48.0","lang":"ruby","lang_version":"2.3.1 p112 (2016-04-26)","platform":"x86_64-darwin14","engine":"ruby","publisher":"stripe","uname":"Darwin 99 | Mattiass-MacBook-Pro.local 14.5.0 Darwin Kernel Version 14.5.0: Wed Jul 29 100 | 02:26:53 PDT 2015; root:xnu-2782.40.9~1/RELEASE_X86_64 x86_64","hostname":"Mattiass-MacBook-Pro.local"}' 101 | response: 102 | status: 103 | code: 200 104 | message: OK 105 | headers: 106 | Server: 107 | - nginx 108 | Date: 109 | - Wed, 18 Oct 2017 10:20:52 GMT 110 | Content-Type: 111 | - application/json 112 | Content-Length: 113 | - '272' 114 | Connection: 115 | - keep-alive 116 | Access-Control-Allow-Credentials: 117 | - 'true' 118 | Access-Control-Allow-Methods: 119 | - GET, POST, HEAD, OPTIONS, DELETE 120 | Access-Control-Allow-Origin: 121 | - "*" 122 | Access-Control-Expose-Headers: 123 | - X-Stripe-Privileged-Session-Required,stripe-manage-version,X-Stripe-External-Auth-Required 124 | Access-Control-Max-Age: 125 | - '300' 126 | Cache-Control: 127 | - no-cache, no-store 128 | Request-Id: 129 | - req_ht79qmXjkuRgTv 130 | Stripe-Version: 131 | - '2015-10-16' 132 | Strict-Transport-Security: 133 | - max-age=31556926; includeSubDomains 134 | body: 135 | encoding: UTF-8 136 | string: | 137 | { 138 | "id": "test", 139 | "object": "plan", 140 | "amount": 1499, 141 | "created": 1481892395, 142 | "currency": "usd", 143 | "interval": "month", 144 | "interval_count": 1, 145 | "livemode": false, 146 | "metadata": {}, 147 | "name": "Test Plan", 148 | "statement_descriptor": null, 149 | "trial_period_days": null 150 | } 151 | http_version: 152 | recorded_at: Wed, 20 Jan 2016 00:00:00 GMT 153 | recorded_with: VCR 2.9.2 154 | -------------------------------------------------------------------------------- /spec/cassettes/validate_vat.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: http://ec.europa.eu/taxation_customs/vies/services/checkVatService 6 | body: 7 | encoding: UTF-8 8 | string: LU21416127 11 | headers: 12 | Soapaction: 13 | - '"checkVat"' 14 | Content-Type: 15 | - text/xml;charset=UTF-8 16 | Content-Length: 17 | - '476' 18 | response: 19 | status: 20 | code: 200 21 | message: '' 22 | headers: 23 | Date: 24 | - Wed, 20 Jan 2016 17:30:24 GMT 25 | Content-Type: 26 | - text/xml; charset=UTF-8 27 | Server: 28 | - Europa 29 | Proxy-Connection: 30 | - Keep-Alive 31 | Connection: 32 | - Keep-Alive 33 | body: 34 | encoding: UTF-8 35 | string: |- 36 | LU214161272016-01-20+01:00trueEBAY EUROPE S.A R.L.
22, BOULEVARD ROYAL 37 | L-2449 LUXEMBOURG
38 | http_version: 39 | recorded_at: Wed, 20 Jan 2016 17:30:27 GMT 40 | - request: 41 | method: post 42 | uri: http://ec.europa.eu/taxation_customs/vies/services/checkVatService 43 | body: 44 | encoding: UTF-8 45 | string: IE6388047V 48 | headers: 49 | Soapaction: 50 | - '"checkVat"' 51 | Content-Type: 52 | - text/xml;charset=UTF-8 53 | Content-Length: 54 | - '476' 55 | response: 56 | status: 57 | code: 200 58 | message: '' 59 | headers: 60 | Date: 61 | - Wed, 20 Jan 2016 17:30:24 GMT 62 | Content-Type: 63 | - text/xml; charset=UTF-8 64 | Server: 65 | - Europa 66 | Proxy-Connection: 67 | - Keep-Alive 68 | Connection: 69 | - Keep-Alive 70 | body: 71 | encoding: UTF-8 72 | string: IE6388047V2016-01-20+01:00trueGOOGLE 74 | IRELAND LIMITED
3RD FLOOR ,GORDON HOUSE ,BARROW STREET ,DUBLIN 75 | 4
76 | http_version: 77 | recorded_at: Wed, 20 Jan 2016 17:30:27 GMT 78 | - request: 79 | method: post 80 | uri: http://ec.europa.eu/taxation_customs/vies/services/checkVatService 81 | body: 82 | encoding: UTF-8 83 | string: LU21416128 86 | headers: 87 | Soapaction: 88 | - '"checkVat"' 89 | Content-Type: 90 | - text/xml;charset=UTF-8 91 | Content-Length: 92 | - '476' 93 | response: 94 | status: 95 | code: 200 96 | message: '' 97 | headers: 98 | Date: 99 | - Wed, 20 Jan 2016 17:30:25 GMT 100 | Content-Type: 101 | - text/xml; charset=UTF-8 102 | Server: 103 | - Europa 104 | Proxy-Connection: 105 | - Keep-Alive 106 | Connection: 107 | - Keep-Alive 108 | body: 109 | encoding: UTF-8 110 | string: LU214161282016-01-20+01:00false---
---
112 | http_version: 113 | recorded_at: Wed, 20 Jan 2016 17:30:27 GMT 114 | recorded_with: VCR 2.9.2 115 | -------------------------------------------------------------------------------- /spec/client/bilbo_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | 3 | describe Billbo do 4 | 5 | let(:preview) {{ 6 | subtotal: 10, 7 | currency: 'eur', 8 | vat: 2.1, 9 | vat_rate: '21' 10 | }} 11 | 12 | let(:preview_quantity) {{ 13 | subtotal: 30, 14 | currency: 'eur', 15 | vat: 6.3, 16 | vat_rate: '21' 17 | }} 18 | 19 | let(:reservation) {{ 20 | year: 2014, 21 | sequence_number: 1, 22 | number: '2014.1', 23 | finalized_at: Time.parse('2014-07-30 17:16:35 +0200'), 24 | reserved_at: Time.parse('2014-07-30 17:16:35 +0200') 25 | }} 26 | 27 | let(:subscription) {{ 28 | id: "sub_4UdC1FzfwISacg", 29 | plan: "basic", 30 | object: "subscription", 31 | start: 1406653101, 32 | status: "active" 33 | }} 34 | 35 | let(:error) {{ 36 | error: { 37 | message: "not good", 38 | type: "card_error", 39 | code: 1, 40 | param: "test" 41 | } 42 | }} 43 | 44 | describe '#preview' do 45 | it 'returns a preview price calculation' do 46 | stub_app(:get, 'preview/basic', {query: {country_code: 'BE', vat_registered: 'false'}}, json: preview) 47 | 48 | Billbo.preview( 49 | plan: 'basic', 50 | country_code: 'BE', 51 | vat_registered: false 52 | ).must_equal preview 53 | end 54 | 55 | it 'returns a preview price calculation (with quantity)' do 56 | stub_app(:get, 'preview/basic', {query: {country_code: 'BE', quantity: 3, vat_registered: 'false'}}, json: preview_quantity) 57 | 58 | Billbo.preview( 59 | plan: 'basic', 60 | country_code: 'BE', 61 | quantity: 3, 62 | vat_registered: false 63 | ).must_equal preview_quantity 64 | end 65 | end 66 | 67 | describe '#reserve' do 68 | subject { Billbo.reserve } 69 | before { stub_app(:post, 'reserve', {}, json: reservation) } 70 | 71 | it 'returns a Billbo::Invoice' do 72 | subject.must_be_kind_of Billbo::Invoice 73 | end 74 | 75 | it 'reserves an empty invoice slot' do 76 | subject.must_equal Billbo::Invoice.new(reservation) 77 | end 78 | end 79 | 80 | describe '#vat' do 81 | it 'returns the number itself if it exists' do 82 | stub_app(:get, 'vat/BE123', {}, status: 200) 83 | 84 | Billbo.vat('BE123').must_equal number: 'BE123' 85 | end 86 | 87 | it 'returns nil if the vat number does not exist' do 88 | stub_app(:get, 'vat/BE123', {}, status: 404) 89 | 90 | Billbo.vat('BE123').must_be_nil 91 | end 92 | end 93 | 94 | describe '#vat/details' do 95 | let(:details) {{ 96 | country_code: 'IE', 97 | vat_number: '1', 98 | request_date: 'date', 99 | name: 'name', 100 | address: 'address' 101 | }} 102 | 103 | it 'returns details about the number if it exists' do 104 | stub_app(:get, 'vat/BE123/details', {}, json: details) 105 | 106 | Billbo.vat_details('BE123').must_equal details 107 | end 108 | 109 | it 'returns nil if the vat number does not exist' do 110 | stub_app(:get, 'vat/BE123/details', {}, status: 404) 111 | 112 | Billbo.vat_details('BE123').must_be_nil 113 | end 114 | end 115 | 116 | describe '#create_subscription' do 117 | it 'returns the created subscription' do 118 | stub_app(:post, 'subscriptions', {body: {plan: 'basic', customer: 'x', other: 'things', metadata: {one: 'two'}}}, json: subscription) 119 | 120 | sub = Billbo.create_subscription( 121 | plan: 'basic', 122 | customer: 'x', 123 | other: 'things', 124 | metadata: { 125 | one: 'two' 126 | } 127 | ) 128 | 129 | sub.must_be_kind_of(Stripe::Subscription) 130 | sub.to_h.must_equal subscription 131 | end 132 | 133 | describe 'a Stripe error occurs' do 134 | it 'raises the error' do 135 | stub_app(:post, 'subscriptions', {body: {plan: 'basic', customer: 'x', other: 'things'}}, status: 402, json: error) 136 | 137 | proc do 138 | Billbo.create_subscription( 139 | plan: 'basic', 140 | customer: 'x', 141 | other: 'things' 142 | ) 143 | end.must_raise(Stripe::CardError) 144 | end 145 | end 146 | end 147 | 148 | describe '#invoices' do 149 | let(:account_id) { 'wilma' } 150 | let(:result) { [{number: 'fred'}, {number: 'barney'}] } 151 | 152 | before do 153 | stub_app(:get, 'invoices', {query: {by_account_id: account_id}}, json: result) 154 | end 155 | 156 | subject { Billbo.invoices(by_account_id: account_id) } 157 | 158 | it 'returns result' do 159 | subject.size.must_equal(result.size) 160 | end 161 | 162 | it 'returns number props' do 163 | subject.map(&:number).must_equal(result.map{|v| v[:number]}) 164 | end 165 | 166 | it 'returns Billbo::Invoice instances' do 167 | subject.map(&:class).uniq.must_equal([Billbo::Invoice]) 168 | end 169 | end 170 | 171 | describe '#pdf' do 172 | let(:number) { 31415 } 173 | let(:data) { 'yabadabadoo!' } 174 | 175 | before do 176 | stub_app(:get, "invoices/#{number}.pdf", {}, body: data) 177 | end 178 | 179 | it 'returns data' do 180 | Billbo.pdf(number). 181 | must_equal(data) 182 | end 183 | end 184 | 185 | def stub_app(method, path, with, response) 186 | response = response.dup 187 | response[:status] ||= 200 188 | if json = response.delete(:json) 189 | response.merge!( 190 | headers: {content_type: 'application/json'}, 191 | body: JSON.dump(json) 192 | ) 193 | end 194 | 195 | stub_request(method, "https://X:TOKEN@billbo.test/#{path}") 196 | .with(with) 197 | .to_return(response) 198 | end 199 | end 200 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Billbo 2 | ============== 3 | [![Build Status](https://secure.travis-ci.org/piesync/billbo.png?branch=master)](http://travis-ci.org/piesync/billbo) 4 | [![Test Coverage](https://codeclimate.com/github/piesync/billbo/coverage.png)](https://codeclimate.com/github/piesync/billbo) 5 | 6 | About 7 | ----- 8 | 9 | Billbo is an easy to use billing service including VAT support written in Ruby that is designed and built around the [Stripe] API. It decouples billing functionality from your application's core functionality and centralizes billing and invoicing logic into a stable workflow. 10 | 11 | Billbo uses the Stripe invoicing system and adds VAT information/charges following legal regulations. As soon as a Stripe invoice charge succeeds, an internal invoice is created, generated in pdf and emailed to the customer. 12 | 13 | It's currently designed to support billing from EU based countries. 14 | 15 | [Stripe]: https://stripe.com/ 16 | 17 | Features 18 | -------- 19 | 20 | * Automatic VAT addition according to legal regulations (incl 2015 VAT update) 21 | * VAT number validation and owner details using VIES service (company name, address) 22 | * Automatic and consistent invoice numbering (configurable) 23 | * Revenue analytics with [Segment.io](https://segment.io/) (optional) 24 | * Error Tracking with [Getsentry](https://getsentry.com/) (optional) 25 | * Automatic and legally correct pdf invoice generation ([download example pdf](https://github.com/piesync/billbo/blob/master/assets/example.pdf?raw=true)) 26 | * Battle-tested at [PieSync](http://www.piesync.com) 27 | * Works in various situations like prorations, discount ([examples](https://github.com/piesync/billbo/tree/master/spec/visual)) 28 | * Works with refunds 29 | * Invoice storage: choice between local or or Amazon S3 30 | 31 | How it Works with Stripe 32 | ------------------------ 33 | 34 | Subscriptions are created through Billbo instead of through the Stripe API directly. When the subscription is being created, Billbo calculated the correct VAT rate that should be applied to this subscription based on the customer metadata. This VAT rate is then passed to Stripe as `tax_percent`. 35 | 36 | When we receive a `invoice.payment_succeeded` event from Stripe, we finalize and assign an invoice number to the associated invoice in the Billbo database. 37 | 38 | Deployment and configuration 39 | ---------- 40 | 41 | The easiest way to get Billbo online to use it for production is Heroku. The `deploy-heroku` script in the root directory helps you with that. It provisions a Heroku instance with a Postgres database and all the right settings. 42 | 43 | The fastest path we can offer you: 44 | 45 | **Deploy to Heroku** 46 | 47 | ``` 48 | git clone git@github.com:piesync/billbo.git && cd billbo 49 | bundle install 50 | ./deploy-heroku [HEROKU_APP_NAME] -s [SECRET_STRIPE_KEY] 51 | ``` 52 | 53 | The deploy script takes lots of additional options to customize your Billbo instance. Just run `./deploy-heroku` to see usage details. 54 | 55 | **Configure Stripe Webhook** 56 | 57 | Add a Webhook to Stripe that points to `https://HEROKU_APP_NAME.herokuapp.com/hook` 58 | 59 | **Configuration** 60 | 61 | TK list env vars 62 | 63 | **Generating invoices** 64 | 65 | TK CRON JOB 66 | 67 | Basic Usage 68 | ----------- 69 | Billbo works with all different types of Stripe invoicing workflows, the only specifics you need are customer related. 70 | 71 | **Creating Stripe Customers** 72 | 73 | Create Stripe customers with the following required metadata. 74 | 75 | Example using Ruby ([or using Curl](https://stripe.com/docs/api/curl#create_customer)): 76 | ```ruby 77 | Stripe::Customer.create( 78 | card: "tok_14JuLq2nHroS7mLXZ5uxDRqs" # obtained with Stripe.js 79 | metadata: { 80 | country_code: 'US', # required - ISO 3166-1 alpha-2 standard 81 | vat_registered: true, # required 82 | name: 'John Doe', # optional 83 | company_name: 'DoeComp', # optional, VIES value if not provided and vat_number is provided 84 | address: 'Doestreet 1, 1111 Doeville', # optional, VIES value if not provided and vat_number is provided 85 | vat_number: 'DOE1234', # optional 86 | accounting_id: '8723648', # optional 87 | ... # optional extra metadata 88 | } 89 | ) 90 | ``` 91 | This data and any extra metadata of your customers will be copied into the metadata of your Stripe invoices. 92 | This way you can make your invoices immutable containing all their needed info taken at a certain point in time. 93 | 94 | 95 | **Creating Subscriptions** 96 | 97 | To use Billbo in the correct way, instead of creating subscriptions using the Stripe API, create your subscriptions using Billbo. 98 | 99 | Example using the `billbo` Ruby Gem: 100 | ```ruby 101 | Billbo.create_subscription( 102 | plan: 'basic', 103 | customer: 'cus_4XWKfwBrWLHvf8', 104 | ... # other Stripe compatible options 105 | ) 106 | # => A Stripe::Subscription object 107 | ``` 108 | 109 | Or using Curl: 110 | ``` 111 | curl https://HOST/subscriptions \ 112 | -u X:BILLBO_TOKEN \ 113 | -d plan=large 114 | ``` 115 | 116 | You can pass all options supported in the Stripe [create subscription](https://stripe.com/docs/api#create_subscription) call. The returned `Stripe::Subscription` or raised errors are 100% compatible with the [Stripe Ruby Gem](https://github.com/stripe/stripe-ruby). 117 | 118 | Help and Discussion 119 | ------------------- 120 | 121 | If you need help you can contact us by sending a message to: 122 | [oss@piesync.com][mail]. 123 | 124 | [mail]: mailto:oss@piesync.com 125 | 126 | If you believe you've found a bug, please report it at: 127 | https://github.com/piesync/billbo/issues 128 | 129 | 130 | Contributing to Billbo 131 | ---------------------- 132 | 133 | * Please fork Billbo on github 134 | * Make your changes and send us a pull request with a brief description of your addition 135 | * We will review all pull requests and merge them upon approval 136 | 137 | Copyright 138 | --------- 139 | 140 | Copyright (c) 2014 PieSync. 141 | -------------------------------------------------------------------------------- /spec/cassettes/account.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://api.stripe.com/v1/account 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - "*/*; q=0.5, application/xml" 12 | Accept-Encoding: 13 | - gzip, deflate 14 | User-Agent: 15 | - Stripe/v1 RubyBindings/1.14.0 16 | Authorization: 17 | - "" 18 | Content-Type: 19 | - application/x-www-form-urlencoded 20 | Stripe-Version: 21 | - '2015-10-16' 22 | X-Stripe-Client-User-Agent: 23 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 24 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 25 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 26 | response: 27 | status: 28 | code: 200 29 | message: OK 30 | headers: 31 | Server: 32 | - nginx 33 | Date: 34 | - Wed, 20 Jan 2016 17:39:14 GMT 35 | Content-Type: 36 | - application/json 37 | Content-Length: 38 | - '2084' 39 | Connection: 40 | - keep-alive 41 | Access-Control-Allow-Credentials: 42 | - 'true' 43 | Access-Control-Allow-Methods: 44 | - GET, POST, HEAD, OPTIONS, DELETE 45 | Access-Control-Allow-Origin: 46 | - "*" 47 | Access-Control-Max-Age: 48 | - '300' 49 | Cache-Control: 50 | - no-cache, no-store 51 | Request-Id: 52 | - req_7kvmItUAFCyeBO 53 | Stripe-Version: 54 | - '2015-10-16' 55 | Strict-Transport-Security: 56 | - max-age=31556926; includeSubDomains 57 | body: 58 | encoding: UTF-8 59 | string: | 60 | { 61 | "id": "acct_102w3g2nHroS7mLX", 62 | "object": "account", 63 | "business_logo": "https://s3.amazonaws.com/stripe-uploads/acct_102w3g2nHroS7mLXmerchant-icon-121515-avatar-128.png", 64 | "business_name": "PieSync", 65 | "business_url": "piesync.com", 66 | "charges_enabled": true, 67 | "country": "BE", 68 | "currencies_supported": [ 69 | "usd", 70 | "aed", 71 | "afn", 72 | "all", 73 | "amd", 74 | "ang", 75 | "aoa", 76 | "ars", 77 | "aud", 78 | "awg", 79 | "azn", 80 | "bam", 81 | "bbd", 82 | "bdt", 83 | "bgn", 84 | "bif", 85 | "bmd", 86 | "bnd", 87 | "bob", 88 | "brl", 89 | "bsd", 90 | "bwp", 91 | "bzd", 92 | "cad", 93 | "cdf", 94 | "chf", 95 | "clp", 96 | "cny", 97 | "cop", 98 | "crc", 99 | "cve", 100 | "czk", 101 | "djf", 102 | "dkk", 103 | "dop", 104 | "dzd", 105 | "egp", 106 | "etb", 107 | "eur", 108 | "fjd", 109 | "fkp", 110 | "gbp", 111 | "gel", 112 | "gip", 113 | "gmd", 114 | "gnf", 115 | "gtq", 116 | "gyd", 117 | "hkd", 118 | "hnl", 119 | "hrk", 120 | "htg", 121 | "huf", 122 | "idr", 123 | "ils", 124 | "inr", 125 | "isk", 126 | "jmd", 127 | "jpy", 128 | "kes", 129 | "kgs", 130 | "khr", 131 | "kmf", 132 | "krw", 133 | "kyd", 134 | "kzt", 135 | "lak", 136 | "lbp", 137 | "lkr", 138 | "lrd", 139 | "lsl", 140 | "ltl", 141 | "mad", 142 | "mdl", 143 | "mga", 144 | "mkd", 145 | "mnt", 146 | "mop", 147 | "mro", 148 | "mur", 149 | "mvr", 150 | "mwk", 151 | "mxn", 152 | "myr", 153 | "mzn", 154 | "nad", 155 | "ngn", 156 | "nio", 157 | "nok", 158 | "npr", 159 | "nzd", 160 | "pab", 161 | "pen", 162 | "pgk", 163 | "php", 164 | "pkr", 165 | "pln", 166 | "pyg", 167 | "qar", 168 | "ron", 169 | "rsd", 170 | "rub", 171 | "rwf", 172 | "sar", 173 | "sbd", 174 | "scr", 175 | "sek", 176 | "sgd", 177 | "shp", 178 | "sll", 179 | "sos", 180 | "srd", 181 | "std", 182 | "svc", 183 | "szl", 184 | "thb", 185 | "tjs", 186 | "top", 187 | "try", 188 | "ttd", 189 | "twd", 190 | "tzs", 191 | "uah", 192 | "ugx", 193 | "uyu", 194 | "uzs", 195 | "vnd", 196 | "vuv", 197 | "wst", 198 | "xaf", 199 | "xcd", 200 | "xof", 201 | "xpf", 202 | "yer", 203 | "zar", 204 | "zmw" 205 | ], 206 | "default_currency": "eur", 207 | "details_submitted": true, 208 | "display_name": "Piesync", 209 | "email": "admin@piesync.com", 210 | "managed": false, 211 | "statement_descriptor": "PIESYNC.COM", 212 | "support_phone": "+32472582979", 213 | "timezone": "Etc/UTC", 214 | "transfers_enabled": true 215 | } 216 | http_version: 217 | recorded_at: Wed, 20 Jan 2016 17:39:16 GMT 218 | recorded_with: VCR 2.9.2 219 | -------------------------------------------------------------------------------- /spec/cassettes/configuration_preload.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: get 5 | uri: https://api.stripe.com/v1/account 6 | body: 7 | encoding: US-ASCII 8 | string: '' 9 | headers: 10 | Accept: 11 | - "*/*; q=0.5, application/xml" 12 | Accept-Encoding: 13 | - gzip, deflate 14 | User-Agent: 15 | - Stripe/v1 RubyBindings/1.14.0 16 | Authorization: 17 | - "" 18 | Content-Type: 19 | - application/x-www-form-urlencoded 20 | Stripe-Version: 21 | - '2015-10-16' 22 | X-Stripe-Client-User-Agent: 23 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 24 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 25 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 26 | response: 27 | status: 28 | code: 200 29 | message: OK 30 | headers: 31 | Server: 32 | - nginx 33 | Date: 34 | - Wed, 20 Jan 2016 17:29:54 GMT 35 | Content-Type: 36 | - application/json 37 | Content-Length: 38 | - '2084' 39 | Connection: 40 | - keep-alive 41 | Access-Control-Allow-Credentials: 42 | - 'true' 43 | Access-Control-Allow-Methods: 44 | - GET, POST, HEAD, OPTIONS, DELETE 45 | Access-Control-Allow-Origin: 46 | - "*" 47 | Access-Control-Max-Age: 48 | - '300' 49 | Cache-Control: 50 | - no-cache, no-store 51 | Request-Id: 52 | - req_7kvdrT5Gr38a5s 53 | Stripe-Version: 54 | - '2015-10-16' 55 | Strict-Transport-Security: 56 | - max-age=31556926; includeSubDomains 57 | body: 58 | encoding: UTF-8 59 | string: | 60 | { 61 | "id": "acct_102w3g2nHroS7mLX", 62 | "object": "account", 63 | "business_logo": "https://s3.amazonaws.com/stripe-uploads/acct_102w3g2nHroS7mLXmerchant-icon-121515-avatar-128.png", 64 | "business_name": "PieSync", 65 | "business_url": "piesync.com", 66 | "charges_enabled": true, 67 | "country": "BE", 68 | "currencies_supported": [ 69 | "usd", 70 | "aed", 71 | "afn", 72 | "all", 73 | "amd", 74 | "ang", 75 | "aoa", 76 | "ars", 77 | "aud", 78 | "awg", 79 | "azn", 80 | "bam", 81 | "bbd", 82 | "bdt", 83 | "bgn", 84 | "bif", 85 | "bmd", 86 | "bnd", 87 | "bob", 88 | "brl", 89 | "bsd", 90 | "bwp", 91 | "bzd", 92 | "cad", 93 | "cdf", 94 | "chf", 95 | "clp", 96 | "cny", 97 | "cop", 98 | "crc", 99 | "cve", 100 | "czk", 101 | "djf", 102 | "dkk", 103 | "dop", 104 | "dzd", 105 | "egp", 106 | "etb", 107 | "eur", 108 | "fjd", 109 | "fkp", 110 | "gbp", 111 | "gel", 112 | "gip", 113 | "gmd", 114 | "gnf", 115 | "gtq", 116 | "gyd", 117 | "hkd", 118 | "hnl", 119 | "hrk", 120 | "htg", 121 | "huf", 122 | "idr", 123 | "ils", 124 | "inr", 125 | "isk", 126 | "jmd", 127 | "jpy", 128 | "kes", 129 | "kgs", 130 | "khr", 131 | "kmf", 132 | "krw", 133 | "kyd", 134 | "kzt", 135 | "lak", 136 | "lbp", 137 | "lkr", 138 | "lrd", 139 | "lsl", 140 | "ltl", 141 | "mad", 142 | "mdl", 143 | "mga", 144 | "mkd", 145 | "mnt", 146 | "mop", 147 | "mro", 148 | "mur", 149 | "mvr", 150 | "mwk", 151 | "mxn", 152 | "myr", 153 | "mzn", 154 | "nad", 155 | "ngn", 156 | "nio", 157 | "nok", 158 | "npr", 159 | "nzd", 160 | "pab", 161 | "pen", 162 | "pgk", 163 | "php", 164 | "pkr", 165 | "pln", 166 | "pyg", 167 | "qar", 168 | "ron", 169 | "rsd", 170 | "rub", 171 | "rwf", 172 | "sar", 173 | "sbd", 174 | "scr", 175 | "sek", 176 | "sgd", 177 | "shp", 178 | "sll", 179 | "sos", 180 | "srd", 181 | "std", 182 | "svc", 183 | "szl", 184 | "thb", 185 | "tjs", 186 | "top", 187 | "try", 188 | "ttd", 189 | "twd", 190 | "tzs", 191 | "uah", 192 | "ugx", 193 | "uyu", 194 | "uzs", 195 | "vnd", 196 | "vuv", 197 | "wst", 198 | "xaf", 199 | "xcd", 200 | "xof", 201 | "xpf", 202 | "yer", 203 | "zar", 204 | "zmw" 205 | ], 206 | "default_currency": "eur", 207 | "details_submitted": true, 208 | "display_name": "Piesync", 209 | "email": "admin@piesync.com", 210 | "managed": false, 211 | "statement_descriptor": "PIESYNC.COM", 212 | "support_phone": "+32472582979", 213 | "timezone": "Etc/UTC", 214 | "transfers_enabled": true 215 | } 216 | http_version: 217 | recorded_at: Wed, 20 Jan 2016 17:29:57 GMT 218 | recorded_with: VCR 2.9.2 219 | -------------------------------------------------------------------------------- /spec/hooks_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe Hooks do 4 | include Rack::Test::Methods 5 | 6 | def app 7 | Hooks 8 | end 9 | 10 | let(:stripe_event_id) { 'xxx' } 11 | 12 | let(:metadata) {{ 13 | country_code: 'NL', 14 | vat_registered: 'false', 15 | other: 'random' 16 | }} 17 | 18 | let(:customer) do 19 | Stripe::Customer.create \ 20 | card: { 21 | number: '4242424242424242', 22 | exp_month: '12', 23 | exp_year: '30', 24 | cvc: '222' 25 | }, 26 | metadata: metadata 27 | end 28 | 29 | let(:stripe_invoice) do 30 | Stripe::InvoiceItem.create \ 31 | customer: customer.id, 32 | amount: 100, 33 | currency: 'usd' 34 | 35 | Stripe::Invoice.create(customer: customer.id) 36 | end 37 | 38 | let(:plan) do 39 | begin 40 | Stripe::Plan.retrieve('test') 41 | rescue 42 | Stripe::Plan.create \ 43 | id: 'test', 44 | name: 'Test Plan', 45 | amount: 1499, 46 | currency: 'usd', 47 | interval: 'month' 48 | end 49 | end 50 | 51 | describe 'any hook' do 52 | it 'spreads a rumor' do 53 | Rumor.expects(:spread).with do |rumor| 54 | rumor.event == :charge_succeeded && 55 | rumor.subject == 1 56 | end 57 | 58 | post '/', 59 | json( 60 | id: stripe_event_id, 61 | type: 'charge.succeeded', 62 | data: {object: 1} 63 | ) 64 | end 65 | end 66 | 67 | describe 'stubbed rumor' do 68 | before do 69 | Rumor.stubs(:spread) 70 | end 71 | 72 | describe 'post invoice payment succeeded' do 73 | it 'finalizes the invoice' do 74 | VCR.use_cassette('hook_invoice_payment_succeeded') do 75 | stripe_invoice.pay 76 | 77 | post '/', 78 | json( 79 | id: stripe_event_id, 80 | type: 'invoice.payment_succeeded', 81 | data: { object: stripe_invoice} 82 | ) 83 | 84 | last_response.ok?.must_equal true 85 | last_response.body.must_be_empty 86 | 87 | Invoice.count.must_equal 1 88 | invoice = Invoice.first 89 | invoice.sequence_number.must_equal 1 90 | invoice.finalized_at.wont_be_nil 91 | invoice.credit_note.must_equal false 92 | end 93 | end 94 | end 95 | 96 | describe 'post charge refunded' do 97 | it 'creates a credit note' do 98 | VCR.use_cassette('hook_charge_refunded') do 99 | customer.subscriptions.create(plan: plan.id) 100 | stripe_invoice = customer.invoices.first 101 | stripe_charge = Stripe::Charge.retrieve(stripe_invoice.charge) 102 | 103 | post '/', 104 | json( 105 | id: stripe_event_id, 106 | type: 'invoice.payment_succeeded', 107 | data: {object: stripe_invoice} 108 | ) 109 | 110 | refund = stripe_charge.refund 111 | 112 | stripe_charge = Stripe::Charge.retrieve(stripe_invoice.charge) 113 | 114 | post '/', 115 | json( 116 | id: stripe_event_id, 117 | type: 'charge.refunded', 118 | data: {object: stripe_charge} 119 | ) 120 | 121 | last_response.ok?.must_equal true 122 | last_response.body.must_be_empty 123 | 124 | Invoice.count.must_equal 2 125 | 126 | invoice = Invoice.order(:sequence_number).first 127 | invoice.sequence_number.must_equal 1 128 | invoice.finalized_at.wont_be_nil 129 | invoice.credit_note.must_equal false 130 | 131 | credit_note = Invoice.order(:sequence_number).last 132 | credit_note.sequence_number.must_equal 2 133 | credit_note.finalized_at.wont_be_nil 134 | credit_note.credit_note.must_equal true 135 | credit_note.reference_number.must_equal invoice.number 136 | end 137 | end 138 | end 139 | 140 | describe 'post charge refunded partial' do 141 | it 'does not create a credit note' do 142 | VCR.use_cassette('hook_charge_refunded_partial') do 143 | customer.subscriptions.create(plan: plan.id) 144 | stripe_invoice = customer.invoices.first 145 | stripe_charge = Stripe::Charge.retrieve(stripe_invoice.charge) 146 | 147 | post '/', 148 | json( 149 | id: stripe_event_id, 150 | type: 'invoice.payment_succeeded', 151 | data: {object: stripe_invoice} 152 | ) 153 | 154 | refund = stripe_charge.refund amount: 100 155 | 156 | stripe_charge = Stripe::Charge.retrieve(stripe_invoice.charge) 157 | before_count = Invoice.count 158 | 159 | post '/', 160 | json( 161 | id: stripe_event_id, 162 | type: 'charge.refunded', 163 | data: {object: stripe_charge} 164 | ) 165 | 166 | last_response.ok?.must_equal true 167 | Invoice.count.must_equal before_count 168 | end 169 | end 170 | end 171 | 172 | describe 'a stripe error occurs' do 173 | it 'responds with the error' do 174 | invoice_service = stub 175 | 176 | app.any_instance.stubs(:invoice_service) 177 | .with(customer_id: '10').returns(invoice_service) 178 | 179 | invoice_service.expects(:process_payment) 180 | .raises(Stripe::CardError.new('not good', :test, 1)) 181 | 182 | post '/', 183 | json( 184 | id: stripe_event_id, 185 | type: 'invoice.payment_succeeded', 186 | data: {object: {id: '1', customer: '10'}} 187 | ) 188 | 189 | last_response.ok?.must_equal false 190 | last_response.status.must_equal 402 191 | last_response.body.must_equal '{"error":{"message":"not good","type":"card_error","code":1,"param":"test"}}' 192 | end 193 | end 194 | 195 | describe 'the customer does not have any metadata' do 196 | let(:metadata) { { other: 'random' } } 197 | 198 | it 'does nothing' do 199 | VCR.use_cassette('hook_invoice_created_no_meta') do 200 | stripe_invoice.pay 201 | 202 | post '/', 203 | json( 204 | id: stripe_event_id, 205 | type: 'invoice.payment_succeeded', 206 | data: {object: Stripe::Invoice.retrieve(stripe_invoice.id)} 207 | ) 208 | 209 | last_response.ok?.must_equal true 210 | invoice = Invoice.first 211 | invoice.number.wont_be_nil 212 | end 213 | end 214 | end 215 | end 216 | 217 | def json(object) 218 | MultiJson.dump(object) 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /spec/invoice_service_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative 'spec_helper' 2 | 3 | describe InvoiceService do 4 | 5 | let(:stripe_event_id) { 'xxx' } 6 | 7 | let(:metadata) {{ 8 | country_code: 'NL', 9 | vat_registered: 'false', 10 | vat_number: 'NL123', 11 | accounting_id: '10001', 12 | other: 'random' 13 | }} 14 | 15 | let(:plan) do 16 | begin 17 | Stripe::Plan.retrieve('test') 18 | rescue 19 | Stripe::Plan.create \ 20 | id: 'test', 21 | name: 'Test Plan', 22 | amount: 1499, 23 | currency: 'usd', 24 | interval: 'month' 25 | end 26 | end 27 | 28 | let(:customer) do 29 | Stripe::Customer.create \ 30 | card: { 31 | number: '4242424242424242', 32 | exp_month: '12', 33 | exp_year: '30', 34 | cvc: '222' 35 | }, 36 | metadata: metadata 37 | end 38 | 39 | let(:coupon) do 40 | begin 41 | Stripe::Coupon.retrieve('25OFF') 42 | rescue 43 | Stripe::Coupon.create( 44 | percent_off: 25, 45 | duration: 'repeating', 46 | duration_in_months: 3, 47 | id: '25OFF' 48 | ) 49 | end 50 | end 51 | 52 | let(:service) { InvoiceService.new(customer_id: customer.id) } 53 | 54 | describe '#process_payment' do 55 | describe 'with new invoice' do 56 | it 'finalizes the invoice' do 57 | VCR.use_cassette('process_payment_new') do 58 | service.create_subscription(plan: plan.id) 59 | 60 | invoice = service.process_payment( 61 | stripe_event_id: stripe_event_id, 62 | stripe_invoice_id: service.last_stripe_invoice.id 63 | ) 64 | 65 | invoice.finalized?.must_equal true 66 | invoice.subtotal.must_equal 1499 67 | invoice.discount_amount.must_equal 0 68 | invoice.subtotal_after_discount.must_equal 1499 69 | invoice.vat_amount.must_equal 315 70 | invoice.vat_rate.must_equal 21 71 | invoice.total.must_equal 1814 72 | invoice.currency.must_equal 'usd' 73 | invoice.customer_country_code.must_equal 'NL' 74 | invoice.customer_vat_number.must_equal 'NL123' 75 | invoice.stripe_event_id.must_equal stripe_event_id 76 | invoice.stripe_customer_id.must_equal customer.id 77 | invoice.customer_accounting_id.must_equal '10001' 78 | invoice.customer_vat_registered.must_equal false 79 | invoice.card_brand.must_equal 'Visa' 80 | invoice.card_last4.must_equal '4242' 81 | invoice.card_country_code.must_equal 'US' 82 | invoice.interval.must_equal 'month' 83 | invoice.stripe_subscription_id.wont_be_nil 84 | end 85 | end 86 | 87 | describe 'when the plan is yearly' do 88 | let(:plan) do 89 | begin 90 | Stripe::Plan.retrieve('test_yearly') 91 | rescue 92 | Stripe::Plan.create \ 93 | id: 'test_yearly', 94 | name: 'Test Yearly Plan', 95 | amount: 10000, 96 | currency: 'usd', 97 | interval: 'year' 98 | end 99 | end 100 | 101 | it 'finalizes the invoice' do 102 | VCR.use_cassette('process_payment_yearly') do 103 | service.create_subscription(plan: plan.id) 104 | 105 | invoice = service.process_payment( 106 | stripe_event_id: stripe_event_id, 107 | stripe_invoice_id: service.last_stripe_invoice.id 108 | ) 109 | 110 | invoice.finalized?.must_equal true 111 | invoice.interval.must_equal 'year' 112 | end 113 | end 114 | end 115 | end 116 | 117 | describe 'with new invoice and discount' do 118 | it 'finalizes the invoice' do 119 | VCR.use_cassette('process_payment_new_discount') do 120 | service.create_subscription(plan: plan.id, coupon: coupon.id) 121 | 122 | invoice = service.process_payment( 123 | stripe_event_id: stripe_event_id, 124 | stripe_invoice_id: service.last_stripe_invoice.id 125 | ) 126 | 127 | invoice.finalized?.must_equal true 128 | invoice.subtotal.must_equal 1499 129 | invoice.discount_amount.must_equal 375 130 | invoice.subtotal_after_discount.must_equal 1499-375 131 | invoice.vat_amount.must_equal 236 132 | invoice.vat_rate.must_equal 21 133 | invoice.total.must_equal 1360 134 | invoice.currency.must_equal 'usd' 135 | invoice.customer_country_code.must_equal 'NL' 136 | invoice.customer_vat_number.must_equal 'NL123' 137 | invoice.stripe_event_id.must_equal stripe_event_id 138 | invoice.stripe_customer_id.must_equal customer.id 139 | invoice.customer_accounting_id.must_equal '10001' 140 | invoice.customer_vat_registered.must_equal false 141 | invoice.card_brand.must_equal 'Visa' 142 | invoice.card_last4.must_equal '4242' 143 | invoice.card_country_code.must_equal 'US' 144 | invoice.interval.must_equal 'month' 145 | end 146 | end 147 | end 148 | 149 | describe 'when all the invoice lines are zero' do 150 | it 'does not create an invoice' do 151 | VCR.use_cassette('process_payment_zero_lines') do 152 | customer.subscriptions.create(plan: plan.id, trial_end: (Time.now.to_i + 1000)) 153 | stripe_invoice = customer.invoices.first 154 | invoice = service.process_payment( 155 | stripe_event_id: stripe_event_id, 156 | stripe_invoice_id: stripe_invoice.id 157 | ) 158 | 159 | invoice.must_be_nil 160 | end 161 | end 162 | end 163 | 164 | describe 'when the invoice total is zero' do 165 | it 'does create an invoice if some lines are not zero' do 166 | VCR.use_cassette('process_payment_zero') do 167 | Stripe::InvoiceItem.create(customer: customer.id, amount: -100, currency: 'usd') 168 | Stripe::InvoiceItem.create(customer: customer.id, amount: 100, currency: 'usd') 169 | stripe_invoice = Stripe::Invoice.create(customer: customer.id) 170 | stripe_invoice.pay 171 | 172 | stripe_invoice = customer.invoices.first 173 | invoice = service.process_payment( 174 | stripe_event_id: stripe_event_id, 175 | stripe_invoice_id: stripe_invoice.id 176 | ) 177 | 178 | invoice.wont_be_nil 179 | invoice.total.must_equal 0 180 | end 181 | end 182 | end 183 | end 184 | 185 | describe '#process_refund' do 186 | it 'is an orphan refund' do 187 | VCR.use_cassette('process_refund_orphan') do 188 | proc do 189 | service.process_refund( 190 | stripe_event_id: stripe_event_id, 191 | stripe_invoice_id: 'xyz' 192 | ) 193 | end.must_raise InvoiceService::OrphanRefund 194 | end 195 | end 196 | 197 | describe 'when it is a real refund' do 198 | it 'creates a credit note' do 199 | VCR.use_cassette('process_refund') do 200 | Stripe::InvoiceItem.create( 201 | customer: customer.id, 202 | amount: 100, 203 | currency: 'usd' 204 | ) 205 | 206 | stripe_invoice = Stripe::Invoice.create(customer: customer.id) 207 | 208 | # Pay the invoice before processing the payment. 209 | stripe_invoice.pay 210 | 211 | service.process_payment( 212 | stripe_event_id: stripe_event_id, 213 | stripe_invoice_id: stripe_invoice.id 214 | ) 215 | 216 | credit_note = service.process_refund( 217 | stripe_event_id: stripe_event_id, 218 | stripe_invoice_id: stripe_invoice.id 219 | ) 220 | credit_note.finalized?.must_equal true 221 | credit_note.stripe_event_id.must_equal stripe_event_id 222 | credit_note.customer_accounting_id.must_equal metadata[:accounting_id] 223 | end 224 | end 225 | end 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: git://github.com/RubyMoney/eu_central_bank.git 3 | revision: 88001f6af414926107c8172a7bea16fa1884c4f1 4 | specs: 5 | eu_central_bank (1.3.1) 6 | money (~> 6.11.0) 7 | nokogiri (~> 1.8.1) 8 | 9 | GIT 10 | remote: git://github.com/bblimke/webmock.git 11 | revision: a340f830d5ec5af43262856c0d5bde886eff6313 12 | specs: 13 | webmock (1.18.0) 14 | addressable (>= 2.3.6) 15 | crack (>= 0.3.2) 16 | 17 | GIT 18 | remote: git://github.com/challengee/countries.git 19 | revision: d983f1a36a9bed32f7a7c8067474447cb16daeec 20 | specs: 21 | countries (0.11.5) 22 | currencies (~> 0.4.2) 23 | i18n_data (~> 0.7.0) 24 | 25 | GIT 26 | remote: git://github.com/piesync/rumor.git 27 | revision: 7b196c9b586d32edd6164269f1ce843baf79a412 28 | specs: 29 | rumor (0.2.0) 30 | 31 | GIT 32 | remote: git://github.com/piesync/tox.git 33 | revision: 09de6dfda7371aca5b261b6bed6d5ad87a70a472 34 | specs: 35 | tox (0.1.0) 36 | ox 37 | 38 | GIT 39 | remote: https://github.com/getsentry/raven-ruby.git 40 | revision: c57cf505c661fddd0fab01823a8f75ad7e14e121 41 | specs: 42 | sentry-raven (0.9.4) 43 | faraday (>= 0.7.6) 44 | hashie (>= 1.1.0) 45 | uuidtools 46 | 47 | PATH 48 | remote: . 49 | specs: 50 | billbo (1.0.0) 51 | multi_json 52 | stripe 53 | 54 | GEM 55 | remote: https://rubygems.org/ 56 | specs: 57 | CFPropertyList (2.3.2) 58 | activemodel (4.1.5) 59 | activesupport (= 4.1.5) 60 | builder (~> 3.1) 61 | activesupport (4.1.5) 62 | i18n (~> 0.6, >= 0.6.9) 63 | json (~> 1.7, >= 1.7.7) 64 | minitest (~> 5.1) 65 | thread_safe (~> 0.1) 66 | tzinfo (~> 1.1) 67 | addressable (2.3.6) 68 | akami (1.2.2) 69 | gyoku (>= 0.4.0) 70 | nokogiri 71 | analytics-ruby (2.0.1) 72 | builder (3.2.2) 73 | capybara (2.4.4) 74 | mime-types (>= 1.16) 75 | nokogiri (>= 1.3.3) 76 | rack (>= 1.0.0) 77 | rack-test (>= 0.5.4) 78 | xpath (~> 2.0) 79 | carrierwave (0.10.0) 80 | activemodel (>= 3.2.0) 81 | activesupport (>= 3.2.0) 82 | json (>= 1.7) 83 | mime-types (>= 1.16) 84 | celluloid (0.15.2) 85 | timers (~> 1.1.0) 86 | choice (0.1.6) 87 | cliver (0.3.2) 88 | coderay (1.1.0) 89 | concurrent-ruby (1.0.5) 90 | crack (0.4.2) 91 | safe_yaml (~> 1.0.0) 92 | currencies (0.4.2) 93 | domain_name (0.5.20160615) 94 | unf (>= 0.0.5, < 1.0.0) 95 | dotenv (0.11.1) 96 | dotenv-deployment (~> 0.0.2) 97 | dotenv-deployment (0.0.2) 98 | excon (0.45.4) 99 | faraday (0.9.0) 100 | multipart-post (>= 1.2, < 3) 101 | ffi (1.9.3) 102 | fission (0.5.0) 103 | CFPropertyList (~> 2.2) 104 | fog (1.37.0) 105 | fog-aliyun (>= 0.1.0) 106 | fog-atmos 107 | fog-aws (>= 0.6.0) 108 | fog-brightbox (~> 0.4) 109 | fog-core (~> 1.32) 110 | fog-dynect (~> 0.0.2) 111 | fog-ecloud (~> 0.1) 112 | fog-google (<= 0.1.0) 113 | fog-json 114 | fog-local 115 | fog-powerdns (>= 0.1.1) 116 | fog-profitbricks 117 | fog-radosgw (>= 0.0.2) 118 | fog-riakcs 119 | fog-sakuracloud (>= 0.0.4) 120 | fog-serverlove 121 | fog-softlayer 122 | fog-storm_on_demand 123 | fog-terremark 124 | fog-vmfusion 125 | fog-voxel 126 | fog-vsphere (>= 0.4.0) 127 | fog-xenserver 128 | fog-xml (~> 0.1.1) 129 | ipaddress (~> 0.5) 130 | fog-aliyun (0.1.0) 131 | fog-core (~> 1.27) 132 | fog-json (~> 1.0) 133 | ipaddress (~> 0.8) 134 | xml-simple (~> 1.1) 135 | fog-atmos (0.1.0) 136 | fog-core 137 | fog-xml 138 | fog-aws (0.8.1) 139 | fog-core (~> 1.27) 140 | fog-json (~> 1.0) 141 | fog-xml (~> 0.1) 142 | ipaddress (~> 0.8) 143 | fog-brightbox (0.10.1) 144 | fog-core (~> 1.22) 145 | fog-json 146 | inflecto (~> 0.0.2) 147 | fog-core (1.35.0) 148 | builder 149 | excon (~> 0.45) 150 | formatador (~> 0.2) 151 | fog-dynect (0.0.2) 152 | fog-core 153 | fog-json 154 | fog-xml 155 | fog-ecloud (0.3.0) 156 | fog-core 157 | fog-xml 158 | fog-google (0.1.0) 159 | fog-core 160 | fog-json 161 | fog-xml 162 | fog-json (1.0.2) 163 | fog-core (~> 1.0) 164 | multi_json (~> 1.10) 165 | fog-local (0.2.1) 166 | fog-core (~> 1.27) 167 | fog-powerdns (0.1.1) 168 | fog-core (~> 1.27) 169 | fog-json (~> 1.0) 170 | fog-xml (~> 0.1) 171 | fog-profitbricks (0.0.5) 172 | fog-core 173 | fog-xml 174 | nokogiri 175 | fog-radosgw (0.0.5) 176 | fog-core (>= 1.21.0) 177 | fog-json 178 | fog-xml (>= 0.0.1) 179 | fog-riakcs (0.1.0) 180 | fog-core 181 | fog-json 182 | fog-xml 183 | fog-sakuracloud (1.7.5) 184 | fog-core 185 | fog-json 186 | fog-serverlove (0.1.2) 187 | fog-core 188 | fog-json 189 | fog-softlayer (1.0.3) 190 | fog-core 191 | fog-json 192 | fog-storm_on_demand (0.1.1) 193 | fog-core 194 | fog-json 195 | fog-terremark (0.1.0) 196 | fog-core 197 | fog-xml 198 | fog-vmfusion (0.1.0) 199 | fission 200 | fog-core 201 | fog-voxel (0.1.0) 202 | fog-core 203 | fog-xml 204 | fog-vsphere (0.4.0) 205 | fog-core 206 | rbvmomi (~> 1.8) 207 | fog-xenserver (0.2.2) 208 | fog-core 209 | fog-xml 210 | fog-xml (0.1.2) 211 | fog-core 212 | nokogiri (~> 1.5, >= 1.5.11) 213 | formatador (0.2.5) 214 | guard (2.6.1) 215 | formatador (>= 0.2.4) 216 | listen (~> 2.7) 217 | lumberjack (~> 1.0) 218 | pry (>= 0.9.12) 219 | thor (>= 0.18.1) 220 | guard-minitest (2.3.1) 221 | guard (~> 2.0) 222 | minitest (>= 3.0) 223 | gyoku (1.2.2) 224 | builder (>= 2.1.2) 225 | hashie (3.2.0) 226 | http-cookie (1.0.2) 227 | domain_name (~> 0.5) 228 | httpi (2.3.0) 229 | rack 230 | i18n (0.9.5) 231 | concurrent-ruby (~> 1.0) 232 | i18n_data (0.7.0) 233 | inflecto (0.0.2) 234 | ipaddress (0.8.2) 235 | json (1.8.3) 236 | listen (2.7.9) 237 | celluloid (>= 0.15.2) 238 | rb-fsevent (>= 0.9.3) 239 | rb-inotify (>= 0.9) 240 | lumberjack (1.0.9) 241 | macaddr (1.7.1) 242 | systemu (~> 2.6.2) 243 | metaclass (0.0.4) 244 | method_source (0.8.2) 245 | mime-types (1.25.1) 246 | mini_portile2 (2.3.0) 247 | minitest (5.4.0) 248 | mocha (1.1.0) 249 | metaclass (~> 0.0.1) 250 | money (6.11.3) 251 | i18n (>= 0.6.4, < 1.1) 252 | multi_json (1.11.2) 253 | multipart-post (2.0.0) 254 | netrc (0.11.0) 255 | nokogiri (1.8.4) 256 | mini_portile2 (~> 2.3.0) 257 | nori (2.4.0) 258 | oj (2.9.9) 259 | ox (2.3.0) 260 | pg (0.17.1) 261 | poltergeist (1.6.0) 262 | capybara (~> 2.1) 263 | cliver (~> 0.3.1) 264 | multi_json (~> 1.0) 265 | websocket-driver (>= 0.2.0) 266 | pry (0.10.0) 267 | coderay (~> 1.1.0) 268 | method_source (~> 0.8.1) 269 | slop (~> 3.4) 270 | puma (3.12.0) 271 | rack (1.6.11) 272 | rack-protection (1.5.3) 273 | rack 274 | rack-test (0.6.3) 275 | rack (>= 1.0) 276 | rake (10.3.2) 277 | rb-fsevent (0.9.4) 278 | rb-inotify (0.9.5) 279 | ffi (>= 0.5.0) 280 | rbvmomi (1.8.2) 281 | builder 282 | nokogiri (>= 1.4.1) 283 | trollop 284 | rest-client (2.0.0) 285 | http-cookie (>= 1.0.2, < 2.0) 286 | mime-types (>= 1.16, < 4.0) 287 | netrc (~> 0.8) 288 | safe_yaml (1.0.4) 289 | savon (2.8.0) 290 | akami (~> 1.2) 291 | builder (>= 2.1.2) 292 | gyoku (~> 1.2) 293 | httpi (~> 2.3) 294 | nokogiri (>= 1.4.0) 295 | nori (~> 2.4) 296 | uuid (~> 2.3.7) 297 | wasabi (= 3.3.0) 298 | sequel (4.12.0) 299 | shotgun (0.9) 300 | rack (>= 1.0) 301 | shrimp (0.0.5) 302 | json 303 | sinatra (1.4.5) 304 | rack (~> 1.4) 305 | rack-protection (~> 1.4) 306 | tilt (~> 1.3, >= 1.3.4) 307 | slim (2.0.3) 308 | temple (~> 0.6.6) 309 | tilt (>= 1.3.3, < 2.1) 310 | slop (3.6.0) 311 | stripe (1.48.0) 312 | rest-client (>= 1.4, < 3.0) 313 | sucker_punch (1.0.5) 314 | celluloid (~> 0.15.2) 315 | systemu (2.6.4) 316 | temple (0.6.8) 317 | thor (0.19.1) 318 | thread_safe (0.3.4) 319 | tilt (1.4.1) 320 | timecop (0.7.3) 321 | timers (1.1.0) 322 | trollop (2.1.2) 323 | tzinfo (1.2.2) 324 | thread_safe (~> 0.1) 325 | unf (0.1.4) 326 | unf_ext 327 | unf_ext (0.0.7.2) 328 | uuid (2.3.7) 329 | macaddr (~> 1.0) 330 | uuidtools (2.1.4) 331 | valvat (0.6.7) 332 | savon (>= 2.3.0) 333 | vcr (2.9.2) 334 | wasabi (3.3.0) 335 | httpi (~> 2.0) 336 | mime-types (< 2.0.0) 337 | nokogiri (>= 1.4.0) 338 | websocket-driver (0.5.4) 339 | websocket-extensions (>= 0.1.0) 340 | websocket-extensions (0.1.2) 341 | xml-simple (1.1.5) 342 | xpath (2.0.0) 343 | nokogiri (~> 1.3) 344 | 345 | PLATFORMS 346 | ruby 347 | 348 | DEPENDENCIES 349 | activesupport 350 | analytics-ruby 351 | billbo! 352 | capybara 353 | carrierwave 354 | choice 355 | countries! 356 | dotenv 357 | eu_central_bank! 358 | fog 359 | guard 360 | guard-minitest 361 | mocha 362 | money 363 | multi_json 364 | oj 365 | pg 366 | poltergeist 367 | puma 368 | rack-test 369 | rake 370 | rumor! 371 | savon 372 | sentry-raven! 373 | sequel 374 | shotgun 375 | shrimp 376 | sinatra 377 | slim 378 | stripe (>= 1.42) 379 | sucker_punch 380 | timecop 381 | tox! 382 | valvat 383 | vcr 384 | webmock! 385 | 386 | RUBY VERSION 387 | ruby 2.3.8p459 388 | 389 | BUNDLED WITH 390 | 2.0.1 391 | -------------------------------------------------------------------------------- /spec/cassettes/hook_invoice_created.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://api.stripe.com/v1/customers 6 | body: 7 | encoding: US-ASCII 8 | string: card[number]=4242424242424242&card[exp_month]=12&card[exp_year]=30&card[cvc]=222&metadata[country_code]=NL&metadata[vat_registered]=false&metadata[other]=random 9 | headers: 10 | Accept: 11 | - "*/*; q=0.5, application/xml" 12 | Accept-Encoding: 13 | - gzip, deflate 14 | User-Agent: 15 | - Stripe/v1 RubyBindings/1.14.0 16 | Authorization: 17 | - "" 18 | Content-Type: 19 | - application/x-www-form-urlencoded 20 | Stripe-Version: 21 | - '2015-10-16' 22 | X-Stripe-Client-User-Agent: 23 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 24 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 25 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 26 | Content-Length: 27 | - '160' 28 | response: 29 | status: 30 | code: 200 31 | message: OK 32 | headers: 33 | Server: 34 | - nginx 35 | Date: 36 | - Wed, 20 Jan 2016 17:39:59 GMT 37 | Content-Type: 38 | - application/json 39 | Content-Length: 40 | - '1462' 41 | Connection: 42 | - keep-alive 43 | Access-Control-Allow-Credentials: 44 | - 'true' 45 | Access-Control-Allow-Methods: 46 | - GET, POST, HEAD, OPTIONS, DELETE 47 | Access-Control-Allow-Origin: 48 | - "*" 49 | Access-Control-Max-Age: 50 | - '300' 51 | Cache-Control: 52 | - no-cache, no-store 53 | Request-Id: 54 | - req_7kvn2FwIzExvSi 55 | Stripe-Version: 56 | - '2015-10-16' 57 | Strict-Transport-Security: 58 | - max-age=31556926; includeSubDomains 59 | body: 60 | encoding: UTF-8 61 | string: | 62 | { 63 | "id": "cus_7kvnlgQp9iU2BH", 64 | "object": "customer", 65 | "account_balance": 0, 66 | "created": 1453311598, 67 | "currency": null, 68 | "default_source": "card_17VPw22nHroS7mLXvoKKBUZN", 69 | "delinquent": false, 70 | "description": null, 71 | "discount": null, 72 | "email": null, 73 | "livemode": false, 74 | "metadata": { 75 | "country_code": "NL", 76 | "vat_registered": "false", 77 | "other": "random" 78 | }, 79 | "shipping": null, 80 | "sources": { 81 | "object": "list", 82 | "data": [ 83 | { 84 | "id": "card_17VPw22nHroS7mLXvoKKBUZN", 85 | "object": "card", 86 | "address_city": null, 87 | "address_country": null, 88 | "address_line1": null, 89 | "address_line1_check": null, 90 | "address_line2": null, 91 | "address_state": null, 92 | "address_zip": null, 93 | "address_zip_check": null, 94 | "brand": "Visa", 95 | "country": "US", 96 | "customer": "cus_7kvnlgQp9iU2BH", 97 | "cvc_check": "pass", 98 | "dynamic_last4": null, 99 | "exp_month": 12, 100 | "exp_year": 2030, 101 | "fingerprint": "0K7oMWAQAFG7TEob", 102 | "funding": "credit", 103 | "last4": "4242", 104 | "metadata": {}, 105 | "name": null, 106 | "tokenization_method": null 107 | } 108 | ], 109 | "has_more": false, 110 | "total_count": 1, 111 | "url": "/v1/customers/cus_7kvnlgQp9iU2BH/sources" 112 | }, 113 | "subscriptions": { 114 | "object": "list", 115 | "data": [], 116 | "has_more": false, 117 | "total_count": 0, 118 | "url": "/v1/customers/cus_7kvnlgQp9iU2BH/subscriptions" 119 | } 120 | } 121 | http_version: 122 | recorded_at: Wed, 20 Jan 2016 17:40:02 GMT 123 | - request: 124 | method: post 125 | uri: https://api.stripe.com/v1/invoiceitems 126 | body: 127 | encoding: US-ASCII 128 | string: customer=cus_7kvnlgQp9iU2BH&amount=100¤cy=usd 129 | headers: 130 | Accept: 131 | - "*/*; q=0.5, application/xml" 132 | Accept-Encoding: 133 | - gzip, deflate 134 | User-Agent: 135 | - Stripe/v1 RubyBindings/1.14.0 136 | Authorization: 137 | - "" 138 | Content-Type: 139 | - application/x-www-form-urlencoded 140 | Stripe-Version: 141 | - '2015-10-16' 142 | X-Stripe-Client-User-Agent: 143 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 144 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 145 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 146 | Content-Length: 147 | - '51' 148 | response: 149 | status: 150 | code: 200 151 | message: OK 152 | headers: 153 | Server: 154 | - nginx 155 | Date: 156 | - Wed, 20 Jan 2016 17:40:00 GMT 157 | Content-Type: 158 | - application/json 159 | Content-Length: 160 | - '418' 161 | Connection: 162 | - keep-alive 163 | Access-Control-Allow-Credentials: 164 | - 'true' 165 | Access-Control-Allow-Methods: 166 | - GET, POST, HEAD, OPTIONS, DELETE 167 | Access-Control-Allow-Origin: 168 | - "*" 169 | Access-Control-Max-Age: 170 | - '300' 171 | Cache-Control: 172 | - no-cache, no-store 173 | Request-Id: 174 | - req_7kvnjYD5kq6P35 175 | Stripe-Version: 176 | - '2015-10-16' 177 | Strict-Transport-Security: 178 | - max-age=31556926; includeSubDomains 179 | body: 180 | encoding: UTF-8 181 | string: | 182 | { 183 | "id": "ii_17VPw42nHroS7mLXJOWRSZSK", 184 | "object": "invoiceitem", 185 | "amount": 100, 186 | "currency": "usd", 187 | "customer": "cus_7kvnlgQp9iU2BH", 188 | "date": 1453311600, 189 | "description": null, 190 | "discountable": true, 191 | "invoice": null, 192 | "livemode": false, 193 | "metadata": {}, 194 | "period": { 195 | "start": 1453311600, 196 | "end": 1453311600 197 | }, 198 | "plan": null, 199 | "proration": false, 200 | "quantity": null, 201 | "subscription": null 202 | } 203 | http_version: 204 | recorded_at: Wed, 20 Jan 2016 17:40:03 GMT 205 | - request: 206 | method: post 207 | uri: https://api.stripe.com/v1/invoices 208 | body: 209 | encoding: US-ASCII 210 | string: customer=cus_7kvnlgQp9iU2BH 211 | headers: 212 | Accept: 213 | - "*/*; q=0.5, application/xml" 214 | Accept-Encoding: 215 | - gzip, deflate 216 | User-Agent: 217 | - Stripe/v1 RubyBindings/1.14.0 218 | Authorization: 219 | - "" 220 | Content-Type: 221 | - application/x-www-form-urlencoded 222 | Stripe-Version: 223 | - '2015-10-16' 224 | X-Stripe-Client-User-Agent: 225 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 226 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 227 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 228 | Content-Length: 229 | - '27' 230 | response: 231 | status: 232 | code: 200 233 | message: OK 234 | headers: 235 | Server: 236 | - nginx 237 | Date: 238 | - Wed, 20 Jan 2016 17:40:02 GMT 239 | Content-Type: 240 | - application/json 241 | Content-Length: 242 | - '1372' 243 | Connection: 244 | - keep-alive 245 | Access-Control-Allow-Credentials: 246 | - 'true' 247 | Access-Control-Allow-Methods: 248 | - GET, POST, HEAD, OPTIONS, DELETE 249 | Access-Control-Allow-Origin: 250 | - "*" 251 | Access-Control-Max-Age: 252 | - '300' 253 | Cache-Control: 254 | - no-cache, no-store 255 | Request-Id: 256 | - req_7kvn8X4YJxqr3z 257 | Stripe-Version: 258 | - '2015-10-16' 259 | Strict-Transport-Security: 260 | - max-age=31556926; includeSubDomains 261 | body: 262 | encoding: UTF-8 263 | string: | 264 | { 265 | "id": "in_17VPw52nHroS7mLXtHxUG3ry", 266 | "object": "invoice", 267 | "amount_due": 100, 268 | "application_fee": null, 269 | "attempt_count": 0, 270 | "attempted": false, 271 | "charge": null, 272 | "closed": false, 273 | "currency": "usd", 274 | "customer": "cus_7kvnlgQp9iU2BH", 275 | "date": 1453311601, 276 | "description": null, 277 | "discount": null, 278 | "ending_balance": null, 279 | "forgiven": false, 280 | "lines": { 281 | "object": "list", 282 | "data": [ 283 | { 284 | "id": "ii_17VPw42nHroS7mLXJOWRSZSK", 285 | "object": "line_item", 286 | "amount": 100, 287 | "currency": "usd", 288 | "description": null, 289 | "discountable": true, 290 | "livemode": false, 291 | "metadata": {}, 292 | "period": { 293 | "start": 1453311600, 294 | "end": 1453311600 295 | }, 296 | "plan": null, 297 | "proration": false, 298 | "quantity": null, 299 | "subscription": null, 300 | "type": "invoiceitem" 301 | } 302 | ], 303 | "has_more": false, 304 | "total_count": 1, 305 | "url": "/v1/invoices/in_17VPw52nHroS7mLXtHxUG3ry/lines" 306 | }, 307 | "livemode": false, 308 | "metadata": {}, 309 | "next_payment_attempt": 1453315201, 310 | "paid": false, 311 | "period_end": 1453311601, 312 | "period_start": 1453311601, 313 | "receipt_number": null, 314 | "starting_balance": 0, 315 | "statement_descriptor": null, 316 | "subscription": null, 317 | "subtotal": 100, 318 | "tax": null, 319 | "tax_percent": null, 320 | "total": 100, 321 | "webhooks_delivered_at": null 322 | } 323 | http_version: 324 | recorded_at: Wed, 20 Jan 2016 17:40:05 GMT 325 | recorded_with: VCR 2.9.2 326 | -------------------------------------------------------------------------------- /spec/cassettes/app_create_subscription.yml: -------------------------------------------------------------------------------- 1 | --- 2 | http_interactions: 3 | - request: 4 | method: post 5 | uri: https://api.stripe.com/v1/customers 6 | body: 7 | encoding: US-ASCII 8 | string: card[number]=4242424242424242&card[exp_month]=12&card[exp_year]=30&card[cvc]=222&metadata[name]=John%20Doe&metadata[country_code]=NL&metadata[vat_registered]=false&metadata[other]=random 9 | headers: 10 | Accept: 11 | - "*/*; q=0.5, application/xml" 12 | Accept-Encoding: 13 | - gzip, deflate 14 | User-Agent: 15 | - Stripe/v1 RubyBindings/1.14.0 16 | Authorization: 17 | - "" 18 | Content-Type: 19 | - application/x-www-form-urlencoded 20 | Stripe-Version: 21 | - '2015-10-16' 22 | X-Stripe-Client-User-Agent: 23 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 24 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 25 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 26 | Content-Length: 27 | - '186' 28 | response: 29 | status: 30 | code: 200 31 | message: OK 32 | headers: 33 | Server: 34 | - nginx 35 | Date: 36 | - Wed, 20 Jan 2016 17:44:05 GMT 37 | Content-Type: 38 | - application/json 39 | Content-Length: 40 | - '1486' 41 | Connection: 42 | - keep-alive 43 | Access-Control-Allow-Credentials: 44 | - 'true' 45 | Access-Control-Allow-Methods: 46 | - GET, POST, HEAD, OPTIONS, DELETE 47 | Access-Control-Allow-Origin: 48 | - "*" 49 | Access-Control-Max-Age: 50 | - '300' 51 | Cache-Control: 52 | - no-cache, no-store 53 | Request-Id: 54 | - req_7kvrNPvKxa2mHt 55 | Stripe-Version: 56 | - '2015-10-16' 57 | Strict-Transport-Security: 58 | - max-age=31556926; includeSubDomains 59 | body: 60 | encoding: UTF-8 61 | string: | 62 | { 63 | "id": "cus_7kvr9HTxvwAijh", 64 | "object": "customer", 65 | "account_balance": 0, 66 | "created": 1453311845, 67 | "currency": null, 68 | "default_source": "card_17VQ012nHroS7mLXHydxEnjl", 69 | "delinquent": false, 70 | "description": null, 71 | "discount": null, 72 | "email": null, 73 | "livemode": false, 74 | "metadata": { 75 | "name": "John Doe", 76 | "country_code": "NL", 77 | "vat_registered": "false", 78 | "other": "random" 79 | }, 80 | "shipping": null, 81 | "sources": { 82 | "object": "list", 83 | "data": [ 84 | { 85 | "id": "card_17VQ012nHroS7mLXHydxEnjl", 86 | "object": "card", 87 | "address_city": null, 88 | "address_country": null, 89 | "address_line1": null, 90 | "address_line1_check": null, 91 | "address_line2": null, 92 | "address_state": null, 93 | "address_zip": null, 94 | "address_zip_check": null, 95 | "brand": "Visa", 96 | "country": "US", 97 | "customer": "cus_7kvr9HTxvwAijh", 98 | "cvc_check": "pass", 99 | "dynamic_last4": null, 100 | "exp_month": 12, 101 | "exp_year": 2030, 102 | "fingerprint": "0K7oMWAQAFG7TEob", 103 | "funding": "credit", 104 | "last4": "4242", 105 | "metadata": {}, 106 | "name": null, 107 | "tokenization_method": null 108 | } 109 | ], 110 | "has_more": false, 111 | "total_count": 1, 112 | "url": "/v1/customers/cus_7kvr9HTxvwAijh/sources" 113 | }, 114 | "subscriptions": { 115 | "object": "list", 116 | "data": [], 117 | "has_more": false, 118 | "total_count": 0, 119 | "url": "/v1/customers/cus_7kvr9HTxvwAijh/subscriptions" 120 | } 121 | } 122 | http_version: 123 | recorded_at: Wed, 20 Jan 2016 17:44:08 GMT 124 | - request: 125 | method: get 126 | uri: https://api.stripe.com/v1/customers/cus_7kvr9HTxvwAijh 127 | body: 128 | encoding: US-ASCII 129 | string: '' 130 | headers: 131 | Accept: 132 | - "*/*; q=0.5, application/xml" 133 | Accept-Encoding: 134 | - gzip, deflate 135 | User-Agent: 136 | - Stripe/v1 RubyBindings/1.14.0 137 | Authorization: 138 | - "" 139 | Content-Type: 140 | - application/x-www-form-urlencoded 141 | Stripe-Version: 142 | - '2015-10-16' 143 | X-Stripe-Client-User-Agent: 144 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 145 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 146 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 147 | response: 148 | status: 149 | code: 200 150 | message: OK 151 | headers: 152 | Server: 153 | - nginx 154 | Date: 155 | - Wed, 20 Jan 2016 17:44:06 GMT 156 | Content-Type: 157 | - application/json 158 | Content-Length: 159 | - '1486' 160 | Connection: 161 | - keep-alive 162 | Access-Control-Allow-Credentials: 163 | - 'true' 164 | Access-Control-Allow-Methods: 165 | - GET, POST, HEAD, OPTIONS, DELETE 166 | Access-Control-Allow-Origin: 167 | - "*" 168 | Access-Control-Max-Age: 169 | - '300' 170 | Cache-Control: 171 | - no-cache, no-store 172 | Request-Id: 173 | - req_7kvrUepYrFlgY9 174 | Stripe-Version: 175 | - '2015-10-16' 176 | Strict-Transport-Security: 177 | - max-age=31556926; includeSubDomains 178 | body: 179 | encoding: UTF-8 180 | string: | 181 | { 182 | "id": "cus_7kvr9HTxvwAijh", 183 | "object": "customer", 184 | "account_balance": 0, 185 | "created": 1453311845, 186 | "currency": null, 187 | "default_source": "card_17VQ012nHroS7mLXHydxEnjl", 188 | "delinquent": false, 189 | "description": null, 190 | "discount": null, 191 | "email": null, 192 | "livemode": false, 193 | "metadata": { 194 | "name": "John Doe", 195 | "country_code": "NL", 196 | "vat_registered": "false", 197 | "other": "random" 198 | }, 199 | "shipping": null, 200 | "sources": { 201 | "object": "list", 202 | "data": [ 203 | { 204 | "id": "card_17VQ012nHroS7mLXHydxEnjl", 205 | "object": "card", 206 | "address_city": null, 207 | "address_country": null, 208 | "address_line1": null, 209 | "address_line1_check": null, 210 | "address_line2": null, 211 | "address_state": null, 212 | "address_zip": null, 213 | "address_zip_check": null, 214 | "brand": "Visa", 215 | "country": "US", 216 | "customer": "cus_7kvr9HTxvwAijh", 217 | "cvc_check": "pass", 218 | "dynamic_last4": null, 219 | "exp_month": 12, 220 | "exp_year": 2030, 221 | "fingerprint": "0K7oMWAQAFG7TEob", 222 | "funding": "credit", 223 | "last4": "4242", 224 | "metadata": {}, 225 | "name": null, 226 | "tokenization_method": null 227 | } 228 | ], 229 | "has_more": false, 230 | "total_count": 1, 231 | "url": "/v1/customers/cus_7kvr9HTxvwAijh/sources" 232 | }, 233 | "subscriptions": { 234 | "object": "list", 235 | "data": [], 236 | "has_more": false, 237 | "total_count": 0, 238 | "url": "/v1/customers/cus_7kvr9HTxvwAijh/subscriptions" 239 | } 240 | } 241 | http_version: 242 | recorded_at: Wed, 20 Jan 2016 17:44:09 GMT 243 | - request: 244 | method: post 245 | uri: https://api.stripe.com/v1/customers/cus_7kvr9HTxvwAijh/subscriptions 246 | body: 247 | encoding: US-ASCII 248 | string: tax_percent=21&plan=test 249 | headers: 250 | Accept: 251 | - "*/*; q=0.5, application/xml" 252 | Accept-Encoding: 253 | - gzip, deflate 254 | User-Agent: 255 | - Stripe/v1 RubyBindings/1.14.0 256 | Authorization: 257 | - "" 258 | Content-Type: 259 | - application/x-www-form-urlencoded 260 | Stripe-Version: 261 | - '2015-10-16' 262 | X-Stripe-Client-User-Agent: 263 | - '{"bindings_version":"1.14.0","lang":"ruby","lang_version":"2.2.3 p173 (2015-08-18)","platform":"x86_64-darwin14","publisher":"stripe","uname":"Darwin 264 | Mattiass-MacBook-Pro.local 14.1.1 Darwin Kernel Version 14.1.1: Fri Feb 6 265 | 21:06:10 PST 2015; root:xnu-2782.15.4~1/RELEASE_X86_64 x86_64"}' 266 | Content-Length: 267 | - '24' 268 | response: 269 | status: 270 | code: 200 271 | message: OK 272 | headers: 273 | Server: 274 | - nginx 275 | Date: 276 | - Wed, 20 Jan 2016 17:44:08 GMT 277 | Content-Type: 278 | - application/json 279 | Content-Length: 280 | - '758' 281 | Connection: 282 | - keep-alive 283 | Access-Control-Allow-Credentials: 284 | - 'true' 285 | Access-Control-Allow-Methods: 286 | - GET, POST, HEAD, OPTIONS, DELETE 287 | Access-Control-Allow-Origin: 288 | - "*" 289 | Access-Control-Max-Age: 290 | - '300' 291 | Cache-Control: 292 | - no-cache, no-store 293 | Request-Id: 294 | - req_7kvrZmcTQ2nmGz 295 | Stripe-Version: 296 | - '2015-10-16' 297 | Strict-Transport-Security: 298 | - max-age=31556926; includeSubDomains 299 | body: 300 | encoding: UTF-8 301 | string: | 302 | { 303 | "id": "sub_7kvrBzuFVMbXmT", 304 | "object": "subscription", 305 | "application_fee_percent": null, 306 | "cancel_at_period_end": false, 307 | "canceled_at": null, 308 | "current_period_end": 1455990247, 309 | "current_period_start": 1453311847, 310 | "customer": "cus_7kvr9HTxvwAijh", 311 | "discount": null, 312 | "ended_at": null, 313 | "metadata": {}, 314 | "plan": { 315 | "id": "test", 316 | "object": "plan", 317 | "amount": 1499, 318 | "created": 1406556583, 319 | "currency": "usd", 320 | "interval": "month", 321 | "interval_count": 1, 322 | "livemode": false, 323 | "metadata": {}, 324 | "name": "Test Plan", 325 | "statement_descriptor": null, 326 | "trial_period_days": null 327 | }, 328 | "quantity": 1, 329 | "start": 1453311847, 330 | "status": "active", 331 | "tax_percent": 21.0, 332 | "trial_end": null, 333 | "trial_start": null 334 | } 335 | http_version: 336 | recorded_at: Wed, 20 Jan 2016 17:44:11 GMT 337 | recorded_with: VCR 2.9.2 338 | --------------------------------------------------------------------------------