API v1 financial_transactions endpoints (#627)
This commit is contained in:
parent
8c8b42c2b2
commit
b96ce06d94
10 changed files with 397 additions and 1 deletions
24
app/controllers/api/v1/financial_transactions_controller.rb
Normal file
24
app/controllers/api/v1/financial_transactions_controller.rb
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
class Api::V1::FinancialTransactionsController < Api::V1::BaseController
|
||||||
|
include Concerns::CollectionScope
|
||||||
|
|
||||||
|
before_action ->{ doorkeeper_authorize! 'finance:read', 'finance:write' }
|
||||||
|
|
||||||
|
def index
|
||||||
|
render_collection search_scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: scope.find(params.require(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def scope
|
||||||
|
FinancialTransaction.includes(:user)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ransack_auth_object
|
||||||
|
:finance
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -0,0 +1,21 @@
|
||||||
|
class Api::V1::User::FinancialTransactionsController < Api::V1::BaseController
|
||||||
|
include Concerns::CollectionScope
|
||||||
|
|
||||||
|
before_action ->{ doorkeeper_authorize! 'finance:user' }
|
||||||
|
before_action :require_ordergroup
|
||||||
|
|
||||||
|
def index
|
||||||
|
render_collection search_scope
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: scope.find(params.require(:id))
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def scope
|
||||||
|
current_ordergroup.financial_transactions.includes(:user)
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
58
app/controllers/concerns/collection_scope.rb
Normal file
58
app/controllers/concerns/collection_scope.rb
Normal file
|
@ -0,0 +1,58 @@
|
||||||
|
module Concerns::CollectionScope
|
||||||
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def scope
|
||||||
|
raise NotImplementedError, 'Please override #scope when you use Concerns::CollectionScope'
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_per_page
|
||||||
|
20
|
||||||
|
end
|
||||||
|
|
||||||
|
def max_per_page
|
||||||
|
250
|
||||||
|
end
|
||||||
|
|
||||||
|
def per_page
|
||||||
|
# allow max_per_page and default_per_page to be nil as well
|
||||||
|
if params[:per_page]
|
||||||
|
[params[:per_page].to_i, max_per_page].compact.min
|
||||||
|
else
|
||||||
|
default_per_page
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def search_scope
|
||||||
|
s = scope
|
||||||
|
s = s.ransack(params[:q], auth_object: ransack_auth_object).result(distinct: true) if params[:q]
|
||||||
|
s = s.page(params[:page].to_i).per(per_page) if per_page && per_page >= 0
|
||||||
|
s
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_collection(scope)
|
||||||
|
render json: scope, meta: collection_meta(scope)
|
||||||
|
end
|
||||||
|
|
||||||
|
def collection_meta(scope, extra = {})
|
||||||
|
return unless scope.respond_to?(:total_count) && per_page
|
||||||
|
|
||||||
|
{
|
||||||
|
page: params[:page].to_i,
|
||||||
|
per_page: per_page,
|
||||||
|
total_pages: (scope.total_count / [1, per_page].max).ceil,
|
||||||
|
total_count: scope.total_count
|
||||||
|
}.merge(extra)
|
||||||
|
end
|
||||||
|
|
||||||
|
# By default, there are no special ransack search scope authentications.
|
||||||
|
# Controllers can override this to return something else and customize a model's
|
||||||
|
# +ransackable_attributes+ and +ransackable_associations+ to allow searching on more
|
||||||
|
# parameters in one controller than another (e.g. to protect searches that are scoped
|
||||||
|
# to a user, while still allowing all search parameters for another endpoint).
|
||||||
|
def ransack_auth_object
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
|
@ -22,6 +22,17 @@ class FinancialTransaction < ApplicationRecord
|
||||||
initialize_financial_transaction_type
|
initialize_financial_transaction_type
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @todo remove alias (and rename created_on to created_at below) after #575
|
||||||
|
ransack_alias :created_at, :created_on
|
||||||
|
|
||||||
|
def self.ransackable_attributes(auth_object = nil)
|
||||||
|
%w(id amount note created_on user_id)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.ransackable_associations(auth_object = nil)
|
||||||
|
%w() # none, and certainly not user until we've secured that more
|
||||||
|
end
|
||||||
|
|
||||||
# Use this save method instead of simple save and after callback
|
# Use this save method instead of simple save and after callback
|
||||||
def add_transaction!
|
def add_transaction!
|
||||||
ordergroup.add_financial_transaction! amount, note, user, financial_transaction_type
|
ordergroup.add_financial_transaction! amount, note, user, financial_transaction_type
|
||||||
|
@ -43,6 +54,11 @@ class FinancialTransaction < ApplicationRecord
|
||||||
reverts.present? || reverted_by.present?
|
reverts.present? || reverted_by.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# @todo rename in model, see #575
|
||||||
|
def created_at
|
||||||
|
created_on
|
||||||
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def initialize_financial_transaction_type
|
def initialize_financial_transaction_type
|
||||||
|
|
13
app/serializers/financial_transaction_serializer.rb
Normal file
13
app/serializers/financial_transaction_serializer.rb
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
class FinancialTransactionSerializer < ActiveModel::Serializer
|
||||||
|
include ApplicationHelper
|
||||||
|
|
||||||
|
attributes :id, :user_id, :user_name, :amount, :note, :created_at
|
||||||
|
|
||||||
|
def user_name
|
||||||
|
show_user object.user
|
||||||
|
end
|
||||||
|
|
||||||
|
def amount
|
||||||
|
object.amount.to_f
|
||||||
|
end
|
||||||
|
end
|
|
@ -49,9 +49,10 @@ Doorkeeper.configure do
|
||||||
# https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes
|
# https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes
|
||||||
|
|
||||||
# default is a collection of read-only scopes
|
# default is a collection of read-only scopes
|
||||||
default_scopes 'config:user', 'user:read'
|
default_scopes 'config:user', 'finance:user', 'user:read'
|
||||||
|
|
||||||
optional_scopes 'config:read', 'config:write',
|
optional_scopes 'config:read', 'config:write',
|
||||||
|
'finance:read', 'finance:write',
|
||||||
'user:write',
|
'user:write',
|
||||||
'offline_access'
|
'offline_access'
|
||||||
|
|
||||||
|
|
|
@ -254,7 +254,10 @@ Foodsoft::Application.routes.draw do
|
||||||
|
|
||||||
namespace :user do
|
namespace :user do
|
||||||
root to: 'users#show'
|
root to: 'users#show'
|
||||||
|
resources :financial_transactions, only: [:index, :show]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
resources :financial_transactions, only: [:index, :show]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,11 @@ info:
|
||||||
version is recent enough).
|
version is recent enough).
|
||||||
This API description points to the default development url with the default
|
This API description points to the default development url with the default
|
||||||
Foodsoft scope - that would be [http://localhost:3000/f](http://localhost:3000/f).
|
Foodsoft scope - that would be [http://localhost:3000/f](http://localhost:3000/f).
|
||||||
|
|
||||||
|
You may find the search parameters for index endpoints lacking. They are not
|
||||||
|
documented here, because there are too many combinations. For now, you'll need
|
||||||
|
to resort to [Ransack](https://github.com/activerecord-hackery/ransack) and
|
||||||
|
looking at Foodsoft's `ransackable_*` model class methods.
|
||||||
externalDocs:
|
externalDocs:
|
||||||
description: General Foodsoft API documentation
|
description: General Foodsoft API documentation
|
||||||
url: https://github.com/foodcoops/foodsoft/blob/master/doc/API.md
|
url: https://github.com/foodcoops/foodsoft/blob/master/doc/API.md
|
||||||
|
@ -52,6 +57,129 @@ paths:
|
||||||
$ref: '#/definitions/Error403'
|
$ref: '#/definitions/Error403'
|
||||||
security:
|
security:
|
||||||
- foodsoft_auth: ['user:read', 'user:write']
|
- foodsoft_auth: ['user:read', 'user:write']
|
||||||
|
|
||||||
|
/user/financial_transactions:
|
||||||
|
get:
|
||||||
|
summary: financial transactions of the member's ordergroup
|
||||||
|
tags:
|
||||||
|
- 1. User
|
||||||
|
- 6. FinancialTransaction
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/parameters/page'
|
||||||
|
- $ref: '#/parameters/per_page'
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: success
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
financial_transactions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/FinancialTransaction'
|
||||||
|
meta:
|
||||||
|
$ref: '#/definitions/Meta'
|
||||||
|
401:
|
||||||
|
description: not logged-in
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error401'
|
||||||
|
403:
|
||||||
|
description: user has no ordergroup or missing scope
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error403'
|
||||||
|
security:
|
||||||
|
- foodsoft_auth: ['finance:user']
|
||||||
|
/user/financial_transactions/{id}:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/parameters/idInUrl'
|
||||||
|
get:
|
||||||
|
summary: find financial transaction by id
|
||||||
|
tags:
|
||||||
|
- 1. User
|
||||||
|
- 6. FinancialTransaction
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: success
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
financial_transaction:
|
||||||
|
$ref: '#/definitions/FinancialTransaction'
|
||||||
|
401:
|
||||||
|
description: not logged-in
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error401'
|
||||||
|
403:
|
||||||
|
description: user has no ordergroup or missing scope
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error403'
|
||||||
|
404:
|
||||||
|
description: not found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error404'
|
||||||
|
security:
|
||||||
|
- foodsoft_auth: ['finance:user']
|
||||||
|
|
||||||
|
/financial_transactions:
|
||||||
|
get:
|
||||||
|
summary: financial transactions
|
||||||
|
tags:
|
||||||
|
- 6. FinancialTransaction
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/parameters/page'
|
||||||
|
- $ref: '#/parameters/per_page'
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: success
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
financial_transactions:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/definitions/FinancialTransaction'
|
||||||
|
meta:
|
||||||
|
$ref: '#/definitions/Meta'
|
||||||
|
401:
|
||||||
|
description: not logged-in
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error401'
|
||||||
|
403:
|
||||||
|
description: missing scope or no permission
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error403'
|
||||||
|
security:
|
||||||
|
- foodsoft_auth: ['finance:read', 'finance:write']
|
||||||
|
/financial_transactions/{id}:
|
||||||
|
parameters:
|
||||||
|
- $ref: '#/parameters/idInUrl'
|
||||||
|
get:
|
||||||
|
summary: find financial transaction by id
|
||||||
|
tags:
|
||||||
|
- 6. FinancialTransaction
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: success
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
financial_transaction:
|
||||||
|
$ref: '#/definitions/FinancialTransaction'
|
||||||
|
401:
|
||||||
|
description: not logged-in
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error401'
|
||||||
|
403:
|
||||||
|
description: missing scope or no permission
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error403'
|
||||||
|
404:
|
||||||
|
description: not found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/Error404'
|
||||||
|
security:
|
||||||
|
- foodsoft_auth: ['finance:read', 'finance:write']
|
||||||
|
|
||||||
/config:
|
/config:
|
||||||
get:
|
get:
|
||||||
summary: configuration variables
|
summary: configuration variables
|
||||||
|
@ -92,6 +220,31 @@ paths:
|
||||||
security:
|
security:
|
||||||
- foodsoft_auth: []
|
- foodsoft_auth: []
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
# url parameters
|
||||||
|
idInUrl:
|
||||||
|
name: id
|
||||||
|
type: integer
|
||||||
|
in: path
|
||||||
|
minimum: 1
|
||||||
|
required: true
|
||||||
|
|
||||||
|
# query parameters
|
||||||
|
page:
|
||||||
|
name: page
|
||||||
|
type: integer
|
||||||
|
in: query
|
||||||
|
description: page number
|
||||||
|
minimum: 0
|
||||||
|
default: 0
|
||||||
|
per_page:
|
||||||
|
name: per_page
|
||||||
|
type: integer
|
||||||
|
in: query
|
||||||
|
description: items per page
|
||||||
|
minimum: 0
|
||||||
|
default: 20
|
||||||
|
|
||||||
definitions:
|
definitions:
|
||||||
# models
|
# models
|
||||||
User:
|
User:
|
||||||
|
@ -109,6 +262,30 @@ definitions:
|
||||||
type: string
|
type: string
|
||||||
description: language code
|
description: language code
|
||||||
required: ['id', 'name', 'email']
|
required: ['id', 'name', 'email']
|
||||||
|
|
||||||
|
FinancialTransaction:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: integer
|
||||||
|
user_id:
|
||||||
|
type: ['integer', 'null']
|
||||||
|
description: id of user who entered the transaction (may be <tt>null</tt> for deleted users or 0 for a system user)
|
||||||
|
user_name:
|
||||||
|
type: ['string', 'null']
|
||||||
|
description: name of user who entered the transaction (may be <tt>null</tt> or empty string for deleted users or system users)
|
||||||
|
amount:
|
||||||
|
type: number
|
||||||
|
description: amount credited (negative for a debit transaction)
|
||||||
|
note:
|
||||||
|
type: string
|
||||||
|
description: note entered with the transaction
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
description: when the transaction was entered
|
||||||
|
required: ['id', 'user_id', 'user_name', 'amount', 'note', 'created_at']
|
||||||
|
|
||||||
Navigation:
|
Navigation:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
|
@ -125,6 +302,24 @@ definitions:
|
||||||
required: ['name']
|
required: ['name']
|
||||||
minProperties: 2 # name+url or name+items
|
minProperties: 2 # name+url or name+items
|
||||||
|
|
||||||
|
# collection meta object in root of a response
|
||||||
|
Meta:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
page:
|
||||||
|
type: integer
|
||||||
|
description: page number of the returned collection
|
||||||
|
per_page:
|
||||||
|
type: integer
|
||||||
|
description: number of items per page
|
||||||
|
total_pages:
|
||||||
|
type: integer
|
||||||
|
description: total number of pages
|
||||||
|
total_count:
|
||||||
|
type: integer
|
||||||
|
description: total number of items in the collection
|
||||||
|
required: ['page', 'per_page', 'total_pages', 'total_count']
|
||||||
|
|
||||||
Error:
|
Error:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
@ -168,6 +363,9 @@ securityDefinitions:
|
||||||
config:user: reading Foodsoft configuration for regular users
|
config:user: reading Foodsoft configuration for regular users
|
||||||
config:read: reading Foodsoft configuration values
|
config:read: reading Foodsoft configuration values
|
||||||
config:write: reading and updating Foodsoft configuration values
|
config:write: reading and updating Foodsoft configuration values
|
||||||
|
finance:user: accessing your own financial transactions
|
||||||
|
finance:read: reading all financial transactions
|
||||||
|
finance:write: reading and creating financial transactions
|
||||||
user:read: reading your own user profile
|
user:read: reading your own user profile
|
||||||
user:write: reading and updating your own user profile
|
user:write: reading and updating your own user profile
|
||||||
offline_access: retain access after user has logged out
|
offline_access: retain access after user has logged out
|
||||||
|
|
|
@ -27,6 +27,32 @@ describe 'API v1', type: :apivore, order: :defined do
|
||||||
it_handles_invalid_token_and_scope(:get, '/user')
|
it_handles_invalid_token_and_scope(:get, '/user')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'financial_transactions' do
|
||||||
|
let(:api_scopes) { ['finance:user'] }
|
||||||
|
let(:other_user) { create :user, :ordergroup }
|
||||||
|
let!(:other_ft_1) { create :financial_transaction, ordergroup: other_user.ordergroup }
|
||||||
|
|
||||||
|
context 'without ordergroup' do
|
||||||
|
it { is_expected.to validate(:get, '/user/financial_transactions', 403, api_auth) }
|
||||||
|
it { is_expected.to validate(:get, '/user/financial_transactions/{id}', 403, api_auth({'id' => other_ft_1.id})) }
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with ordergroup' do
|
||||||
|
let(:user) { create :user, :ordergroup }
|
||||||
|
let!(:ft_1) { create :financial_transaction, ordergroup: user.ordergroup }
|
||||||
|
let!(:ft_2) { create :financial_transaction, ordergroup: user.ordergroup }
|
||||||
|
let!(:ft_3) { create :financial_transaction, ordergroup: user.ordergroup }
|
||||||
|
|
||||||
|
it { is_expected.to validate(:get, '/user/financial_transactions', 200, api_auth) }
|
||||||
|
it { is_expected.to validate(:get, '/user/financial_transactions/{id}', 200, api_auth({'id' => ft_2.id})) }
|
||||||
|
it { is_expected.to validate(:get, '/user/financial_transactions/{id}', 404, api_auth({'id' => other_ft_1.id})) }
|
||||||
|
it { is_expected.to validate(:get, '/user/financial_transactions/{id}', 404, api_auth({'id' => FinancialTransaction.last.id + 1})) }
|
||||||
|
|
||||||
|
it_handles_invalid_token_and_scope(:get, '/user/financial_transactions')
|
||||||
|
it_handles_invalid_token_and_scope(:get, '/user/financial_transactions/{id}', ->{ api_auth('id' => ft_2.id) })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
context 'config' do
|
context 'config' do
|
||||||
let(:api_scopes) { ['config:user'] }
|
let(:api_scopes) { ['config:user'] }
|
||||||
|
|
||||||
|
@ -42,6 +68,27 @@ describe 'API v1', type: :apivore, order: :defined do
|
||||||
|
|
||||||
it_handles_invalid_token(:get, '/navigation')
|
it_handles_invalid_token(:get, '/navigation')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
context 'financial_transactions' do
|
||||||
|
let(:api_scopes) { ['finance:read'] }
|
||||||
|
let(:user) { create(:user, :role_finance) }
|
||||||
|
let(:other_user) { create :user, :ordergroup }
|
||||||
|
let!(:ft_1) { create :financial_transaction, ordergroup: other_user.ordergroup }
|
||||||
|
let!(:ft_2) { create :financial_transaction, ordergroup: other_user.ordergroup }
|
||||||
|
|
||||||
|
it { is_expected.to validate(:get, '/financial_transactions', 200, api_auth) }
|
||||||
|
it { is_expected.to validate(:get, '/financial_transactions/{id}', 200, api_auth({'id' => ft_2.id})) }
|
||||||
|
it { is_expected.to validate(:get, '/financial_transactions/{id}', 404, api_auth({'id' => FinancialTransaction.last.id + 1})) }
|
||||||
|
|
||||||
|
context 'without role_finance' do
|
||||||
|
let(:user) { create(:user) }
|
||||||
|
it { is_expected.to validate(:get, '/financial_transactions', 403, api_auth) }
|
||||||
|
it { is_expected.to validate(:get, '/financial_transactions/{id}', 403, api_auth({'id' => ft_2.id})) }
|
||||||
|
end
|
||||||
|
|
||||||
|
it_handles_invalid_token_and_scope(:get, '/financial_transactions')
|
||||||
|
it_handles_invalid_token_and_scope(:get, '/financial_transactions/{id}', ->{ api_auth({'id' => ft_2.id}) })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# needs to be last context so it is always run at the end
|
# needs to be last context so it is always run at the end
|
||||||
|
|
15
spec/factories/financial_transaction.rb
Normal file
15
spec/factories/financial_transaction.rb
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
require 'factory_bot'
|
||||||
|
|
||||||
|
FactoryBot.define do
|
||||||
|
factory :financial_transaction do
|
||||||
|
user
|
||||||
|
ordergroup
|
||||||
|
amount { rand(-99_999.00..99_999.00) }
|
||||||
|
note { Faker::Lorem.sentence }
|
||||||
|
|
||||||
|
# This builds a new type and class by default, while for normal financial
|
||||||
|
# transactions we'd use the default. This, however, is the easiest way to
|
||||||
|
# get the factory going. If you want equal types, specify it explicitly.
|
||||||
|
financial_transaction_type
|
||||||
|
end
|
||||||
|
end
|
Loading…
Reference in a new issue