diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 09705d1..3a4f8f8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,2 +1,5 @@ -class ApplicationController < ActionController::Base +class ApplicationController < ActionController::API + rescue_from ActiveRecord::RecordNotFound do |exception| + render json: 'not_found', status: :not_found + end end diff --git a/app/controllers/loans_controller.rb b/app/controllers/loans_controller.rb index ec98cfa..821c67a 100644 --- a/app/controllers/loans_controller.rb +++ b/app/controllers/loans_controller.rb @@ -1,14 +1,10 @@ -class LoansController < ActionController::API - - rescue_from ActiveRecord::RecordNotFound do |exception| - render json: 'not_found', status: :not_found - end +class LoansController < ApplicationController def index - render json: Loan.all + render json: Loan.all.as_json(methods: :outstanding_balance) end def show - render json: Loan.find(params[:id]) + render json: Loan.find(params[:id]).as_json(methods: :outstanding_balance) end end diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb new file mode 100644 index 0000000..84fa5c5 --- /dev/null +++ b/app/controllers/payments_controller.rb @@ -0,0 +1,26 @@ +class PaymentsController < ApplicationController + def index + render json: Loan.find(params[:loan_id]).payments + end + + def show + render json: Loan.find(params[:loan_id]).payments.find(params[:id]) + end + + def create + loan = Loan.find(params[:loan_id]) + payment = loan.payments.build(payment_params) + + if payment.save + render json: payment, status: :created + else + render json: payment.errors, status: :unprocessable_entity + end + end + + private + + def payment_params + params.require(:payment).permit(:date, :amount) + end +end diff --git a/app/models/loan.rb b/app/models/loan.rb index aa46eda..9da78c0 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -1,2 +1,7 @@ class Loan < ActiveRecord::Base + has_many :payments + + def outstanding_balance + funded_amount - payments.sum(:amount) + end end diff --git a/app/models/payment.rb b/app/models/payment.rb new file mode 100644 index 0000000..84e6f1b --- /dev/null +++ b/app/models/payment.rb @@ -0,0 +1,18 @@ +class Payment < ActiveRecord::Base + belongs_to :loan + + before_create :validate_and_create_payment + + private + + def validate_and_create_payment + ActiveRecord::Base.transaction do + loan.with_lock do + if amount > loan.outstanding_balance + errors.add(:amount, "Payment amount exceeds loan balance") + throw :abort + end + end + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 89db866..27041a1 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,5 @@ Rails.application.routes.draw do - resources :loans, defaults: {format: :json} + resources :loans, defaults: {format: :json}, only: [:index, :show] do + resources :payments, defaults: {format: :json}, only: [:index, :show, :create] + end end diff --git a/db/migrate/20250226204247_create_payments.rb b/db/migrate/20250226204247_create_payments.rb new file mode 100644 index 0000000..9d96b2b --- /dev/null +++ b/db/migrate/20250226204247_create_payments.rb @@ -0,0 +1,11 @@ +class CreatePayments < ActiveRecord::Migration[5.2] + def change + create_table :payments do |t| + t.references :loan, foreign_key: true + t.date :date + t.decimal :amount, precision: 8, scale: 2 + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 3b0f090..40202b2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20150903195334) do +ActiveRecord::Schema.define(version: 2025_02_26_204247) do create_table "loans", force: :cascade do |t| t.decimal "funded_amount", precision: 8, scale: 2 @@ -18,4 +18,13 @@ t.datetime "updated_at", null: false end + create_table "payments", force: :cascade do |t| + t.integer "loan_id" + t.date "date" + t.decimal "amount", precision: 8, scale: 2 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["loan_id"], name: "index_payments_on_loan_id" + end + end diff --git a/spec/controllers/loans_controller_spec.rb b/spec/controllers/loans_controller_spec.rb index 312463a..6b63fa3 100644 --- a/spec/controllers/loans_controller_spec.rb +++ b/spec/controllers/loans_controller_spec.rb @@ -1,21 +1,34 @@ require 'rails_helper' RSpec.describe LoansController, type: :controller do + let(:loan) { Loan.create!(funded_amount: 100.0) } + describe '#index' do it 'responds with a 200' do get :index expect(response).to have_http_status(:ok) end + + it 'includes oustanding balance in the response' do + loan # create loan + get :index + json_response = JSON.parse(response.body) + expect(json_response.first['outstanding_balance'].to_f).to be 100.0 + end end describe '#show' do - let(:loan) { Loan.create!(funded_amount: 100.0) } - it 'responds with a 200' do get :show, params: { id: loan.id } expect(response).to have_http_status(:ok) end + it 'includes oustanding balance in the response' do + get :show, params: { id: loan.id } + json_response = JSON.parse(response.body) + expect(json_response['outstanding_balance'].to_f).to be 100.0 + end + context 'if the loan is not found' do it 'responds with a 404' do get :show, params: { id: 10000 } diff --git a/spec/controllers/payments_controller_spec.rb b/spec/controllers/payments_controller_spec.rb new file mode 100644 index 0000000..bc06674 --- /dev/null +++ b/spec/controllers/payments_controller_spec.rb @@ -0,0 +1,48 @@ +require 'rails_helper' + +RSpec.describe PaymentsController, type: :controller do + let(:loan) { Loan.create!(funded_amount: 100.0) } + let(:payment) { Payment.create!(loan: loan, date: Date.today, amount: 100.00) } + + describe "#index" do + it "returns http success with payments" do + payment + get :index, params: { loan_id: loan.id } + expect(response).to have_http_status(:success) + expect(response.body).to include("100.0") + end + + context "when loan is not found" do + it "returns http not found" do + get :index, params: { loan_id: 10000 } + expect(response).to have_http_status(:not_found) + end + end + end + + describe "#show" do + it "returns http success with payment details" do + payment + get :show, params: { loan_id: loan.id, id: payment.id } + expect(response).to have_http_status(:success) + expect(response.body).to include("100.0") + end + end + + describe "#create" do + it "add payment & returns http success" do + expect do + post :create, params: { loan_id: loan.id, payment: { date: Date.today, amount: 100.00 } } + expect(response).to have_http_status(:success) + end.to change { loan.payments.count }.by(1) + end + + it "returns http unprocessable_entity when payment exeeds loan balance" do + expect do + post :create, params: { loan_id: loan.id, payment: { date: Date.today, amount: 1000.00 } } + expect(response).to have_http_status(:unprocessable_entity) + expect(response.body).to include("Payment amount exceeds loan balance") + end.to_not change { loan.payments.count } + end + end +end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb new file mode 100644 index 0000000..035a74e --- /dev/null +++ b/spec/models/payment_spec.rb @@ -0,0 +1,20 @@ +require 'rails_helper' + +RSpec.describe Payment, type: :model do + let(:loan) { Loan.create!(funded_amount: 100.0) } + + it "is thread-safe" do + payment1 = Payment.new(loan: loan, date: Date.today, amount: 60.0) + payment2 = Payment.new(loan: loan, date: Date.today, amount: 60.0) + + thread1 = Thread.new { payment1.save } + thread2 = Thread.new { payment2.save } + + thread1.join + thread2.join + + loan.reload + expect(loan.outstanding_balance).to eq(40.0) + expect(loan.payments.count).to eq(1) + end +end