diff --git a/.env.sample b/.env.sample index bf66b2e28..5b3d2a4e4 100644 --- a/.env.sample +++ b/.env.sample @@ -59,3 +59,6 @@ STOCKIT_API_TOKEN= SLACK_API_TOKEN= SLACK_PIN_CHANNEL= + +STRIPE_PUBLISHABLE_KEY= +STRIPE_SECRET_KEY= diff --git a/Gemfile b/Gemfile index 4b0ae7d68..0286664b8 100755 --- a/Gemfile +++ b/Gemfile @@ -52,6 +52,7 @@ gem 'sidekiq-statistic' gem 'sinatra', require: nil # for sidekiq reporting console gem 'slack-ruby-client' gem 'state_machine' +gem 'stripe', '~> 5.29.0' gem 'traco' gem 'twilio-ruby', '~> 5.11.0' gem 'whenever', '~> 0.9.5', require: false diff --git a/Gemfile.lock b/Gemfile.lock index 1816bf2b1..6beabc7d1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -486,6 +486,7 @@ GEM net-scp (>= 1.1.2) net-ssh (>= 2.8.0) state_machine (1.2.0) + stripe (5.29.0) sys-uname (1.2.1) ffi (>= 1.0.0) thor (1.0.1) @@ -598,6 +599,7 @@ DEPENDENCIES spring spring-commands-rspec state_machine + stripe (~> 5.29.0) timecop traco twilio-ruby (~> 5.11.0) diff --git a/app/controllers/api/v1/ability.rb b/app/controllers/api/v1/ability.rb index 479579f10..4e82f2824 100644 --- a/app/controllers/api/v1/ability.rb +++ b/app/controllers/api/v1/ability.rb @@ -385,6 +385,7 @@ def taxonomies can [:index, :show], UserRole can [:index, :show], CancellationReason can [:names], Organisation + can [:fetch_public_key, :create_setupintent, :save_payment_method], :stripe if can_add_or_remove_inventory_number? || @api_user can [:create, :remove_number], InventoryNumber diff --git a/app/controllers/api/v1/stripe_controller.rb b/app/controllers/api/v1/stripe_controller.rb new file mode 100644 index 000000000..cd75c7dbf --- /dev/null +++ b/app/controllers/api/v1/stripe_controller.rb @@ -0,0 +1,59 @@ +module Api + module V1 + class StripeController < Api::V1::ApiController + + load_and_authorize_resource class: false + + api :GET, "/v1/fetch_public_key", "Get stripe public key" + def fetch_public_key + render json: StripeService.new.public_key + end + + api :POST, "/v1/create_setupintent", "Create setup-intent for current-user" + def create_setupintent + render json: StripeService.new.create_setup_intent + end + + api :POST, "/v1/save_payment_method", "Save payment-method" + param :stripe_response, Hash, required: true + param :source_id, [Integer, String], required: true, desc: "Id of the source (transport_order)" + param :source_type, String, required: true, desc: "Type of the source (transport_order)" + param :authorize_amount, [true, false, 'true', 'false'], allow_nil: true, default: false, desc: 'Amount should be authorized from card for given source' + def save_payment_method + + # PARAMS: + # { + # "stripe_response": { + # "id": "seti_1IOyT3JG1rVU4bz1mBbHH6Yu", + # "object": "setup_intent", + # "cancellation_reason": "", + # "client_secret": "seti_1IOyT3JG1rVU4bz1mBbHH6Yu_secret_J10Uje2R9PSUp74wN3S3TfwaDyCMMG8", + # "created": "1614315741", + # "description": "", + # "last_setup_error": "", + # "livemode": "false", + # "next_action": "", + # "payment_method": "pm_1IOyTrJG1rVU4bz1wYJv9s5N", "payment_method_types": ["card"], + # "status": "succeeded", + # "usage": "off_session" + # }, + # "source_id": "1", + # "source_type": "transport_order", + # "authorize_amount": "true", + # "format": "json", + # "controller": "api/v1/stripe", + # "action": "save_payment_method" + # } + + StripeService.new( + source_type: params[:source_type], + source_id: params[:source_id], + authorize_amount: params[:authorize_amount] + ).save_payment_method(params[:stripe_response]) + + render json: params + end + + end + end +end diff --git a/app/models/stripe_payment.rb b/app/models/stripe_payment.rb new file mode 100644 index 000000000..1ecd8c02e --- /dev/null +++ b/app/models/stripe_payment.rb @@ -0,0 +1,3 @@ +class StripePayment < ApplicationRecord + belongs_to :source, polymorphic: true +end diff --git a/app/services/stripe_service.rb b/app/services/stripe_service.rb new file mode 100644 index 000000000..203b4bf64 --- /dev/null +++ b/app/services/stripe_service.rb @@ -0,0 +1,250 @@ +## Steps for saving cards for future use: +## 1. Create or retrieve customer from associated user. +## 2. Create setup-intent for customer. +## 3. On client side: setup-intent-ID is passed, which is used to store card details and +## returns payment-method details. +## 4. Update payment-method value in record. (created in step 2) +## 5. Authorize amount to above saved-card i.e. payment_method. Set capture_method as 'manual' +## 6. Capture payment + +class StripeService + + attr_accessor :user_id, :source_type, :source_id, :customer_id, :amount, :payment, + :authorize_amount + + def initialize(args={}) + @user_id = User.current_user.try(:id) + @source_type = args[:source_type] + @source_id = args[:source_id] + @authorize_amount = args[:authorize_amount] + + Stripe.api_key = stripe_secret_key + @customer_id = fetch_customer_id # STEP 1 + end + + # STEP 2 + def create_setup_intent + Stripe::SetupIntent.create({ + customer: @customer_id + }) + + ## RESPONCE + # # JSON: { + # "id": "seti_1IOyT3JG1rVU4bz1mBbHH6Yu", + # "object": "setup_intent", + # "application": null, + # "cancellation_reason": null, + # "client_secret": "seti_1IOyT3JG1rVU4bz1mBbHH6Yu_secret_J10Uje2R9PSUp74wN3S3TfwaDyCMMG8", + # "created": 1614314537, + # "customer": "cus_IzVEJkwLRIZg1F", + # "description": null, + # "last_setup_error": null, + # "latest_attempt": null, + # "livemode": false, + # "mandate": null, + # "metadata": {}, + # "next_action": null, + # "on_behalf_of": null, + # "payment_method": null, + # "payment_method_options": {"card":{"request_three_d_secure":"automatic"}}, + # "payment_method_types": [ + # "card" + # ], + # "single_use_mandate": null, + # "status": "requires_payment_method", + # "usage": "off_session" + # } + end + + def public_key + { + 'publicKey': stripe_publishable_key + } + end + + # STEP 4 + def save_payment_method(details) + setup_stripe_payment( + setup_intent_id: details[:id], + status: details[:status], + payment_method_id: details[:payment_method] + ) + + if @authorize_amount.to_s.downcase == 'true' + authorize_amount_on_saved_card(amount_for_source, @customer_id, details[:payment_method]) + end + end + + # STEP 5 + def authorize_amount_on_saved_card(amount, customer_id, payment_method_id) + begin + + intent = Stripe::PaymentIntent.create({ + amount: amount, + currency: "inr", + customer: customer_id, + payment_method: payment_method_id, + off_session: true, + confirm: true, + capture_method: 'manual', # Ref: https://stripe.com/docs/payments/capture-later + }) + + ## RESPONSE: + # # JSON: { + # "id": "pi_1IOyTuJG1rVU4bz1qiHYyCsO", + # "object": "payment_intent", + # "amount": 30000, + # "amount_capturable": 30000, + # "amount_received": 0, + # "application": null, + # "application_fee_amount": null, + # "canceled_at": null, + # "cancellation_reason": null, + # "capture_method": "manual", + # "charges": {"object":"list","data":[{"id":"ch_1IOyTuJG1rVU4bz1t6dznTFj","object":"charge","amount":30000,"amount_captured":0,"amount_refunded":0,"application":null,"application_fee":null,"application_fee_amount":null,"balance_transaction":null,"billing_details":{"address":{"city":null,"country":null,"line1":null,"line2":null,"postal_code":"42424","state":null},"email":"swati@kiprosh.com","name":null,"phone":null},"calculated_statement_descriptor":"Stripe","captured":false,"created":1614315794,"currency":"inr","customer":"cus_IzVEJkwLRIZg1F","description":null,"destination":null,"dispute":null,"disputed":false,"failure_code":null,"failure_message":null,"fraud_details":{},"invoice":null,"livemode":false,"metadata":{},"on_behalf_of":null,"order":null,"outcome":{"network_status":"approved_by_network","reason":null,"risk_level":"normal","risk_score":43,"seller_message":"Payment complete.","type":"authorized"},"paid":true,"payment_intent":"pi_1IOyTuJG1rVU4bz1qiHYyCsO","payment_method":"pm_1IOyTrJG1rVU4bz1wYJv9s5N","payment_method_details":{"card":{"brand":"visa","checks":{"address_line1_check":null,"address_postal_code_check":"pass","cvc_check":"pass"},"country":"US","exp_month":4,"exp_year":2024,"fingerprint":"VTCnHArgdpMTv44P","funding":"credit","installments":null,"last4":"4242","network":"visa","three_d_secure":null,"wallet":null},"type":"card"},"receipt_email":null,"receipt_number":null,"receipt_url":"https://pay.stripe.com/receipts/acct_1IBXaDJG1rVU4bz1/ch_1IOyTuJG1rVU4bz1t6dznTFj/rcpt_J10Uwnh11pAfJsEc8fnTHmGeBHcq3Iv","refunded":false,"refunds":{"object":"list","data":[],"has_more":false,"total_count":0,"url":"/v1/charges/ch_1IOyTuJG1rVU4bz1t6dznTFj/refunds"},"review":null,"shipping":null,"source":null,"source_transfer":null,"statement_descriptor":null,"statement_descriptor_suffix":null,"status":"succeeded","transfer_data":null,"transfer_group":null}],"has_more":false,"total_count":1,"url":"/v1/charges?payment_intent=pi_1IOyTuJG1rVU4bz1qiHYyCsO"}, + # "client_secret": "pi_1IOyTuJG1rVU4bz1qiHYyCsO_secret_UVumzun2tSD79h3haVp7wuUEh", + # "confirmation_method": "automatic", + # "created": 1614315794, + # "currency": "inr", + # "customer": "cus_IzVEJkwLRIZg1F", + # "description": null, + # "invoice": null, + # "last_payment_error": null, + # "livemode": false, + # "metadata": {}, + # "next_action": null, + # "on_behalf_of": null, + # "payment_method": "pm_1IOyTrJG1rVU4bz1wYJv9s5N", + # "payment_method_options": {"card":{"installments":null,"network":null,"request_three_d_secure":"automatic"}}, + # "payment_method_types": [ + # "card" + # ], + # "receipt_email": null, + # "review": null, + # "setup_future_usage": null, + # "shipping": null, + # "source": null, + # "statement_descriptor": null, + # "statement_descriptor_suffix": null, + # "status": "requires_capture", + # "transfer_data": null, + # "transfer_group": null + # } + + # Update payment-intent details for customer's service. + @payment.update({payment_intent_id: intent["id"], status: intent["status"]}) + + rescue Stripe::CardError => e + # TODO + # Error code will be authentication_required if authentication is needed + puts "Error is: #{e.error.code}" + payment_intent_id = e.error.payment_intent.id + payment_intent = Stripe::PaymentIntent.retrieve(payment_intent_id) + puts payment_intent.id + end + end + + # STEP 6 + # Capture payment when specific service(transport-order) is completed. + def capture_payment(payment_intent_id, amount) + intent = Stripe::PaymentIntent.capture( + payment_intent_id, + { + amount_to_capture: amount, + } + ) + + ## RESPONSE + ## JSON: { + # "id": "pi_1IOyTuJG1rVU4bz1qiHYyCsO", + # "object": "payment_intent", + # "amount": 30000, + # "amount_capturable": 0, + # "amount_received": 20000, + # "application": null, + # "application_fee_amount": null, + # "canceled_at": null, + # "cancellation_reason": null, + # "capture_method": "manual", + # "charges": {"object":"list","data":[{"id":"ch_1IOyTuJG1rVU4bz1t6dznTFj","object":"charge","amount":30000,"amount_captured":20000,"amount_refunded":10000,"application":null,"application_fee":null,"application_fee_amount":null,"balance_transaction":"txn_1IOyZMJG1rVU4bz10NuRnEhQ","billing_details":{"address":{"city":null,"country":null,"line1":null,"line2":null,"postal_code":"42424","state":null},"email":"swati@kiprosh.com","name":null,"phone":null},"calculated_statement_descriptor":"Stripe","captured":true,"created":1614315794,"currency":"inr","customer":"cus_IzVEJkwLRIZg1F","description":null,"destination":null,"dispute":null,"disputed":false,"failure_code":null,"failure_message":null,"fraud_details":{},"invoice":null,"livemode":false,"metadata":{},"on_behalf_of":null,"order":null,"outcome":{"network_status":"approved_by_network","reason":null,"risk_level":"normal","risk_score":43,"seller_message":"Payment complete.","type":"authorized"},"paid":true,"payment_intent":"pi_1IOyTuJG1rVU4bz1qiHYyCsO","payment_method":"pm_1IOyTrJG1rVU4bz1wYJv9s5N","payment_method_details":{"card":{"brand":"visa","checks":{"address_line1_check":null,"address_postal_code_check":"pass","cvc_check":"pass"},"country":"US","exp_month":4,"exp_year":2024,"fingerprint":"VTCnHArgdpMTv44P","funding":"credit","installments":null,"last4":"4242","network":"visa","three_d_secure":null,"wallet":null},"type":"card"},"receipt_email":null,"receipt_number":null,"receipt_url":"https://pay.stripe.com/receipts/acct_1IBXaDJG1rVU4bz1/ch_1IOyTuJG1rVU4bz1t6dznTFj/rcpt_J10Uwnh11pAfJsEc8fnTHmGeBHcq3Iv","refunded":false,"refunds":{"object":"list","data":[{"id":"re_1IOyZMJG1rVU4bz1xhvil8nK","object":"refund","amount":10000,"balance_transaction":"txn_1IOyZMJG1rVU4bz1oRiPReWp","charge":"ch_1IOyTuJG1rVU4bz1t6dznTFj","created":1614316132,"currency":"inr","metadata":{},"payment_intent":"pi_1IOyTuJG1rVU4bz1qiHYyCsO","reason":null,"receipt_number":null,"source_transfer_reversal":null,"status":"succeeded","transfer_reversal":null}],"has_more":false,"total_count":1,"url":"/v1/charges/ch_1IOyTuJG1rVU4bz1t6dznTFj/refunds"},"review":null,"shipping":null,"source":null,"source_transfer":null,"statement_descriptor":null,"statement_descriptor_suffix":null,"status":"succeeded","transfer_data":null,"transfer_group":null}],"has_more":false,"total_count":1,"url":"/v1/charges?payment_intent=pi_1IOyTuJG1rVU4bz1qiHYyCsO"}, + # "client_secret": "pi_1IOyTuJG1rVU4bz1qiHYyCsO_secret_UVumzun2tSD79h3haVp7wuUEh", + # "confirmation_method": "automatic", + # "created": 1614315794, + # "currency": "inr", + # "customer": "cus_IzVEJkwLRIZg1F", + # "description": null, + # "invoice": null, + # "last_payment_error": null, + # "livemode": false, + # "metadata": {}, + # "next_action": null, + # "on_behalf_of": null, + # "payment_method": "pm_1IOyTrJG1rVU4bz1wYJv9s5N", + # "payment_method_options": {"card":{"installments":null,"network":null,"request_three_d_secure":"automatic"}}, + # "payment_method_types": [ + # "card" + # ], + # "receipt_email": null, + # "review": null, + # "setup_future_usage": null, + # "shipping": null, + # "source": null, + # "statement_descriptor": null, + # "statement_descriptor_suffix": null, + # "status": "succeeded", + # "transfer_data": null, + # "transfer_group": null + # } + + end + + private + + def stripe_publishable_key + Rails.application.secrets.stripe[:publishable_key] + end + + def stripe_secret_key + Rails.application.secrets.stripe[:secret_key] + end + + def fetch_customer_id + user = User.find_by(id: @user_id) + user&.stripe_customer_id || create_customer(user) + end + + def create_customer(user) + customer = Stripe::Customer.create({ + email: user&.email, + name: user&.full_name, + phone: user&.mobile, + }) + + user.update_column(:stripe_customer_id, customer.id) + customer.id + end + + def setup_stripe_payment(details) + @payment = StripePayment.create( + setup_intent_id: details[:setup_intent_id], + payment_method_id: details[:payment_method_id], + status: details[:status], + user_id: @user_id, + amount: @amount, + source_id: @source_id, + source_type: @source_type + ) + end + + # TODO: + # Fetch amount from the source for which amount has to be deducted. + def amount_for_source + if @source_id && @source_type + # @amount = @payment&.source&.amount + @amount = 30000 + end + + @amount + end + +end diff --git a/config/routes.rb b/config/routes.rb index 800965672..853079e10 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -16,7 +16,7 @@ resources :users do get :me, on: :collection end - + resources :shareables, only: [:show, :index, :create, :destroy, :update] do collection do delete :unshare @@ -225,6 +225,12 @@ get "stockit_items/:id", to: "packages#stockit_item_details" put "orders_packages/:id/actions/:action_name", to: "orders_packages#exec_action" put "packages/:id/actions/:action_name", to: "packages#register_quantity_change" + + get "stripe/fetch_public_key", to: "stripe#fetch_public_key" + post "stripe/create_setupintent", to: "stripe#create_setupintent" + post "stripe/save_payment_method", to: "stripe#save_payment_method" + + end end end diff --git a/config/secrets.yml b/config/secrets.yml index 477844e1b..91ba1f7b3 100755 --- a/config/secrets.yml +++ b/config/secrets.yml @@ -55,6 +55,9 @@ base: &BASE printer_host: <%=ENV['BARCODE_PRINTER_HOST']%> printer_user: <%=ENV['BARCODE_PRINTER_USER']%> printer_pwd: <%=ENV['BARCODE_PRINTER_PWD']%> + stripe: + publishable_key: <%= ENV['STRIPE_PUBLISHABLE_KEY'] %> + secret_key: <%= ENV['STRIPE_SECRET_KEY'] %> development: <<: *BASE diff --git a/db/migrate/20210217160745_add_stripe_customer_id_to_users.rb b/db/migrate/20210217160745_add_stripe_customer_id_to_users.rb new file mode 100644 index 000000000..f490a27fb --- /dev/null +++ b/db/migrate/20210217160745_add_stripe_customer_id_to_users.rb @@ -0,0 +1,5 @@ +class AddStripeCustomerIdToUsers < ActiveRecord::Migration[5.2] + def change + add_column :users, :stripe_customer_id, :string, default: nil + end +end diff --git a/db/migrate/20210217163846_create_stripe_payments.rb b/db/migrate/20210217163846_create_stripe_payments.rb new file mode 100644 index 000000000..156d8d171 --- /dev/null +++ b/db/migrate/20210217163846_create_stripe_payments.rb @@ -0,0 +1,17 @@ +class CreateStripePayments < ActiveRecord::Migration[5.2] + def change + create_table :stripe_payments do |t| + t.integer :user_id + t.string :setup_intent_id + t.string :payment_method_id + t.string :payment_intent_id + t.float :amount + t.string :status + t.string :receipt_url + t.string :source_type + t.integer :source_id + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 6d3ff3d48..e899f838c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,21 +10,22 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_01_18_111336) do +ActiveRecord::Schema.define(version: 2021_02_17_163846) do # These are extensions that must be enabled in order to support this database - enable_extension "fuzzystrmatch" + enable_extension "btree_gin" enable_extension "pg_trgm" + enable_extension "pgcrypto" enable_extension "plpgsql" create_table "addresses", id: :serial, force: :cascade do |t| - t.string "flat", limit: 255 - t.string "building", limit: 255 - t.string "street", limit: 255 + t.string "flat" + t.string "building" + t.string "street" t.integer "district_id" t.integer "addressable_id" - t.string "addressable_type", limit: 255 - t.string "address_type", limit: 255 + t.string "addressable_type" + t.string "address_type" t.datetime "created_at" t.datetime "updated_at" t.datetime "deleted_at" @@ -47,7 +48,7 @@ create_table "auth_tokens", id: :serial, force: :cascade do |t| t.datetime "otp_code_expiry" - t.string "otp_secret_key", limit: 255 + t.string "otp_secret_key" t.integer "user_id" t.datetime "created_at" t.datetime "updated_at" @@ -75,7 +76,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.string "identifier" - t.index ["identifier"], name: "index_booking_types_on_identifier" + t.index ["name_en", "name_zh_tw"], name: "index_booking_types_on_name_en_and_name_zh_tw", unique: true end create_table "boxes", id: :serial, force: :cascade do |t| @@ -159,8 +160,8 @@ end create_table "contacts", id: :serial, force: :cascade do |t| - t.string "name", limit: 255 - t.string "mobile", limit: 255 + t.string "name" + t.string "mobile" t.datetime "created_at" t.datetime "updated_at" t.datetime "deleted_at" @@ -174,8 +175,8 @@ end create_table "crossroads_transports", id: :serial, force: :cascade do |t| - t.string "name_en", limit: 255 - t.string "name_zh_tw", limit: 255 + t.string "name_en" + t.string "name_zh_tw" t.datetime "created_at" t.datetime "updated_at" t.integer "cost" @@ -187,7 +188,7 @@ t.integer "offer_id" t.integer "contact_id" t.integer "schedule_id" - t.string "delivery_type", limit: 255 + t.string "delivery_type" t.datetime "start" t.datetime "finish" t.datetime "created_at" @@ -201,8 +202,8 @@ end create_table "districts", id: :serial, force: :cascade do |t| - t.string "name_en", limit: 255 - t.string "name_zh_tw", limit: 255 + t.string "name_en" + t.string "name_zh_tw" t.integer "territory_id" t.datetime "created_at" t.datetime "updated_at" @@ -212,8 +213,8 @@ end create_table "donor_conditions", id: :serial, force: :cascade do |t| - t.string "name_en", limit: 255 - t.string "name_zh_tw", limit: 255 + t.string "name_en" + t.string "name_zh_tw" t.datetime "created_at" t.datetime "updated_at" t.boolean "visible_to_donor", default: true, null: false @@ -238,7 +239,7 @@ create_table "gogovan_orders", id: :serial, force: :cascade do |t| t.integer "booking_id" - t.string "status", limit: 255 + t.string "status" t.datetime "created_at" t.datetime "updated_at" t.datetime "deleted_at" @@ -252,8 +253,8 @@ end create_table "gogovan_transports", id: :serial, force: :cascade do |t| - t.string "name_en", limit: 255 - t.string "name_zh_tw", limit: 255 + t.string "name_en" + t.string "name_zh_tw" t.datetime "created_at" t.datetime "updated_at" t.boolean "disabled", default: false @@ -282,7 +283,7 @@ create_table "holidays", id: :serial, force: :cascade do |t| t.datetime "holiday" t.integer "year" - t.string "name", limit: 255 + t.string "name" t.datetime "created_at" t.datetime "updated_at" end @@ -296,7 +297,7 @@ end create_table "images", id: :serial, force: :cascade do |t| - t.string "cloudinary_id", limit: 255 + t.string "cloudinary_id" t.boolean "favourite", default: false t.integer "item_id" t.datetime "created_at" @@ -314,11 +315,11 @@ create_table "items", id: :serial, force: :cascade do |t| t.text "donor_description" - t.string "state", limit: 255 + t.string "state" t.integer "offer_id", null: false t.integer "package_type_id" t.integer "rejection_reason_id" - t.string "reject_reason", limit: 255 + t.string "reject_reason" t.datetime "created_at" t.datetime "updated_at" t.integer "donor_condition_id" @@ -335,6 +336,8 @@ t.string "area" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["area"], name: "index_locations_on_area", using: :gin + t.index ["building"], name: "index_locations_on_building", using: :gin end create_table "lookups", id: :serial, force: :cascade do |t| @@ -370,17 +373,18 @@ t.integer "messageable_id" t.jsonb "lookup", default: {} t.integer "recipient_id" + t.index ["body"], name: "messages_body_search_idx", using: :gin t.index ["lookup"], name: "index_messages_on_lookup", using: :gin t.index ["sender_id"], name: "index_messages_on_sender_id" end create_table "offers", id: :serial, force: :cascade do |t| - t.string "language", limit: 255 - t.string "state", limit: 255 - t.string "origin", limit: 255 + t.string "language" + t.string "state" + t.string "origin" t.boolean "stairs" t.boolean "parking" - t.string "estimated_size", limit: 255 + t.string "estimated_size" t.text "notes" t.integer "created_by_id" t.datetime "created_at" @@ -409,6 +413,7 @@ t.index ["created_by_id"], name: "index_offers_on_created_by_id" t.index ["crossroads_transport_id"], name: "index_offers_on_crossroads_transport_id" t.index ["gogovan_transport_id"], name: "index_offers_on_gogovan_transport_id" + t.index ["notes"], name: "offers_notes_search_idx", using: :gin t.index ["received_by_id"], name: "index_offers_on_received_by_id" t.index ["reviewed_by_id"], name: "index_offers_on_reviewed_by_id" t.index ["state"], name: "index_offers_on_state" @@ -445,12 +450,9 @@ t.string "code" t.string "detail_type" t.integer "detail_id" - t.integer "stockit_contact_id" - t.integer "stockit_organisation_id" t.datetime "created_at" t.datetime "updated_at", null: false t.text "description" - t.integer "stockit_activity_id" t.integer "country_id" t.integer "created_by_id" t.integer "processed_by_id" @@ -478,10 +480,14 @@ t.integer "cancellation_reason_id" t.boolean "continuous", default: false t.date "shipment_date" + t.integer "stockit_activity_id" + t.integer "stockit_organisation_id" + t.integer "stockit_contact_id" t.index ["address_id"], name: "index_orders_on_address_id" t.index ["beneficiary_id"], name: "index_orders_on_beneficiary_id" t.index ["cancelled_by_id"], name: "index_orders_on_cancelled_by_id" t.index ["closed_by_id"], name: "index_orders_on_closed_by_id" + t.index ["code"], name: "orders_code_idx", using: :gin t.index ["country_id"], name: "index_orders_on_country_id" t.index ["created_by_id"], name: "index_orders_on_created_by_id" t.index ["detail_id", "detail_type"], name: "index_orders_on_detail_id_and_detail_type" @@ -492,9 +498,6 @@ t.index ["processed_by_id"], name: "index_orders_on_processed_by_id" t.index ["shipment_date"], name: "index_orders_on_shipment_date" t.index ["state"], name: "index_orders_on_state" - t.index ["stockit_activity_id"], name: "index_orders_on_stockit_activity_id" - t.index ["stockit_contact_id"], name: "index_orders_on_stockit_contact_id" - t.index ["stockit_organisation_id"], name: "index_orders_on_stockit_organisation_id" t.index ["submitted_by_id"], name: "index_orders_on_submitted_by_id" end @@ -608,23 +611,23 @@ t.boolean "visible_in_selects", default: false t.integer "location_id" t.boolean "allow_requests", default: true - t.boolean "allow_pieces", default: false t.boolean "allow_stock", default: false + t.boolean "allow_pieces", default: false t.string "subform" t.boolean "allow_box", default: false t.boolean "allow_pallet", default: false - t.decimal "default_value_hk_dollar" t.boolean "allow_expiry_date", default: false - t.text "description_en" - t.text "description_zh_tw" + t.decimal "default_value_hk_dollar" t.integer "length" t.integer "width" t.integer "height" t.string "department" + t.text "description_en" + t.text "description_zh_tw" t.index ["allow_requests"], name: "index_package_types_on_allow_requests" - t.index ["description_en"], name: "index_package_types_on_description_en" - t.index ["description_zh_tw"], name: "index_package_types_on_description_zh_tw" t.index ["location_id"], name: "index_package_types_on_location_id" + t.index ["name_en"], name: "package_types_name_en_search_idx", using: :gin + t.index ["name_zh_tw"], name: "package_types_name_zh_tw_search_idx", using: :gin t.index ["visible_in_selects"], name: "index_package_types_on_visible_in_selects" end @@ -634,7 +637,7 @@ t.integer "height" t.text "notes", null: false t.integer "item_id" - t.string "state", limit: 255 + t.string "state" t.datetime "received_at" t.datetime "rejected_at" t.integer "package_type_id" @@ -650,13 +653,9 @@ t.integer "box_id" t.integer "pallet_id" t.integer "order_id" - t.date "stockit_sent_on" - t.date "stockit_designated_on" - t.integer "stockit_designated_by_id" - t.integer "stockit_sent_by_id" + t.integer "on_hand_boxed_quantity", default: 0 + t.integer "on_hand_palletized_quantity", default: 0 t.integer "favourite_image_id" - t.date "stockit_moved_on" - t.integer "stockit_moved_by_id" t.boolean "saleable" t.string "case_number" t.boolean "allow_web_publish" @@ -666,37 +665,43 @@ t.integer "detail_id" t.string "detail_type" t.integer "storage_type_id" - t.decimal "value_hk_dollar" t.integer "available_quantity", default: 0 t.integer "on_hand_quantity", default: 0 t.integer "designated_quantity", default: 0 t.integer "dispatched_quantity", default: 0 t.date "expiry_date" + t.decimal "value_hk_dollar" t.integer "package_set_id" t.integer "restriction_id" t.text "comment" - t.integer "on_hand_boxed_quantity", default: 0 - t.integer "on_hand_palletized_quantity", default: 0 + t.integer "stockit_moved_by_id" + t.datetime "stockit_moved_on" + t.integer "stockit_sent_by_id" + t.integer "stockit_designated_by_id" + t.datetime "stockit_designated_on" + t.datetime "stockit_sent_on" t.text "notes_zh_tw" t.index ["allow_web_publish"], name: "index_packages_on_allow_web_publish" t.index ["available_quantity"], name: "index_packages_on_available_quantity" t.index ["box_id"], name: "index_packages_on_box_id" + t.index ["case_number"], name: "index_packages_on_case_number", using: :gin t.index ["designated_quantity"], name: "index_packages_on_designated_quantity" + t.index ["designation_name"], name: "index_packages_on_designation_name", using: :gin t.index ["detail_type", "detail_id"], name: "index_packages_on_detail_type_and_detail_id" t.index ["dispatched_quantity"], name: "index_packages_on_dispatched_quantity" t.index ["donor_condition_id"], name: "index_packages_on_donor_condition_id" t.index ["inventory_number"], name: "index_packages_on_inventory_number" + t.index ["inventory_number"], name: "inventory_numbers_search_idx", using: :gin t.index ["item_id"], name: "index_packages_on_item_id" t.index ["location_id"], name: "index_packages_on_location_id" + t.index ["notes"], name: "index_packages_on_notes", using: :gin t.index ["offer_id"], name: "index_packages_on_offer_id" t.index ["on_hand_quantity"], name: "index_packages_on_on_hand_quantity" t.index ["order_id"], name: "index_packages_on_order_id" t.index ["package_set_id"], name: "index_packages_on_package_set_id" t.index ["package_type_id"], name: "index_packages_on_package_type_id" t.index ["pallet_id"], name: "index_packages_on_pallet_id" - t.index ["stockit_designated_by_id"], name: "index_packages_on_stockit_designated_by_id" - t.index ["stockit_moved_by_id"], name: "index_packages_on_stockit_moved_by_id" - t.index ["stockit_sent_by_id"], name: "index_packages_on_stockit_sent_by_id" + t.index ["state"], name: "index_packages_on_state", using: :gin t.index ["storage_type_id"], name: "index_packages_on_storage_type_id" end @@ -747,7 +752,7 @@ end create_table "permissions", id: :serial, force: :cascade do |t| - t.string "name", limit: 255 + t.string "name" t.datetime "created_at" t.datetime "updated_at" end @@ -792,10 +797,10 @@ end create_table "rejection_reasons", id: :serial, force: :cascade do |t| - t.string "name_en", limit: 255 + t.string "name_en" t.datetime "created_at" t.datetime "updated_at" - t.string "name_zh_tw", limit: 255 + t.string "name_zh_tw" end create_table "requested_packages", id: :serial, force: :cascade do |t| @@ -834,10 +839,10 @@ end create_table "schedules", id: :serial, force: :cascade do |t| - t.string "resource", limit: 255 + t.string "resource" t.integer "slot" - t.string "slot_name", limit: 255 - t.string "zone", limit: 255 + t.string "slot_name" + t.string "zone" t.datetime "scheduled_at" t.datetime "created_at" t.datetime "updated_at" @@ -877,6 +882,10 @@ t.integer "stockit_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["first_name"], name: "st_contacts_first_name_idx", using: :gin + t.index ["last_name"], name: "st_contacts_last_name_idx", using: :gin + t.index ["mobile_phone_number"], name: "st_contacts_mobile_phone_number_idx", using: :gin + t.index ["phone_number"], name: "st_contacts_phone_number_idx", using: :gin end create_table "stockit_local_orders", id: :serial, force: :cascade do |t| @@ -887,6 +896,7 @@ t.datetime "created_at", null: false t.datetime "updated_at", null: false t.text "purpose_of_goods" + t.index ["client_name"], name: "st_local_orders_client_name_idx", using: :gin end create_table "stockit_organisations", id: :serial, force: :cascade do |t| @@ -894,6 +904,7 @@ t.integer "stockit_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.index ["name"], name: "st_organisations_name_idx", using: :gin end create_table "stocktake_revisions", id: :serial, force: :cascade do |t| @@ -936,21 +947,35 @@ t.integer "max_unit_quantity" end + create_table "stripe_payments", force: :cascade do |t| + t.integer "user_id" + t.string "setup_intent_id" + t.string "payment_method_id" + t.string "payment_intent_id" + t.float "amount" + t.string "status" + t.string "receipt_url" + t.string "source_type" + t.integer "source_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "subpackage_types", id: :serial, force: :cascade do |t| t.integer "package_type_id" t.integer "subpackage_type_id" t.boolean "is_default", default: false t.datetime "created_at", null: false t.datetime "updated_at", null: false - t.index ["package_type_id", "package_type_id"], name: "index_subpackage_types_on_package_type_id_and_package_type_id" t.index ["package_type_id"], name: "index_subpackage_types_on_package_type_id" + t.index ["package_type_id"], name: "index_subpackage_types_on_package_type_id_and_package_type_id" t.index ["subpackage_type_id"], name: "index_subpackage_types_on_subpackage_type_id" end create_table "subscriptions", id: :serial, force: :cascade do |t| t.integer "user_id" t.integer "message_id" - t.string "state", limit: 255 + t.string "state" t.string "subscribable_type" t.integer "subscribable_id" t.index ["message_id"], name: "index_subscriptions_on_message_id" @@ -959,19 +984,40 @@ end create_table "territories", id: :serial, force: :cascade do |t| - t.string "name_en", limit: 255 - t.string "name_zh_tw", limit: 255 + t.string "name_en" + t.string "name_zh_tw" t.datetime "created_at" t.datetime "updated_at" end create_table "timeslots", id: :serial, force: :cascade do |t| - t.string "name_en", limit: 255 - t.string "name_zh_tw", limit: 255 + t.string "name_en" + t.string "name_zh_tw" t.datetime "created_at" t.datetime "updated_at" end + create_table "transport_orders", force: :cascade do |t| + t.integer "transport_provider_id" + t.string "order_uuid" + t.string "status" + t.datetime "scheduled_at" + t.jsonb "metadata" + t.integer "source_id" + t.string "source_type" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "transport_providers", force: :cascade do |t| + t.string "name" + t.string "logo" + t.text "description" + t.jsonb "metadata", default: "{}" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "user_favourites", force: :cascade do |t| t.string "favourite_type" t.integer "favourite_id" @@ -995,9 +1041,9 @@ end create_table "users", id: :serial, force: :cascade do |t| - t.string "first_name", limit: 255 - t.string "last_name", limit: 255 - t.string "mobile", limit: 255 + t.string "first_name" + t.string "last_name" + t.string "mobile" t.datetime "created_at" t.datetime "updated_at" t.integer "image_id" @@ -1012,6 +1058,7 @@ t.boolean "receive_email", default: false t.string "other_phone" t.string "preferred_language" + t.string "stripe_customer_id" t.index ["image_id"], name: "index_users_on_image_id" t.index ["mobile"], name: "index_users_on_mobile" t.index ["sms_reminder_sent_at"], name: "index_users_on_sms_reminder_sent_at" @@ -1094,10 +1141,7 @@ add_foreign_key "orders", "countries", name: "orders_country_id_fk" add_foreign_key "orders", "districts", name: "orders_district_id_fk" add_foreign_key "orders", "organisations", name: "orders_organisation_id_fk" - add_foreign_key "orders", "stockit_activities", name: "orders_stockit_activity_id_fk" - add_foreign_key "orders", "stockit_contacts", name: "orders_stockit_contact_id_fk" add_foreign_key "orders", "stockit_local_orders", column: "detail_id", name: "orders_detail_id_fk" - add_foreign_key "orders", "stockit_organisations", name: "orders_stockit_organisation_id_fk" add_foreign_key "orders", "users", column: "cancelled_by_id", name: "orders_cancelled_by_id_fk" add_foreign_key "orders", "users", column: "closed_by_id", name: "orders_closed_by_id_fk" add_foreign_key "orders", "users", column: "created_by_id", name: "orders_created_by_id_fk" @@ -1131,9 +1175,6 @@ add_foreign_key "packages", "pallets", name: "packages_pallet_id_fk" add_foreign_key "packages", "restrictions", name: "packages_restriction_id_fk" add_foreign_key "packages", "storage_types", name: "packages_storage_type_id_fk" - add_foreign_key "packages", "users", column: "stockit_designated_by_id", name: "packages_stockit_designated_by_id_fk" - add_foreign_key "packages", "users", column: "stockit_moved_by_id", name: "packages_stockit_moved_by_id_fk" - add_foreign_key "packages", "users", column: "stockit_sent_by_id", name: "packages_stockit_sent_by_id_fk" add_foreign_key "packages_inventories", "locations" add_foreign_key "packages_inventories", "packages" add_foreign_key "packages_inventories", "users" diff --git a/spec/factories/stripe_payments.rb b/spec/factories/stripe_payments.rb new file mode 100644 index 000000000..77f6683aa --- /dev/null +++ b/spec/factories/stripe_payments.rb @@ -0,0 +1,10 @@ +FactoryBot.define do + factory :stripe_payment do + user_id { 1 } + setup_intent_id { "MyString" } + payment_intent_id { "MyString" } + amount { 1.5 } + status { "MyString" } + receipt_url { "MyString" } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 63c73e8a3..dc39ffeb6 100755 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -86,6 +86,10 @@ is_mobile_verified { false } end + trait :stripe_user do + stripe_customer_id { 'cus_IzVEJhwLTIZg1F' } + end + trait :stockit_user do first_name { 'Stockit' } last_name { 'User' } diff --git a/spec/models/stripe_payment_spec.rb b/spec/models/stripe_payment_spec.rb new file mode 100644 index 000000000..238b93da7 --- /dev/null +++ b/spec/models/stripe_payment_spec.rb @@ -0,0 +1,4 @@ +require 'rails_helper' + +RSpec.describe StripePayment, type: :model do +end diff --git a/spec/services/stripe_service_spec.rb b/spec/services/stripe_service_spec.rb new file mode 100644 index 000000000..994805bd6 --- /dev/null +++ b/spec/services/stripe_service_spec.rb @@ -0,0 +1,147 @@ +require "rails_helper" + +describe StripeService do + + let(:stripe_object) { StripeService.new(attributes) } + + let(:user) { create :user, :stripe_user } + let(:new_user) { create :user } + + let(:stripe_setupintent_object) { + { + id: "seti_1IO3z18WYtC2zB", + object: "setup_intent", + customer: user.stripe_customer_id + } + } + + let(:attributes) { + { + source_type: 'transport_order', + source_id: 1, + authorize_amount: false, + } + } + + let(:stripe_payment_method_object) { + { + id: "seti_1IO3abJG1rVU42zB", + payment_method: "pm_1IO3bbz1shC6ytgC", + status: "requires_payment_method" + } + } + + let(:authorize_charge_response) { + { id: "pi_1IO3bLJG1rVU4", status: "requires_capture" } + } + + before { User.current_user = user } + + context "initialization" do + it "user_id" do + expect(stripe_object.user_id).to eql(user.id) + end + + it "customer_id" do + expect(stripe_object.customer_id).to eql(user.stripe_customer_id) + end + + it "source_type" do + expect(stripe_object.source_type).to eql(attributes[:source_type]) + end + + it "source_id" do + expect(stripe_object.source_id).to eql(attributes[:source_id]) + end + + it "authorize_amount" do + expect(stripe_object.authorize_amount).to eql(attributes[:authorize_amount]) + end + end + + context "public_key" do + it do + expect(stripe_object.public_key).to eql( + { publicKey: Rails.application.secrets.stripe[:publishable_key] } + ) + end + end + + context "stripe customer creation" do + + before { User.current_user = new_user } + let(:stripe_customer_id) { "cus_J0KAOGkksjsbBT" } + + it "should hit stript endpoint to create new customer" do + + stub_request(:post, "https://api.stripe.com/v1/customers"). + with( + body: {"email" => new_user.email, "name" => new_user.full_name, "phone" => new_user.mobile}, + ).to_return(status: 200, body: {id: stripe_customer_id}.to_json, headers: {}) + + stripe_object = StripeService.new() + + expect(stripe_object.customer_id).to eq(stripe_customer_id) + expect(new_user.reload.stripe_customer_id).to eq(stripe_customer_id) + end + end + + context "create_setup_intent" do + it "should hit stripe to initiate stripe-payment process for customer" do + + stub_request(:post, "https://api.stripe.com/v1/setup_intents") + .with(body: {"customer"=>"cus_IzVEJhwLTIZg1F"}) + .to_return(status: 200, body: stripe_setupintent_object.to_json, headers: {}) + + expect(stripe_object.create_setup_intent.to_json).to eql(stripe_setupintent_object.to_json) + end + end + + context "save_payment_method" do + it "add StripePayment record for customer payment-method details" do + expect{ + stripe_object.save_payment_method(stripe_payment_method_object) + }.to change(StripePayment, :count).by(1) + + payment = StripePayment.last + expect(payment.setup_intent_id).to eq(stripe_payment_method_object[:id]) + expect(payment.payment_method_id).to eq(stripe_payment_method_object[:payment_method]) + expect(payment.status).to eq(stripe_payment_method_object[:status]) + end + end + + context "authorize_amount_on_saved_card" do + before { stripe_object.save_payment_method(stripe_payment_method_object) } + + it "should hit stripe to authorize charge manually" do + + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with( + body: { + amount: "20000", + capture_method: "manual", + confirm: "true", + currency: "inr", + customer: user.stripe_customer_id, + off_session: "true", + payment_method: stripe_payment_method_object[:payment_method] + }) + .to_return(status: 200, body: authorize_charge_response.to_json, headers: {}) + + stripe_object.authorize_amount_on_saved_card(20000, user.stripe_customer_id, stripe_payment_method_object[:payment_method]) + + expect(StripePayment.last.payment_intent_id).to eq(authorize_charge_response[:id]) + expect(StripePayment.last.status).to eq(authorize_charge_response[:status]) + end + end + + context "capture_payment" do + it "should capture amount from authorized amount" do + stub_request(:post, "https://api.stripe.com/v1/payment_intents/#{authorize_charge_response[:id]}/capture") + .with( body: { amount_to_capture: "20000" }).to_return(status: 200, body: {}.to_json, headers: {}) + + stripe_object.capture_payment(authorize_charge_response[:id], 20000) + end + end + +end diff --git a/spec/support/env.rb b/spec/support/env.rb index 706c4ecf3..8240f1158 100644 --- a/spec/support/env.rb +++ b/spec/support/env.rb @@ -27,3 +27,6 @@ ENV['JWT_VALIDITY_FOR_API'] = "31536000" ENV['OTP_CODE_VALIDITY']="30" ENV['SOCKETIO_SERVICE_URL']="http://localhost:1337/send?site=goodcity&apiKey=132323" + +ENV['STRIPE_PUBLISHABLE_KEY'] = "pk_test_51IBXaDJG1rVU4bz1Z8GkITD4hci63k9cPs5Jq60" +ENV['STRIPE_SECRET_KEY'] = "sk_test_51IBXaDJG1rVU4bz1ZjTt0cI6daI5HuVZi0hOQ5P"