diff --git a/app/controllers/loans_controller.rb b/app/controllers/loans_controller.rb index ec98cfa..678d6fd 100644 --- a/app/controllers/loans_controller.rb +++ b/app/controllers/loans_controller.rb @@ -1,14 +1,14 @@ class LoansController < ActionController::API rescue_from ActiveRecord::RecordNotFound do |exception| - render json: 'not_found', status: :not_found + render json: { error: 'not_found' }, status: :not_found end 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..be63c46 --- /dev/null +++ b/app/controllers/payments_controller.rb @@ -0,0 +1,34 @@ +class PaymentsController < ActionController::API + before_action :set_loan + + rescue_from ActiveRecord::RecordNotFound do |exception| + render json: { error: 'not_found' }, status: :not_found + end + + def index + render json: @loan.payments + end + + def show + render json: @loan.payments.find(params[:id]) + end + + def create + @payment = @loan.payments.build(payment_params) + if @payment.save + render json: @payment, status: :created + else + render json: { errors: @payment.errors.full_messages }, status: :unprocessable_entity + end + end + + private + + def set_loan + @loan = Loan.find(params[:loan_id]) + end + + def payment_params + params.require(:payment).permit(:payment_date, :amount) + end +end diff --git a/app/models/loan.rb b/app/models/loan.rb index aa46eda..8be74fe 100644 --- a/app/models/loan.rb +++ b/app/models/loan.rb @@ -1,2 +1,8 @@ class Loan < ActiveRecord::Base + has_many :payments + + # Calculates the outstanding balance: funded_amount minus total payments made + 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..346174a --- /dev/null +++ b/app/models/payment.rb @@ -0,0 +1,16 @@ +class Payment < ActiveRecord::Base + belongs_to :loan + + validates :payment_date, presence: true + validates :amount, presence: true, numericality: { greater_than: 0 } + validate :amount_does_not_exceed_outstanding_balance + + private + + def amount_does_not_exceed_outstanding_balance + existing_total = loan.payments.where.not(id: self.id).sum(:amount) + if loan && amount && (existing_total + amount > loan.funded_amount) + errors.add(:amount, "exceeds the outstanding balance of the loan") + end + end +end diff --git a/config/routes.rb b/config/routes.rb index 89db866..e47ebfb 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, only: [:index, :show], defaults: { format: :json } do + resources :payments, only: [:index, :show, :create], defaults: { format: :json } + end end diff --git a/db/migrate/20250313061519_create_payments.rb b/db/migrate/20250313061519_create_payments.rb new file mode 100644 index 0000000..5f811bf --- /dev/null +++ b/db/migrate/20250313061519_create_payments.rb @@ -0,0 +1,11 @@ +class CreatePayments < ActiveRecord::Migration[5.2] + def change + create_table :payments do |t| + t.references :loan, null: false, foreign_key: true + t.date :payment_date, null: false + t.decimal :amount, precision: 15, scale: 2, null: false + + t.timestamps + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 3b0f090..9eb546a 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_03_13_061519) 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", null: false + t.date "payment_date", null: false + t.decimal "amount", precision: 15, scale: 2, null: false + 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..0e4fd8a 100644 --- a/spec/controllers/loans_controller_spec.rb +++ b/spec/controllers/loans_controller_spec.rb @@ -1,25 +1,36 @@ require 'rails_helper' RSpec.describe LoansController, type: :controller do - describe '#index' do - it 'responds with a 200' do - get :index + let!(:loan) { Loan.create!(funded_amount: 1000) } + let!(:payment) { Payment.create!(loan: loan, payment_date: Date.today, amount: 200) } + + describe "GET #index" do + it "returns loans with the outstanding balance included" do + get :index, format: :json expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json.first).to have_key("outstanding_balance") + expect(json.first["outstanding_balance"].to_f).to eq(800.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) + describe "GET #show" do + context "with a valid id" do + it "returns a loan with its outstanding balance" do + get :show, params: { id: loan.id }, format: :json + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json).to have_key("outstanding_balance") + expect(json["outstanding_balance"].to_f).to eq(800.0) + end end - context 'if the loan is not found' do - it 'responds with a 404' do - get :show, params: { id: 10000 } + context "with an invalid id" do + it "returns a 404 not_found response" do + get :show, params: { id: 0 }, format: :json expect(response).to have_http_status(:not_found) + json = JSON.parse(response.body) + expect(json["error"]).to eq("not_found") end end end diff --git a/spec/controllers/payments_controller_spec.rb b/spec/controllers/payments_controller_spec.rb new file mode 100644 index 0000000..9f3a443 --- /dev/null +++ b/spec/controllers/payments_controller_spec.rb @@ -0,0 +1,44 @@ +require 'rails_helper' + +RSpec.describe PaymentsController, type: :controller do + let!(:loan) { Loan.create!(funded_amount: 1200) } + let!(:payment) { Payment.create!(loan: loan, payment_date: Date.today, amount: 500) } + + describe "GET #index" do + it "returns a list of payments for a loan" do + get :index, params: { loan_id: loan.id }, format: :json + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json.size).to eq(1) + end + end + + describe "GET #show" do + it "returns a specific payment" do + get :show, params: { loan_id: loan.id, id: payment.id }, format: :json + expect(response).to have_http_status(:ok) + json = JSON.parse(response.body) + expect(json["id"]).to eq(payment.id) + end + end + + describe "POST #create" do + context "with valid attributes" do + it "creates a new payment" do + post :create, params: { loan_id: loan.id, payment: { payment_date: '2025-03-13', amount: 500 } }, format: :json + expect(response).to have_http_status(:created) + json = JSON.parse(response.body) + expect(json["amount"].to_f).to eq(500.0) + end + end + + context "with invalid attributes" do + it "returns errors when payment exceeds the outstanding balance" do + post :create, params: { loan_id: loan.id, payment: { payment_date: '2025-03-13', amount: 900 } }, format: :json + expect(response).to have_http_status(:unprocessable_entity) + json = JSON.parse(response.body) + expect(json["errors"]).to include("Amount exceeds the outstanding balance of the loan") + end + end + end +end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb new file mode 100644 index 0000000..7f9793a --- /dev/null +++ b/spec/models/payment_spec.rb @@ -0,0 +1,21 @@ +require 'rails_helper' + +RSpec.describe Payment, type: :model do + let(:loan) { Loan.create!(funded_amount: 10000) } + + it "is valid with a payment amount within the outstanding balance" do + payment = Payment.new(loan: loan, payment_date: Date.today, amount: 4500) + expect(payment).to be_valid + end + + it "is invalid if the payment amount exceeds the outstanding balance" do + + Payment.create!(loan: loan, payment_date: Date.today, amount: 5000) + Payment.create!(loan: loan, payment_date: Date.today, amount: 4000) + Payment.create!(loan: loan, payment_date: Date.today, amount: 800) + + payment = Payment.new(loan: loan, payment_date: Date.today, amount: 300) + expect(payment).not_to be_valid + expect(payment.errors[:amount]).to include("exceeds the outstanding balance of the loan") + end +end