1
0
mirror of https://github.com/LaCasemate/fab-manager.git synced 2025-01-29 18:52:22 +01:00

Merge branch 'monthly-payment' into staging

This commit is contained in:
Sylvain 2020-12-29 13:14:19 +01:00
commit 3c5103103e
47 changed files with 633 additions and 291 deletions

3
.gitignore vendored
View File

@ -34,6 +34,9 @@
# PDF invoices
/invoices/*
# PDF Payment Schedules
/payment_schedules/*
# XLSX exports
/exports/*

View File

@ -3,6 +3,7 @@
## Next release
- Refactored theme builder to use scss files
- Updated stripe gem to 5.21.0
- Architecture documentation
- Fix a bug: unable to access embedded plan views
- Fix a bug: warning message overflow in credit wallet modal
- Fix a bug: when using a cash coupon, the amount shown in the statistics is invalid
@ -10,8 +11,10 @@
- [TODO DEPLOY] `rails fablab:maintenance:rebuild_stylesheet`
- [TODO DEPLOY] `rails fablab:stripe:set_product_id`
- [TODO DEPLOY] `rails fablab:setup:add_schedule_reference`
- [TODO DEPLOY] `rails db:seed`
- [TODO DEPLOY] add the `INTL_LOCALE` environment variable (see [doc/environment.md](doc/environment.md#INTL_LOCALE) for configuration details)
- [TODO DEPLOY] add the `INTL_CURRENCY` environment variable (see [doc/environment.md](doc/environment.md#INTL_CURRENCY) for configuration details)
- [TODO DEPLOY] `\curl -sSL https://raw.githubusercontent.com/sleede/fab-manager/master/scripts/mount-payment-schedules.sh | bash`
## v4.6.5 2020 December 07
- Fix a bug: unable to run the upgrade script with docker-compose >= v1.19

View File

@ -58,6 +58,7 @@ RUN apk del .build-deps && \
RUN mkdir -p /usr/src/app && \
mkdir -p /usr/src/app/config && \
mkdir -p /usr/src/app/invoices && \
mkdir -p /usr/src/app/payment_schedules && \
mkdir -p /usr/src/app/exports && \
mkdir -p /usr/src/app/imports && \
mkdir -p /usr/src/app/log && \
@ -72,6 +73,7 @@ COPY . /usr/src/app
# Volumes
VOLUME /usr/src/app/invoices
VOLUME /usr/src/app/payment_schedules
VOLUME /usr/src/app/exports
VOLUME /usr/src/app/imports
VOLUME /usr/src/app/public

View File

@ -1,4 +1,4 @@
web: bundle exec rails server puma -p $PORT
#web: bundle exec rails server puma -p $PORT
worker: bundle exec sidekiq -C ./config/sidekiq.yml
wp-client: bin/webpack-dev-server
wp-server: SERVER_BUNDLE_ONLY=yes bin/webpack --watch

View File

@ -1,3 +1,5 @@
# frozen_string_literal: true
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
# API Controller for resources of PaymentSchedule
class API::PaymentSchedulesController < API::ApiController
before_action :authenticate_user!
before_action :set_payment_schedule, only: %i[download]
def download
authorize @payment_schedule
send_file File.join(Rails.root, @payment_schedule.file), type: 'application/pdf', disposition: 'attachment'
end
private
def set_payment_schedule
@payment_schedule = PaymentSchedule.find(params[:id])
end
end

View File

@ -89,8 +89,8 @@ class API::PaymentsController < API::ApiController
end
render generate_payment_response(intent, res)
rescue Stripe::InvalidRequestError
render json: { error: 'no such setup intent' }, status: :unprocessable_entity
rescue Stripe::InvalidRequestError => e
render json: e, status: :unprocessable_entity
end
private
@ -105,7 +105,7 @@ class API::PaymentsController < API::ApiController
is_reserve = Reservations::Reserve.new(user_id, current_user.invoicing_profile.id)
.pay_and_save(@reservation,
payment_details: details,
payment_intent_id: intent.id,
intent_id: intent.id,
schedule: params[:cart_items][:reservation][:payment_schedule],
payment_method: params[:cart_items][:reservation][:payment_method])
if intent.class == Stripe::PaymentIntent
@ -135,7 +135,7 @@ class API::PaymentsController < API::ApiController
is_subscribe = Subscriptions::Subscribe.new(current_user.invoicing_profile.id, user_id)
.pay_and_save(@subscription,
payment_details: details,
payment_intent_id: intent.id,
intent_id: intent.id,
schedule: params[:cart_items][:subscription][:payment_schedule],
payment_method: 'stripe')
if intent.class == Stripe::PaymentIntent
@ -187,6 +187,11 @@ class API::PaymentsController < API::ApiController
slots = cart_items_params[:slots_attributes] || []
nb_places = cart_items_params[:nb_reserve_places]
tickets = cart_items_params[:tickets_attributes]
user_id = if current_user.admin? || current_user.manager?
params[:cart_items][:reservation][:user_id]
else
current_user.id
end
else
raise NotImplementedError unless params[:cart_items][:subscription]
@ -195,10 +200,15 @@ class API::PaymentsController < API::ApiController
slots = []
nb_places = nil
tickets = nil
user_id = if current_user.admin? || current_user.manager?
params[:cart_items][:subscription][:user_id]
else
current_user.id
end
end
price_details = Price.compute(false,
current_user,
User.find(user_id),
reservable,
slots,
plan_id: plan_id,

View File

@ -21,7 +21,8 @@ class OpenAPI::V1::InvoicesController < OpenAPI::V1::BaseController
end
private
def per_page
params[:per_page] || 20
end
def per_page
params[:per_page] || 20
end
end

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
# Raised when the Avoir cannot be generated from an existing Invoice
class CannotRefundError < StandardError
end

View File

@ -19,15 +19,21 @@ client.interceptors.response.use(function (response) {
});
function extractHumanReadableMessage(error: any): string {
if (error.match(/^<!DOCTYPE html>/)) {
// parse ruby error pages
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(error, 'text/html');
return htmlDoc.querySelector('h2').textContent;
if (typeof error === 'string') {
if (error.match(/^<!DOCTYPE html>/)) {
// parse ruby error pages
const parser = new DOMParser();
const htmlDoc = parser.parseFromString(error, 'text/html');
if (htmlDoc.querySelectorAll('h2').length > 2) {
return htmlDoc.querySelector('h2').textContent;
} else {
return htmlDoc.querySelector('h1').textContent;
}
}
return error;
}
if (typeof error === 'string') return error;
// parse Rails errors (as JSON)
let message = '';
if (error instanceof Object) {
// iterate through all the keys to build the message

View File

@ -11,6 +11,7 @@ interface StripeFormProps {
onSuccess: (result: SetupIntent|PaymentConfirmation|any) => void,
onError: (message: string) => void,
customer: User,
operator: User,
className?: string,
paymentSchedule?: boolean,
cartItems?: CartItems
@ -20,7 +21,7 @@ interface StripeFormProps {
* A form component to collect the credit card details and to create the payment method on Stripe.
* The form validation button must be created elsewhere, using the attribute form="stripe-form".
*/
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer }) => {
export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onError, children, className, paymentSchedule = false, cartItems, customer, operator }) => {
const { t } = useTranslation('shared');
@ -57,7 +58,16 @@ export const StripeForm: React.FC<StripeFormProps> = ({ onSubmit, onSuccess, onE
// we start by associating the payment method with the user
const { client_secret } = await PaymentAPI.setupIntent(customer.id);
const { setupIntent, error } = await stripe.confirmCardSetup(client_secret, {
payment_method: paymentMethod.id
payment_method: paymentMethod.id,
mandate_data: {
customer_acceptance: {
type: 'online',
online: {
ip_address: operator.ip_address,
user_agent: navigator.userAgent
}
}
}
})
if (error) {
onError(error.message);

View File

@ -178,6 +178,7 @@ const StripeModal: React.FC<StripeModalProps> = ({ isOpen, toggleModal, afterSuc
<StripeForm onSubmit={handleSubmit}
onSuccess={handleFormSuccess}
onError={handleFormError}
operator={currentUser}
className="stripe-form"
cartItems={cartItems}
customer={customer}

View File

@ -758,6 +758,9 @@ Application.Controllers.controller('ReserveMachineController', ['$scope', '$stat
*/
const refetchCalendar = function () {
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
setTimeout(() => {
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
}, 200);
};
// !!! MUST BE CALLED AT THE END of the controller

View File

@ -669,6 +669,9 @@ Application.Controllers.controller('ReserveSpaceController', ['$scope', '$stateP
*/
const refetchCalendar = function () {
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
setTimeout(() => {
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
}, 200);
};
// !!! MUST BE CALLED AT THE END of the controller

View File

@ -462,6 +462,9 @@ Application.Controllers.controller('ReserveTrainingController', ['$scope', '$sta
*/
const refetchCalendar = function () {
uiCalendarConfig.calendars.calendar.fullCalendar('refetchEvents');
setTimeout(() => {
uiCalendarConfig.calendars.calendar.fullCalendar('rerenderEvents');
}, 200);
};
// !!! MUST BE CALLED AT THE END of the controller

View File

@ -45,7 +45,7 @@ Application.Filters.filter('machineFilter', [function () {
};
}]);
Application.Filters.filter('projectMemberFilter', [ 'Auth', function (Auth) {
Application.Filters.filter('projectMemberFilter', ['Auth', function (Auth) {
return function (projects, selectedMember) {
if (!angular.isUndefined(projects) && angular.isDefined(selectedMember) && (projects != null) && (selectedMember != null) && (selectedMember !== '')) {
const filteredProject = [];
@ -165,7 +165,7 @@ Application.Filters.filter('simpleText', [function () {
};
}]);
Application.Filters.filter('toTrusted', [ '$sce', function ($sce) {
Application.Filters.filter('toTrusted', ['$sce', function ($sce) {
return text => $sce.trustAsHtml(text);
}]);
@ -178,7 +178,7 @@ Application.Filters.filter('humanReadablePlanName', ['$filter', function ($filte
if (plan != null) {
let result = plan.base_name;
if (groups != null) {
for (let group of Array.from(groups)) {
for (const group of Array.from(groups)) {
if (group.id === plan.group_id) {
if (short != null) {
result += ` - ${group.slug}`;
@ -318,7 +318,7 @@ Application.Filters.filter('toIsoDate', [function () {
};
}]);
Application.Filters.filter('booleanFormat', [ '_t', function (_t) {
Application.Filters.filter('booleanFormat', ['_t', function (_t) {
return function (boolean) {
if (((typeof boolean === 'boolean') && boolean) || ((typeof boolean === 'string') && (boolean === 'true'))) {
return _t('app.shared.buttons.yes');
@ -328,7 +328,7 @@ Application.Filters.filter('booleanFormat', [ '_t', function (_t) {
};
}]);
Application.Filters.filter('maxCount', [ '_t', function (_t) {
Application.Filters.filter('maxCount', ['_t', function (_t) {
return function (max) {
if ((typeof max === 'undefined') || (max === null) || ((typeof max === 'number') && (max === 0))) {
return _t('app.admin.pricing.unlimited');

View File

@ -15,6 +15,7 @@ export interface User {
role: UserRole
name: string,
need_completion: boolean,
ip_address: string,
profile: {
id: number,
first_name: string,

View File

@ -12,7 +12,7 @@ class Avoir < Invoice
attr_accessor :invoice_items_ids
def generate_reference
self.reference = InvoiceReferenceService.generate_reference(self, date: created_at, avoir: true)
super(created_at)
end
def expire_subscription

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
# SuperClass for models that are secured by chained footprints.
class Footprintable < ApplicationRecord
self.abstract_class = true
def self.columns_out_of_footprint
[]
end
def check_footprint
footprint == compute_footprint
end
def chain_record(sort_on = 'id')
self.footprint = compute_footprint
save!
FootprintDebug.create!(
footprint: footprint,
data: FootprintService.footprint_data(self.class, self, sort_on),
klass: self.class.name
)
end
def debug_footprint
FootprintService.debug_footprint(self.class, self)
end
protected
def compute_footprint(sort_on = 'id')
FootprintService.compute_footprint(self.class, self, sort_on)
end
end

View File

@ -3,28 +3,14 @@
require 'checksum'
# Setting values, kept history of modifications
class HistoryValue < ApplicationRecord
class HistoryValue < Footprintable
belongs_to :setting
belongs_to :invoicing_profile
after_create :chain_record
def chain_record
self.footprint = compute_footprint
save!
FootprintDebug.create!(
footprint: footprint,
data: FootprintService.footprint_data(HistoryValue, self, 'created_at'),
klass: HistoryValue.name
)
end
def check_footprint
footprint == compute_footprint
end
def debug_footprint
FootprintService.debug_footprint(HistoryValue, self)
super('created_at')
end
def user
@ -34,6 +20,6 @@ class HistoryValue < ApplicationRecord
private
def compute_footprint
FootprintService.compute_footprint(HistoryValue, self, 'created_at')
super('created_at')
end
end

View File

@ -4,7 +4,7 @@ require 'checksum'
# Invoice correspond to a single purchase made by an user. This purchase may
# include reservation(s) and/or a subscription
class Invoice < ApplicationRecord
class Invoice < PaymentDocument
include NotifyWith::NotificationAttachedObject
require 'fileutils'
scope :only_invoice, -> { where(type: nil) }
@ -41,30 +41,21 @@ class Invoice < ApplicationRecord
end
def filename
prefix = Setting.find_by(name: 'invoice_prefix').history_values.order(created_at: :desc).where('created_at <= ?', created_at).limit(1).first
prefix ||= if created_at < Setting.find_by(name: 'invoice_prefix').history_values.order(created_at: :asc).limit(1).first.created_at
Setting.find_by(name: 'invoice_prefix').history_values.order(created_at: :asc).limit(1).first
prefix = Setting.find_by(name: 'invoice_prefix').value_at(created_at)
prefix ||= if created_at < Setting.find_by(name: 'invoice_prefix').first_update
Setting.find_by(name: 'invoice_prefix').first_value
else
Setting.find_by(name: 'invoice_prefix')..history_values.order(created_at: :desc).limit(1).first
Setting.get('invoice_prefix')
end
"#{prefix.value}-#{id}_#{created_at.strftime('%d%m%Y')}.pdf"
"#{prefix}-#{id}_#{created_at.strftime('%d%m%Y')}.pdf"
end
def user
invoicing_profile.user
end
def generate_reference
self.reference = InvoiceReferenceService.generate_reference(self)
end
def update_reference
generate_reference
save
end
def order_number
InvoiceReferenceService.generate_order_number(self)
PaymentDocumentService.generate_order_number(self)
end
# for debug & used by rake task "fablab:maintenance:regenerate_invoices"
@ -74,7 +65,7 @@ class Invoice < ApplicationRecord
end
def build_avoir(attrs = {})
raise Exception if refunded? == true || prevent_refund?
raise CannotRefundError if refunded? == true || prevent_refund?
avoir = Avoir.new(dup.attributes)
avoir.type = 'Avoir'
@ -168,35 +159,10 @@ class Invoice < ApplicationRecord
res
end
def add_environment
self.environment = Rails.env
end
def chain_record
self.footprint = compute_footprint
save!
FootprintDebug.create!(
footprint: footprint,
data: FootprintService.footprint_data(Invoice, self),
klass: Invoice.name
)
end
def check_footprint
invoice_items.map(&:check_footprint).all? && footprint == compute_footprint
end
def debug_footprint
FootprintService.debug_footprint(Invoice, self)
end
def set_wallet_transaction(amount, transaction_id)
raise InvalidFootprintError unless check_footprint
update_columns(wallet_amount: amount, wallet_transaction_id: transaction_id)
chain_record
end
def paid_with_stripe?
stp_payment_intent_id? || stp_invoice_id? || payment_method == 'stripe'
end
@ -213,10 +179,6 @@ class Invoice < ApplicationRecord
InvoiceWorker.perform_async(id, user&.subscription&.expired_at)
end
def compute_footprint
FootprintService.compute_footprint(Invoice, self)
end
def log_changes
return if Rails.env.test?
return unless changed?

View File

@ -3,7 +3,7 @@
require 'checksum'
# A single line inside an invoice. Can be a subscription or a reservation
class InvoiceItem < ApplicationRecord
class InvoiceItem < Footprintable
belongs_to :invoice
belongs_to :subscription
@ -12,24 +12,6 @@ class InvoiceItem < ApplicationRecord
after_create :chain_record
after_update :log_changes
def chain_record
self.footprint = compute_footprint
save!
FootprintDebug.create!(
footprint: footprint,
data: FootprintService.footprint_data(InvoiceItem, self),
klass: InvoiceItem.name
)
end
def check_footprint
footprint == compute_footprint
end
def debug_footprint
FootprintService.debug_footprint(InvoiceItem, self)
end
def amount_after_coupon
# deduct coupon discount
coupon_service = CouponService.new
@ -51,10 +33,6 @@ class InvoiceItem < ApplicationRecord
private
def compute_footprint
FootprintService.compute_footprint(InvoiceItem, self)
end
def log_changes
return if Rails.env.test?
return unless changed?

View File

@ -0,0 +1,26 @@
# frozen_string_literal: true
# SuperClass for models that provides legal PDF documents concerning sales
class PaymentDocument < Footprintable
self.abstract_class = true
def generate_reference(date = DateTime.current)
self.reference = PaymentDocumentService.generate_reference(self, date: date)
end
def update_reference
generate_reference
save
end
def add_environment
self.environment = Rails.env
end
def set_wallet_transaction(amount, transaction_id)
raise InvalidFootprintError unless check_footprint
update_columns(wallet_amount: amount, wallet_transaction_id: transaction_id)
chain_record
end
end

View File

@ -2,7 +2,9 @@
# PaymentSchedule is a way for members to pay something (especially a Subscription) with multiple payment,
# staged on a long period rather than with a single payment
class PaymentSchedule < ApplicationRecord
class PaymentSchedule < PaymentDocument
require 'fileutils'
belongs_to :scheduled, polymorphic: true
belongs_to :wallet_transaction
belongs_to :coupon
@ -16,6 +18,25 @@ class PaymentSchedule < ApplicationRecord
before_create :add_environment
after_create :update_reference, :chain_record
after_commit :generate_and_send_document, on: [:create], if: :persisted?
def file
dir = "payment_schedules/#{invoicing_profile.id}"
# create directories if they doesn't exists (payment_schedules & invoicing_profile_id)
FileUtils.mkdir_p dir
"#{dir}/#{filename}"
end
def filename
prefix = Setting.find_by(name: 'payment_schedule_prefix').value_at(created_at)
prefix ||= if created_at < Setting.find_by(name: 'payment_schedule_prefix').first_update
Setting.find_by(name: 'payment_schedule_prefix').first_value
else
Setting.get('payment_schedule_prefix')
end
"#{prefix}-#{id}_#{created_at.strftime('%d%m%Y')}.pdf"
end
##
# This is useful to check the first item because its amount may be different from the others
@ -24,37 +45,19 @@ class PaymentSchedule < ApplicationRecord
payment_schedule_items.order(due_date: :asc)
end
def add_environment
self.environment = Rails.env
end
def update_reference
self.reference = InvoiceReferenceService.generate_reference(self, payment_schedule: true)
save
end
def set_wallet_transaction(amount, transaction_id)
raise InvalidFootprintError unless check_footprint
update_columns(wallet_amount: amount, wallet_transaction_id: transaction_id)
chain_record
end
def chain_record
self.footprint = compute_footprint
save!
FootprintDebug.create!(
footprint: footprint,
data: FootprintService.footprint_data(PaymentSchedule, self),
klass: PaymentSchedule.name
)
end
def compute_footprint
FootprintService.compute_footprint(PaymentSchedule, self)
end
def check_footprint
payment_schedule_items.map(&:check_footprint).all? && footprint == compute_footprint
end
private
def generate_and_send_document
return unless Setting.get('invoicing_module')
unless Rails.env.test?
puts "Creating an PaymentScheduleWorker job to generate the following payment schedule: id(#{id}), scheduled_id(#{scheduled_id}), " \
"scheduled_type(#{scheduled_type}), user_id(#{invoicing_profile.user_id})"
end
PaymentScheduleWorker.perform_async(id)
end
end

View File

@ -1,26 +1,12 @@
# frozen_string_literal: true
# Represents a due date and the associated amount for a PaymentSchedule
class PaymentScheduleItem < ApplicationRecord
class PaymentScheduleItem < Footprintable
belongs_to :payment_schedule
belongs_to :invoice
after_create :chain_record
def chain_record
self.footprint = compute_footprint
save!
FootprintDebug.create!(
footprint: footprint,
data: FootprintService.footprint_data(PaymentScheduleItem, self),
klass: PaymentScheduleItem.name
)
end
def check_footprint
footprint == compute_footprint
end
def compute_footprint
FootprintService.compute_footprint(PaymentScheduleItem, self)
def self.columns_out_of_footprint
%w[invoice_id]
end
end

View File

@ -106,16 +106,32 @@ class Setting < ApplicationRecord
confirmation_required
wallet_module
statistics_module
upcoming_events_shown] }
upcoming_events_shown
payment_schedule_prefix] }
# WARNING: when adding a new key, you may also want to add it in app/policies/setting_policy.rb#public_whitelist
def value
last_value = history_values.order(HistoryValue.arel_table['created_at'].desc).first
last_value = history_values.order(HistoryValue.arel_table['created_at'].desc).limit(1).first
last_value&.value
end
def value_at(date)
val = history_values.order(HistoryValue.arel_table['created_at'].desc).where('created_at <= ?', date).limit(1).first
val&.value
end
def first_update
first_value = history_values.order(HistoryValue.arel_table['created_at'].asc).limit(1).first
first_value&.created_at
end
def first_value
first_value = history_values.order(HistoryValue.arel_table['created_at'].asc).limit(1).first
first_value&.value
end
def last_update
last_value = history_values.order(HistoryValue.arel_table['created_at'].desc).first
last_value = history_values.order(HistoryValue.arel_table['created_at'].desc).limit(1).first
last_value&.created_at
end

View File

@ -29,7 +29,7 @@ class FootprintService
# Return an ordered array of the columns used in the footprint computation
# @param klass Invoice|InvoiceItem|HistoryValue
def self.footprint_columns(klass)
klass.columns.map(&:name).delete_if { |c| %w[footprint updated_at].include? c }
klass.columns.map(&:name).delete_if { |c| %w[footprint updated_at].concat(klass.columns_out_of_footprint).include? c }
end
# Logs a debugging message to help finding why a footprint is invalid
@ -38,7 +38,7 @@ class FootprintService
def self.debug_footprint(klass, item)
columns = FootprintService.footprint_columns(klass)
current = FootprintService.footprint_data(klass, item)
saved = FootprintDebug.find_by(footprint: item.footprint, klass: klass)
saved = FootprintDebug.find_by(footprint: item.footprint, klass: klass.name)
puts "Debug footprint for #{klass} [ id: #{item.id} ]"
puts '-----------------------------------------'
puts "columns: [ #{columns.join(', ')} ]"

View File

@ -1,15 +1,15 @@
# frozen_string_literal: true
# Provides methods to generate Invoice or PaymentSchedule references
class InvoiceReferenceService
# Provides methods to generate Invoice, Avoir or PaymentSchedule references
class PaymentDocumentService
class << self
def generate_reference(invoice, date: DateTime.current, avoir: false, payment_schedule: false)
def generate_reference(document, date: DateTime.current)
pattern = Setting.get('invoice_reference')
reference = replace_invoice_number_pattern(pattern)
reference = replace_date_pattern(reference, date)
if avoir
if document.class == Avoir
# information about refund/avoir (R[text])
reference.gsub!(/R\[([^\]]+)\]/, '\1')
@ -17,16 +17,16 @@ class InvoiceReferenceService
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
# remove information about payment schedule (S[text])
reference.gsub!(/S\[([^\]]+)\]/, ''.to_s)
elsif payment_schedule
elsif document.class == PaymentSchedule
# information about payment schedule
reference.gsub!(/S\[([^\]]+)\]/, '\1')
# remove information about online selling (X[text])
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
# remove information about refunds (R[text])
reference.gsub!(/R\[([^\]]+)\]/, ''.to_s)
else
elsif document.class == Invoice
# information about online selling (X[text])
if invoice.paid_with_stripe?
if document.paid_with_stripe?
reference.gsub!(/X\[([^\]]+)\]/, '\1')
else
reference.gsub!(/X\[([^\]]+)\]/, ''.to_s)
@ -36,6 +36,8 @@ class InvoiceReferenceService
reference.gsub!(/R\[([^\]]+)\]/, ''.to_s)
# remove information about payment schedule (S[text])
reference.gsub!(/S\[([^\]]+)\]/, ''.to_s)
else
raise TypeError
end
reference
@ -44,7 +46,7 @@ class InvoiceReferenceService
def generate_order_number(invoice)
pattern = Setting.get('invoice_order-nb')
# global invoice number (nn..nn)
# global document number (nn..nn)
reference = pattern.gsub(/n+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('global'), match.to_s.length)
end
@ -119,21 +121,21 @@ class InvoiceReferenceService
end
##
# Replace the invoice number elements in the provided pattern with counts from the database
# Replace the document number elements in the provided pattern with counts from the database
# @param reference {string}
##
def replace_invoice_number_pattern(reference)
copy = reference.dup
# invoice number per year (yy..yy)
# document number per year (yy..yy)
copy.gsub!(/y+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('year'), match.to_s.length)
end
# invoice number per month (mm..mm)
# document number per month (mm..mm)
copy.gsub!(/m+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('month'), match.to_s.length)
end
# invoice number per day (dd..dd)
# document number per day (dd..dd)
copy.gsub!(/d+(?![^\[]*\])/) do |match|
pad_and_truncate(number_of_invoices('day'), match.to_s.length)
end

View File

@ -46,7 +46,7 @@ class PaymentScheduleService
{ payment_schedule: ps, items: items }
end
def create(subscription, total, coupon: nil, operator: nil, payment_method: nil, reservation: nil, user: nil)
def create(subscription, total, coupon: nil, operator: nil, payment_method: nil, reservation: nil, user: nil, setup_intent_id: nil)
subscription = reservation.generate_subscription if !subscription && reservation.plan_id
schedule = compute(subscription.plan, total, coupon)
@ -55,6 +55,7 @@ class PaymentScheduleService
ps.scheduled = reservation || subscription
ps.payment_method = payment_method
ps.stp_setup_intent_id = setup_intent_id
ps.operator_profile = operator.invoicing_profile
ps.invoicing_profile = user.invoicing_profile
ps.save!
@ -63,7 +64,7 @@ class PaymentScheduleService
item.save!
end
StripeWorker.perform_async(:create_stripe_subscription, ps.id, reservation&.reservable&.stp_product_id) if payment_method == 'stripe'
StripeService.create_stripe_subscription(ps.id, subscription, reservation&.reservable&.stp_product_id, setup_intent_id) if payment_method == 'stripe'
ps
end
end

View File

@ -13,24 +13,28 @@ class Reservations::Reserve
# Confirm the payment of the given reservation, generate the associated documents and save teh record into
# the database.
##
def pay_and_save(reservation, payment_details: nil, payment_intent_id: nil, schedule: false, payment_method: nil)
def pay_and_save(reservation, payment_details: nil, intent_id: nil, schedule: false, payment_method: nil)
user = User.find(user_id)
reservation.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
reservation.pre_check
payment = if schedule
generate_schedule(reservation: reservation,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon])
else
generate_invoice(reservation, operator_profile_id, payment_details, payment_intent_id)
end
payment.save
WalletService.debit_user_wallet(payment, user, reservation)
reservation.post_save
ActiveRecord::Base.transaction do
reservation.pre_check
payment = if schedule
generate_schedule(reservation: reservation,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon],
setup_intent_id: intent_id)
else
generate_invoice(reservation, operator_profile_id, payment_details, intent_id)
end
payment.save
reservation.save
WalletService.debit_user_wallet(payment, user, reservation)
reservation.post_save
end
true
end
@ -39,7 +43,8 @@ class Reservations::Reserve
##
# Generate the invoice for the given reservation+subscription
##
def generate_schedule(reservation: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon_code: nil)
def generate_schedule(reservation: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon_code: nil,
setup_intent_id: nil)
operator = InvoicingProfile.find(operator_profile_id)&.user
coupon = Coupon.find_by(code: coupon_code) unless coupon_code.nil?
@ -50,7 +55,8 @@ class Reservations::Reserve
operator: operator,
payment_method: payment_method,
user: user,
reservation: reservation
reservation: reservation,
setup_intent_id: setup_intent_id
)
end

View File

@ -0,0 +1,75 @@
# frozen_string_literal: true
# Helpers and utilities for interactions with the Stripe payment gateway
class StripeService
class << self
# Create the provided PaymentSchedule on Stripe, using the Subscription API
def create_stripe_subscription(payment_schedule_id, subscription, reservable_stp_id, setup_intent_id)
stripe_key = Setting.get('stripe_secret_key')
payment_schedule = PaymentSchedule.find(payment_schedule_id)
first_item = payment_schedule.ordered_items.first
# setup intent (associates the customer and the payment method)
intent = Stripe::SetupIntent.retrieve(setup_intent_id, api_key: stripe_key)
# subscription (recurring price)
price = create_price(first_item.details['recurring'],
subscription.plan.stp_product_id,
nil, monthly: true)
# other items (not recurring)
items = subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
stp_subscription = Stripe::Subscription.create({
customer: payment_schedule.invoicing_profile.user.stp_customer_id,
cancel_at: subscription.expiration_date.to_i,
promotion_code: payment_schedule.coupon&.code,
add_invoice_items: items,
items: [
{ price: price[:id] }
],
default_payment_method: intent[:payment_method]
}, { api_key: stripe_key })
payment_schedule.update_attributes(stp_subscription_id: stp_subscription.id)
end
private
def subscription_invoice_items(payment_schedule, subscription, first_item, reservable_stp_id)
second_item = payment_schedule.ordered_items[1]
items = []
if first_item.amount != second_item.amount
unless first_item.details['adjustment']&.zero?
# adjustment: when dividing the price of the plan / months, sometimes it forces us to round the amount per month.
# The difference is invoiced here
p1 = create_price(first_item.details['adjustment'],
subscription.plan.stp_product_id,
"Price adjustment for payment schedule #{payment_schedule.id}")
items.push(price: p1[:id])
end
unless first_item.details['other_items']&.zero?
# when taking a subscription at the same time of a reservation (space, machine or training), the amount of the
# reservation is invoiced here.
p2 = create_price(first_item.details['other_items'],
reservable_stp_id,
"Reservations for payment schedule #{payment_schedule.id}")
items.push(price: p2[:id])
end
end
items
end
def create_price(amount, stp_product_id, name, monthly: false)
params = {
unit_amount: amount,
currency: Setting.get('stripe_currency'),
product: stp_product_id,
nickname: name
}
params[:recurring] = { interval: 'month', interval_count: 1 } if monthly
Stripe::Price.create(params, api_key: Setting.get('stripe_secret_key'))
end
end
end

View File

@ -12,29 +12,32 @@ class Subscriptions::Subscribe
##
# @param subscription {Subscription}
# @param payment_details {Hash} as generated by Price.compute
# @param payment_intent_id {String} from stripe
# @param intent_id {String} from stripe
# @param schedule {Boolean}
# @param payment_method {String} only for schedules
##
def pay_and_save(subscription, payment_details: nil, payment_intent_id: nil, schedule: false, payment_method: nil)
def pay_and_save(subscription, payment_details: nil, intent_id: nil, schedule: false, payment_method: nil)
return false if user_id.nil?
subscription.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
subscription.init_save
user = User.find(user_id)
subscription.statistic_profile_id = StatisticProfile.find_by(user_id: user_id).id
payment = if schedule
generate_schedule(subscription: subscription,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon])
else
generate_invoice(subscription, operator_profile_id, payment_details, payment_intent_id)
end
payment.save
WalletService.debit_user_wallet(payment, user, subscription)
ActiveRecord::Base.transaction do
subscription.init_save
payment = if schedule
generate_schedule(subscription: subscription,
total: payment_details[:before_coupon],
operator_profile_id: operator_profile_id,
user: user,
payment_method: payment_method,
coupon_code: payment_details[:coupon],
setup_intent_id: intent_id)
else
generate_invoice(subscription, operator_profile_id, payment_details, intent_id)
end
payment.save
WalletService.debit_user_wallet(payment, user, subscription)
end
true
end
@ -54,7 +57,8 @@ class Subscriptions::Subscribe
total: details[:before_coupon],
operator_profile_id: operator_profile_id,
user: new_sub.user,
payment_method: schedule.payment_method)
payment_method: schedule.payment_method,
setup_intent_id: schedule.stp_setup_intent_id)
else
generate_invoice(subscription, operator_profile_id, details)
end
@ -70,7 +74,8 @@ class Subscriptions::Subscribe
##
# Generate the invoice for the given subscription
##
def generate_schedule(subscription: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon_code: nil)
def generate_schedule(subscription: nil, total: nil, operator_profile_id: nil, user: nil, payment_method: nil, coupon_code: nil,
setup_intent_id: nil)
operator = InvoicingProfile.find(operator_profile_id)&.user
coupon = Coupon.find_by(code: coupon_code) unless coupon_code.nil?
@ -80,7 +85,8 @@ class Subscriptions::Subscribe
coupon: coupon,
operator: operator,
payment_method: payment_method,
user: user
user: user,
setup_intent_id: setup_intent_id
)
end

View File

@ -4,6 +4,7 @@ json.extract! member, :id, :username, :email, :group_id
json.role member.roles.first.name
json.name member.profile.full_name
json.need_completion member.need_completion?
json.ip_address member.current_sign_in_ip.to_s
json.profile do
json.id member.profile.id

View File

@ -1,3 +1,6 @@
# frozen_string_literal: true
# Generates the PDF Document associated with the provided invoice, and send it to the customer
class InvoiceWorker
include Sidekiq::Worker

View File

@ -0,0 +1,24 @@
# frozen_string_literal: true
# Periodically checks if a PaymentScheduleItem cames to its due date.
# If this is the case
class PaymentScheduleItemWorker
include Sidekiq::Worker
def perform
PaymentScheduleItem.where(due_date: [DateTime.current.at_beginning_of_day, DateTime.current.end_of_day], state: 'new').each do |psi|
# the following depends on the payment method (stripe/check)
if psi.payment_schedule.payment_method == 'stripe'
# TODO, if stripe:
# - verify the payment was successful
# - if not, alert the admins
# - if succeeded, generate the invoice
else
# TODO, if check:
# - alert the admins and the user that it is time to bank the check
# - generate the invoice
end
# TODO, finally, in any cases, update the psi.state field according to the new status
end
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
# Generates the PDF Document associated with the provided payment schedule, and send it to the customer
# If this is the case
class PaymentScheduleWorker
include Sidekiq::Worker
def perform(payment_schedule_id)
# generate a payment schedule document
ps = PaymentSchedule.find(payment_schedule_id)
pdf = ::PDF::PaymentSchedule.new(ps).render # TODO, create ::PDF::PaymentSchedule
# save the file on the disk
File.binwrite(ps.file, pdf)
# notify user, send schedule document by email
NotificationCenter.call type: 'notify_user_when_invoice_ready', # TODO, create a more appropriate notification type
receiver: ps.user,
attached_object: ps
end
end

View File

@ -64,59 +64,7 @@ class StripeWorker
}, { api_key: Setting.get('stripe_secret_key') }
)
object.update_attributes(stp_product_id: product.id)
puts "Stripe product was created for the #{class_name} \##{id}"
end
end
def create_stripe_subscription(payment_schedule_id, reservable_stp_id)
payment_schedule = PaymentSchedule.find(payment_schedule_id)
first_item = payment_schedule.ordered_items.first
second_item = payment_schedule.ordered_items[1]
items = []
if first_item.amount != second_item.amount
unless first_item.details['adjustment']&.zero?
# adjustment: when dividing the price of the plan / months, sometimes it forces us to round the amount per month.
# The difference is invoiced here
p1 = Stripe::Price.create({
unit_amount: first_item.details['adjustment'],
currency: Setting.get('stripe_currency'),
product: payment_schedule.scheduled.plan.stp_product_id,
nickname: "Price adjustment for payment schedule #{payment_schedule_id}"
}, { api_key: Setting.get('stripe_secret_key') })
items.push(price: p1[:id])
end
unless first_item.details['other_items']&.zero?
# when taking a subscription at the same time of a reservation (space, machine or training), the amount of the
# reservation is invoiced here.
p2 = Stripe::Price.create({
unit_amount: first_item.details['other_items'],
currency: Setting.get('stripe_currency'),
product: reservable_stp_id,
nickname: "Reservations for payment schedule #{payment_schedule_id}"
}, { api_key: Setting.get('stripe_secret_key') })
items.push(price: p2[:id])
end
end
# subscription (recurring price)
price = Stripe::Price.create({
unit_amount: first_item.details['recurring'],
currency: Setting.get('stripe_currency'),
recurring: { interval: 'month', interval_count: 1 },
product: payment_schedule.scheduled.plan.stp_product_id
},
{ api_key: Setting.get('stripe_secret_key') })
stp_subscription = Stripe::Subscription.create({
customer: payment_schedule.invoicing_profile.user.stp_customer_id,
cancel_at: payment_schedule.scheduled.expiration_date.to_i,
promotion_code: payment_schedule.coupon&.code,
add_invoice_items: items,
items: [
{ price: price[:id] }
]
}, { api_key: Setting.get('stripe_secret_key') })
payment_schedule.update_attributes(stp_subscription_id: stp_subscription.id)
end
end

View File

@ -1,4 +1,6 @@
# frozen_string_literal: true
# This file is used by Rack-based servers to start the application.
require ::File.expand_path('../config/environment', __FILE__)
require ::File.expand_path('../config/environment', __FILE__)
run Rails.application

View File

@ -8,6 +8,7 @@ class CreatePaymentSchedules < ActiveRecord::Migration[5.2]
t.references :scheduled, polymorphic: true
t.integer :total
t.string :stp_subscription_id
t.string :stp_setup_intent_id
t.string :reference
t.string :payment_method
t.integer :wallet_amount

View File

@ -6,6 +6,7 @@ class CreatePaymentScheduleItems < ActiveRecord::Migration[5.2]
create_table :payment_schedule_items do |t|
t.integer :amount
t.datetime :due_date
t.string :state, default: 'new'
t.jsonb :details, default: '{}'
t.belongs_to :payment_schedule, foreign_key: true
t.belongs_to :invoice, foreign_key: true

View File

@ -883,6 +883,8 @@ Setting.set('stripe_currency', 'EUR') unless Setting.find_by(name: 'stripe_curre
Setting.set('invoice_prefix', 'FabManager_invoice') unless Setting.find_by(name: 'invoice_prefix').try(:value)
Setting.set('payment_schedule_prefix', 'FabManager_paymentSchedule') unless Setting.find_by(name: 'payment_schedule_prefix').try(:value)
Setting.set('confirmation_required', false) unless Setting.find_by(name: 'confirmation_required').try(:value)
Setting.set('wallet_module', true) unless Setting.find_by(name: 'wallet_module').try(:value)

View File

@ -78,11 +78,11 @@ CREATE FUNCTION public.fill_search_vector_for_project() RETURNS trigger
select string_agg(description, ' ') as content into step_description from project_steps where project_id = new.id;
new.search_vector :=
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(new.name, ''))), 'A') ||
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(new.tags, ''))), 'B') ||
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(new.description, ''))), 'D') ||
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(step_title.title, ''))), 'C') ||
setweight(to_tsvector('pg_catalog.french', unaccent(coalesce(step_description.content, ''))), 'D');
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(new.name, ''))), 'A') ||
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(new.tags, ''))), 'B') ||
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(new.description, ''))), 'D') ||
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(step_title.title, ''))), 'C') ||
setweight(to_tsvector('pg_catalog.simple', unaccent(coalesce(step_description.content, ''))), 'D');
return new;
end
@ -108,8 +108,8 @@ SET default_tablespace = '';
CREATE TABLE public.abuses (
id integer NOT NULL,
signaled_type character varying,
signaled_id integer,
signaled_type character varying,
first_name character varying,
last_name character varying,
email character varying,
@ -187,8 +187,8 @@ CREATE TABLE public.addresses (
locality character varying,
country character varying,
postal_code character varying,
placeable_type character varying,
placeable_id integer,
placeable_type character varying,
created_at timestamp without time zone,
updated_at timestamp without time zone
);
@ -263,8 +263,8 @@ CREATE TABLE public.ar_internal_metadata (
CREATE TABLE public.assets (
id integer NOT NULL,
viewable_type character varying,
viewable_id integer,
viewable_type character varying,
attachment character varying,
type character varying,
created_at timestamp without time zone,
@ -504,8 +504,8 @@ ALTER SEQUENCE public.coupons_id_seq OWNED BY public.coupons.id;
CREATE TABLE public.credits (
id integer NOT NULL,
creditable_type character varying,
creditable_id integer,
creditable_type character varying,
plan_id integer,
hours integer,
created_at timestamp without time zone,
@ -1046,8 +1046,8 @@ ALTER SEQUENCE public.invoice_items_id_seq OWNED BY public.invoice_items.id;
CREATE TABLE public.invoices (
id integer NOT NULL,
invoiced_type character varying,
invoiced_id integer,
invoiced_type character varying,
stp_invoice_id character varying,
total integer,
created_at timestamp without time zone,
@ -1227,15 +1227,15 @@ ALTER SEQUENCE public.machines_id_seq OWNED BY public.machines.id;
CREATE TABLE public.notifications (
id integer NOT NULL,
receiver_id integer,
attached_object_type character varying,
attached_object_id integer,
attached_object_type character varying,
notification_type_id integer,
is_read boolean DEFAULT false,
created_at timestamp without time zone,
updated_at timestamp without time zone,
receiver_type character varying,
is_send boolean DEFAULT false,
meta_data jsonb DEFAULT '"{}"'::jsonb
meta_data jsonb DEFAULT '{}'::jsonb
);
@ -1470,6 +1470,7 @@ CREATE TABLE public.payment_schedule_items (
id bigint NOT NULL,
amount integer,
due_date timestamp without time zone,
state character varying DEFAULT 'new'::character varying,
details jsonb DEFAULT '"{}"'::jsonb,
payment_schedule_id bigint,
invoice_id bigint,
@ -1508,6 +1509,7 @@ CREATE TABLE public.payment_schedules (
scheduled_id bigint,
total integer,
stp_subscription_id character varying,
stp_setup_intent_id character varying,
reference character varying,
payment_method character varying,
wallet_amount integer,
@ -1657,8 +1659,8 @@ CREATE TABLE public.prices (
id integer NOT NULL,
group_id integer,
plan_id integer,
priceable_type character varying,
priceable_id integer,
priceable_type character varying,
amount integer,
created_at timestamp without time zone NOT NULL,
updated_at timestamp without time zone NOT NULL
@ -1973,8 +1975,8 @@ CREATE TABLE public.reservations (
message text,
created_at timestamp without time zone,
updated_at timestamp without time zone,
reservable_type character varying,
reservable_id integer,
reservable_type character varying,
nb_reserve_places integer,
statistic_profile_id integer
);
@ -2006,8 +2008,8 @@ ALTER SEQUENCE public.reservations_id_seq OWNED BY public.reservations.id;
CREATE TABLE public.roles (
id integer NOT NULL,
name character varying,
resource_type character varying,
resource_id integer,
resource_type character varying,
created_at timestamp without time zone,
updated_at timestamp without time zone
);
@ -2943,8 +2945,8 @@ CREATE TABLE public.users_roles (
CREATE TABLE public.wallet_transactions (
id integer NOT NULL,
wallet_id integer,
transactable_type character varying,
transactable_id integer,
transactable_type character varying,
transaction_type character varying,
amount integer,
created_at timestamp without time zone NOT NULL,
@ -4033,14 +4035,6 @@ ALTER TABLE ONLY public.roles
ADD CONSTRAINT roles_pkey PRIMARY KEY (id);
--
-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
ALTER TABLE ONLY public.schema_migrations
ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version);
--
-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: -
--
@ -5105,6 +5099,29 @@ CREATE INDEX profiles_lower_unaccent_last_name_trgm_idx ON public.profiles USING
CREATE INDEX projects_search_vector_idx ON public.projects USING gin (search_vector);
--
-- Name: unique_schema_migrations; Type: INDEX; Schema: public; Owner: -
--
CREATE UNIQUE INDEX unique_schema_migrations ON public.schema_migrations USING btree (version);
--
-- Name: accounting_periods accounting_periods_del_protect; Type: RULE; Schema: public; Owner: -
--
CREATE RULE accounting_periods_del_protect AS
ON DELETE TO public.accounting_periods DO INSTEAD NOTHING;
--
-- Name: accounting_periods accounting_periods_upd_protect; Type: RULE; Schema: public; Owner: -
--
CREATE RULE accounting_periods_upd_protect AS
ON UPDATE TO public.accounting_periods DO INSTEAD NOTHING;
--
-- Name: projects projects_search_content_trigger; Type: TRIGGER; Schema: public; Owner: -
--
@ -5639,6 +5656,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20140605125131'),
('20140605142133'),
('20140605151442'),
('20140606133116'),
('20140609092700'),
('20140609092827'),
('20140610153123'),
@ -5707,12 +5725,14 @@ INSERT INTO "schema_migrations" (version) VALUES
('20150507075620'),
('20150512123546'),
('20150520132030'),
('20150520133409'),
('20150526130729'),
('20150527153312'),
('20150529113555'),
('20150601125944'),
('20150603104502'),
('20150603104658'),
('20150603133050'),
('20150604081757'),
('20150604131525'),
('20150608142234'),
@ -5794,6 +5814,7 @@ INSERT INTO "schema_migrations" (version) VALUES
('20160905142700'),
('20160906094739'),
('20160906094847'),
('20160906145713'),
('20160915105234'),
('20161123104604'),
('20170109085345'),

View File

@ -32,6 +32,9 @@ If you intend to translate Fab-manager to a new, or an already supported languag
### Developer's documentation
The following guides should help those who want to contribute to the code.
#### Architecture
- [Code architecture](architecture.md)
#### How to setup a development environment
- [With docker-compose](development_readme.md)

115
doc/architecture.md Normal file
View File

@ -0,0 +1,115 @@
# Architecture
## Root
`fab-manager/`
╠═ `.docker/` In development, data of the databases are stored in this untracked folder;
╠═ `.github/` Configuration of the GitHub repository;
╠═ `accounting/` When some accounting exports are generated in the application, they are saved in this untracked folder;
╠═ `app/` **The source code of the application**;
╠═ `bin/` Ruby-on-rails binaries;
╠═ `config/` Application and frameworks configurations are saved here. **Translations are saved here too**;
╠═ `coverage/` Coveralls.io saves its temporary data into this untracked folder;
╠═ `db/` Database schema and migrations (ie. the history of the construction of the database);
╠═ `doc/` Various documentations about Fab-manager;
╠═ `docker/` Files used to build the docker image. Also: files to set up a development environment based on docker;
╠═ `exports/` When some exports are generated in the application, they are saved in this untracked folder;
╠═ `imports/` When some files are imported to the application, they are saved in this untracked folder;
╠═ `invoices/` When some invoices are generated in the application, they are saved in this untracked folder;
╠═ `lib/` **Some more code of the application**. This code may not be loaded automatically;
╠═ `log/` When running, the application will produce some debug outputs, they are saved in this untracked folder;
╠═ `node_modules` Third party libraries for the front-end JS application are stored in this untracked folder by the package manager (yarn);
╠═ `payment_schedules` When some payment schedules are generated in the application, they are saved in this untracked folder;
╠═ `plugins/` Some code can be dropped in that untracked folder to use plugins with Fab-manager;
╠═ `provision/` Scripts used to setup a development environment based on vagrant;
╠═ `public` Files that will be exposed to the world by the HTTP server (nginx). This includes the compilation result of the front-end application;
╠═ `scripts/` Some bash scripts. Scripts ran during the upgrade phrase are located here;
╠═ `setup/` Everything needed to set up a new instance of Fab-manager, including the setup script;
╠═ `test/` Automated tests of the application (MiniTest);
╠═ `tmp/` Various temporary files are stored in this untracked folder;
╠═ `vendor/` (deprecated) Previously 3rd-party assets were stored here. Now, only the fonts for the PDF generation remains here;
╠═ `.browserslistrc` Required by babel (JS compiler) to specify target browsers for the compilation of the front-end application;
╠═ `.coveralls.yml` Configuration of coveralls.io;
╠═ `.dockerignore` List of files that won't be included in the docker image;
╠═ `.env` Environment variables for development and test environments;
╠═ `.eslitignore` List of files that won't be parsed by ESLint;
╠═ `.eslintrc` Configuration of the JS code quality checking (ESLint);
╠═ `.gemrc` Ruby gems configuration;
╠═ `.gitignore` List of files that won't be tracked by the version control system (git);
╠═ `.nvmrc` Version of node.js used in this project. This file is read by NVM in development environments;
╠═ `.rubocop.yml` Configuration of the Ruby code quality checking (Rubocop);
╠═ `.ruby-gemset` Used by RVM to isolate the gems of this application
╠═ `.ruby-version` Version of Ruby used in this project. This file is read by RVM in development environments;
╠═ `babel.config.js` Configuration of babel (JS compiler);
╠═ `Capfile` (deprecated) Configuration of capistrano (previous deployment system);
╠═ `CHANGELOG.md` List of changes between releases of Fab-manager. Also contains deployment instructions for upgrading;
╠═ `config.ru` This file is used by Rack-based servers to start the application;
╠═ `CONTRIBUTING.md` Contribution guidelines;
╠═ `crowdin.yml` Configuration of the translation management system (Crowdin);
╠═ `Dockerfile` This file list instructions to build the docker image of the application;
╠═ `env.example` Example of configuration for the environment variables, for development and test environments;
╠═ `Gemfile` List of third-party libraries used in the Ruby-on-Rails application;
╠═ `Gemfile.lock` Version lock of the ruby-on-rails dependencies;
╠═ `LICENSE.md` Publication licence of Fab-manager;
╠═ `package.json` List of third-party libraries used in the Javascript application. Also: version number of Fab-manager;
╠═ `postcss.config.js` Configuration of PostCSS (CSS compiler);
╠═ `Procfile` List the process ran by foreman when starting the application in development;
╠═ `Rakefile` Configuration of Rake (Ruby commands interpreter);
╠═ `README.md` Entrypoint for the documentation;
╠═ `tsconfig.json` Configuration of TypeScript;
╠═ `Vagrantfile` Configuration of Vagrant, for development environments;
╠═ `yarn.lock` Version lock of the javascript dependencies;
╚═ `yarn-error.log` This untracked file keeps logs of the package manager (yarn), if any error occurs;
## Backend application
The backend application respects the Ruby-on-Rails conventions for MVC applications.
It mainly provides a REST-JSON API for the frontend application.
It also provides another REST-JSON API, open to the 3rd-party applications, and known as OpenAPI.
`fab-manager/`
╚═╦ `app/`
╠═ `controllers/` Controllers (MVC);
╠═ `doc/` Documentation for the OpenAPI;
╠═ `exceptions/` Custom errors;
╠═ `frontend/` **Source code for the frontend application**;
╠═ `helpers/` System-wide libraries and utilities. Prefer using `services/` when it's possible;
╠═ `mailers/` Sending emails;
╠═ `models/` Models (MVC);
╠═ `pdfs/` PDF documents generation;
╠═ `policies/` Access policies for the API and OpenAPI endpoints;
╠═ `services/` Utilities arranged by data models;
╠═ `sweepers/` Build cached version of some data;
╠═ `themes/` SASS files that overrides the frontend styles. We plan to move all styles here to build multiple themes;
╠═ `uploaders/` Handling of the uploaded files
╠═ `validators/` Custom data validation (before saving);
╠═ `views/` Views (MVC)
╚═ `workers/` Asynchronous tasks run by Sidekiq
## Frontend application
The frontend application is historically an Angular.js MVC application.
We are moving, step-by-step, to an application based on React.js + Typescript.
For now, the main application is still using Angular.js but it uses some React.js components thanks to coatue-oss/react2angular.
`fab-manager/`
╚═╦ `app/`
╚═╦ `frontend/`
╠═ `images/` Static images used all over the frontend app;
╠═ `packs/` Entry points for webpack (bundler);
╠═╦ `src/`
║ ╠═╦ `javascript/`
║ ║ ╠═ `api/` (TS) New components to access the backend API;
║ ║ ╠═ `components/` (TS) New React.js components;
║ ║ ╠═ `controllers/` (JS) Old Angular.js controllers for the views located in `app/frontend/templates`;
║ ║ ╠═ `directives/` (JS) Old Angular.js directives (interface components);
║ ║ ╠═ `filters/` (JS) Old Angular.js filters (processors transforming data);
║ ║ ╠═ `lib/` (TS) New utilities + (JS) Old external libraries customized;
║ ║ ╠═ `models/` (TS) Typed interfaces reflecting the API data models;
║ ║ ╠═ `services/` (JS) Old Angular.js components to access the backend API;
║ ║ ╠═ `typings/` (TS) Typed modules for non-JS/TS file types;
║ ║ ╠═ `app.js` Entrypoint for the angular.js application;
║ ║ ╠═ `plugins.js.erb` Entrypoint for embedding Fab-manager's plugins in the frontend application;
║ ║ ╚═ `router.js` Configuration for UI-Router (mapping between routes, controllers and templates)
║ ╚═ `stylesheets/` SASS source for the application style
╚═ `templates/` Angular.js views (HTML)

View File

@ -0,0 +1,38 @@
#!/usr/bin/env bash
yq() {
docker run --rm -i -v "${PWD}:/workdir" mikefarah/yq yq "$@"
}
config()
{
echo -ne "Checking user... "
if [[ "$(whoami)" != "root" ]] && ! groups | grep docker
then
echo "Please add your current user to the docker group OR run this script as root."
echo "current user is not allowed to use docker, exiting..."
exit 1
fi
if ! command -v awk || ! [[ $(awk -W version) =~ ^GNU ]]
then
echo "Please install GNU Awk before running this script."
echo "gawk was not found, exiting..."
exit 1
fi
SERVICE="$(yq r docker-compose.yml --printMode p 'services.*(.==sleede/fab-manager*)' | awk 'BEGIN { FS = "." } ; {print $2}')"
}
add_mount()
{
# shellcheck disable=SC2016
# we don't want to expand ${PWD}
yq w docker-compose.yml "services.$SERVICE.volumes[+]" '- ${PWD}/payment_schedules:/usr/src/app/payment_schedules'
}
proceed()
{
config
add_mount
}
proceed "$@"

View File

@ -11,6 +11,7 @@ services:
- ${PWD}/public/packs:/usr/src/app/public/packs
- ${PWD}/public/uploads:/usr/src/app/public/uploads
- ${PWD}/invoices:/usr/src/app/invoices
- ${PWD}/payment_schedules:/usr/src/app/payment_schedules
- ${PWD}/exports:/usr/src/app/exports
- ${PWD}/imports:/usr/src/app/imports
- ${PWD}/log:/var/log/supervisor

View File

@ -724,12 +724,20 @@ class Reservations::CreateTest < ActionDispatch::IntegrationTest
assert_equal payment_schedule_count + 1, PaymentSchedule.count, 'missing the payment schedule'
assert_equal payment_schedule_items_count + 12, PaymentScheduleItem.count, 'missing some payment schedule items'
# get the objects
reservation = Reservation.last
payment_schedule = PaymentSchedule.last
# subscription assertions
assert_equal 1, @user_without_subscription.subscriptions.count
assert_not_nil @user_without_subscription.subscribed_plan, "user's subscribed plan was not found"
assert_not_nil @user_without_subscription.subscription, "user's subscription was not found"
assert_equal plan.id, @user_without_subscription.subscribed_plan.id, "user's plan does not match"
# reservation assertions
assert reservation.payment_schedule
assert_equal payment_schedule.scheduled, reservation
# Check the answer
reservation = json_response(response.body)
assert_equal plan.id, reservation[:user][:subscribed_plan][:id], 'subscribed plan does not match'