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:
commit
3c5103103e
3
.gitignore
vendored
3
.gitignore
vendored
@ -34,6 +34,9 @@
|
||||
# PDF invoices
|
||||
/invoices/*
|
||||
|
||||
# PDF Payment Schedules
|
||||
/payment_schedules/*
|
||||
|
||||
# XLSX exports
|
||||
/exports/*
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
2
Procfile
2
Procfile
@ -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
|
||||
|
2
Rakefile
2
Rakefile
@ -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.
|
||||
|
||||
|
18
app/controllers/api/payment_schedules_controller.rb
Normal file
18
app/controllers/api/payment_schedules_controller.rb
Normal 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
|
@ -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,
|
||||
|
@ -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
|
||||
|
6
app/exceptions/cannot_refund_error.rb
Normal file
6
app/exceptions/cannot_refund_error.rb
Normal file
@ -0,0 +1,6 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
# Raised when the Avoir cannot be generated from an existing Invoice
|
||||
class CannotRefundError < StandardError
|
||||
end
|
||||
|
@ -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
|
||||
|
@ -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);
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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');
|
||||
|
@ -15,6 +15,7 @@ export interface User {
|
||||
role: UserRole
|
||||
name: string,
|
||||
need_completion: boolean,
|
||||
ip_address: string,
|
||||
profile: {
|
||||
id: number,
|
||||
first_name: string,
|
||||
|
@ -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
|
||||
|
34
app/models/footprintable.rb
Normal file
34
app/models/footprintable.rb
Normal 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
|
@ -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
|
||||
|
@ -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?
|
||||
|
@ -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?
|
||||
|
26
app/models/payment_document.rb
Normal file
26
app/models/payment_document.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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(', ')} ]"
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
75
app/services/stripe_service.rb
Normal file
75
app/services/stripe_service.rb
Normal 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
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
24
app/workers/payment_schedule_item_worker.rb
Normal file
24
app/workers/payment_schedule_item_worker.rb
Normal 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
|
21
app/workers/payment_schedule_worker.rb
Normal file
21
app/workers/payment_schedule_worker.rb
Normal 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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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'),
|
||||
|
@ -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
115
doc/architecture.md
Normal 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)
|
38
scripts/mount-payment-schedules.sh
Normal file
38
scripts/mount-payment-schedules.sh
Normal 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 "$@"
|
@ -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
|
||||
|
@ -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'
|
||||
|
Loading…
x
Reference in New Issue
Block a user