Skip to content
Draft
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
eaec719
add support for flatrates in accounting posts (wip)
svenwey May 26, 2025
90757dc
add many to many relation for flatrates and invoices
svenwey May 27, 2025
a2ae461
add the selected flatrates to smallInvoice invoicing
svenwey May 27, 2025
c5c7b96
prefix the position for flatrates in smallinvoice
svenwey May 27, 2025
4ba050b
refactor merging of positions and flatrates into separate function fo…
svenwey May 27, 2025
f53583e
translate flatrate label in invoice form into german (Ch, DE)
svenwey May 27, 2025
f987261
only show flatrate name and accounting post name in invoice
svenwey May 27, 2025
eda9958
make periodicity a list of integers which represents the quantity of …
svenwey May 28, 2025
73d1391
rewrite datepicker to correctly handle date fields which are added af…
svenwey May 28, 2025
d36efb8
before caching site, destroy datepicker object to avoid caching bugs
svenwey May 28, 2025
7ad3fcb
wip changing to approach which makes it possible to select how many t…
svenwey Jun 2, 2025
19ccf94
wip changing to new flatrate approach
svenwey Jun 4, 2025
81bf868
add index to invoice_flatrates and add flatrate_invoice id to form
svenwey Jun 4, 2025
0981c61
adapt display of invoice_flatrate in invoice form
svenwey Jun 4, 2025
a32d185
reject invoice_flatrates with quantity 0 & also display invoice_flatr…
svenwey Jun 4, 2025
0859010
hide invoice_flatrate with css display: none, since removing the flat…
svenwey Jun 4, 2025
c8400bc
make smallinvoice unit for flatrates selectable
svenwey Jun 12, 2025
a26bdc3
on cascate delete and only bill flatrates up to contract end date
svenwey Jun 16, 2025
044bc21
write tests for flatrate model and add fixtures for flatrate
svenwey Jun 16, 2025
3347c4b
add test for nested flatrate fields in accounting post form
svenwey Jun 16, 2025
072da82
add tooltip for description field saying that the description is only…
svenwey Jun 16, 2025
8beb9d5
use full month names
svenwey Jun 16, 2025
0ef5b6b
adapt translation (instead of flatrates key use invoice_flatrates)
svenwey Jun 16, 2025
40d5fe2
add invoice_form_tests and clip min amount of flatrates to add to an…
svenwey Jun 18, 2025
601f156
remove trailing whitespace
svenwey Jun 18, 2025
58cec0c
appease linter
svenwey Jun 18, 2025
595e02c
remove puts from test
svenwey Jun 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 42 additions & 18 deletions app/assets/javascripts/datepicker.js.coffee
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of
# PuzzleTime and licensed under the Affero General Public License version 3
# or later. See the COPYING file at the top-level directory or at
# https://github.com/puzzle/puzzletime.

# Copyright (c) 2006-2017, Puzzle ITC GmbH.
# This file is part of PuzzleTime and licensed under the AGPL v3 or later.

app = window.App ||= {}

Expand All @@ -22,37 +19,64 @@ app.datepicker = new class
onSelect = (dateString, instance) =>
if instance.input.data('format') == 'week'
date = $.datepicker.parseDate(i18n().dateFormat, dateString)
instance.input
.val(formatWeek(date))
instance.input.val(@formatWeek(date))
instance.input.trigger('change')

options = $.extend({ onSelect, showWeek: true }, i18n())
options = $.extend({ onSelect: @onSelect, showWeek: true }, i18n())

init: ->
$('input.date').each((_i, elem) ->
$(elem).datepicker($.extend({}, options, {
changeYear: $(elem).data('changeyear')
})))
$('input.date:not(.datepicker-initialized)').each (_i, elem) =>
$elem = $(elem)
$elem.datepicker($.extend({}, @options, {
changeYear: $elem.data('changeyear')
}))
$elem.addClass('datepicker-initialized')

@bindListeners()
@observe()

formatWeek: formatWeek

destroy: ->
$('input.date').datepicker('destroy')
$('input.date.datepicker-initialized').each (_i, elem) =>
$(elem).datepicker('destroy').removeClass('datepicker-initialized')

@bindListeners(true)

bindListeners: (unbind) ->
func = if unbind then 'off' else 'on'
if @observer?
@observer.disconnect()
@observer = null

bindListeners: (unbind = false) ->
func = if unbind then 'off' else 'on'
$(document)[func]('click', 'input.date + .input-group-addon', @show)

show: (event) ->
field = $(event.target)
if !field.is('input.date')
unless field.is('input.date')
field = field.closest('.input-group').find('.date')
field.datepicker('show')

$(document).on('turbolinks:load', ->
observe: ->
return if @observer?

@observer = new MutationObserver (mutations) =>
mutations.forEach (mutation) =>
mutation.addedNodes.forEach (node) =>
return unless node.nodeType is 1 # ELEMENT_NODE
$node = $(node)

if $node.is('input.date') || $node.find('input.date').length > 0
@init() # Will only init uninitialized ones

@observer.observe(document.body,
childList: true,
subtree: true
)

$(document).on 'turbolinks:load', ->
app.datepicker.destroy()
app.datepicker.init()
)

$(document).on 'turbolinks:before-cache', ->
app.datepicker.destroy()
14 changes: 13 additions & 1 deletion app/controllers/accounting_posts_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,19 @@ class AccountingPostsController < CrudController
:remaining_hours, :portfolio_item_id, :service_id, :billable,
:description_required, :ticket_required, :from_to_times_required,
:meal_compensation,
{ work_item_attributes: %i[name shortname description] }]
{ work_item_attributes: %i[name shortname description] },
{ flatrates_attributes: [
:id,
:active_from,
:active_to,
:name,
:amount,
:description,
:unit,
{ periodicity: [] },
*(0..11).map { |i| :"periodicity_#{i}" },
:_destroy
] }]

helper_method :order

Expand Down
56 changes: 54 additions & 2 deletions app/controllers/invoices_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,12 @@ class InvoicesController < CrudController
self.nesting = [Order]

self.permitted_attrs = [:billing_date, :due_date, :period_from, :period_to, :period_shortcut,
:billing_address_id, :grouping, { employee_ids: [], work_item_ids: [] }]
:billing_address_id, :grouping, { employee_ids: [], work_item_ids: [], flatrate_ids: [] },
{ invoice_flatrates_attributes: %i[id flatrate_id invoice_id quantity _destroy] }]

self.sort_mappings = { period: :period_from, manual?: :grouping }

helper_method :checked_work_item_ids, :checked_employee_ids, :order
helper_method :checked_work_item_ids, :checked_employee_ids, :checked_flatrate_ids, :order

prepend_before_action :entry, only: %i[show new create edit update destroy sync]

Expand Down Expand Up @@ -41,6 +42,32 @@ def show
def new
assign_attributes
@autoselect_workitems_and_employees = true
set_period

order.accounting_posts.flat_map(&:flatrates).each do |flatrate|
next if @invoice.invoice_flatrates.pluck(:flatrate_id).include?(flatrate.id)

Rails.logger.info("flatrate #{flatrate.name} doesn't exist yet. Creating a new invoice_flatrate")
@invoice.invoice_flatrates.build(
flatrate: flatrate,
invoice: @invoice,
quantity: flatrate.not_billed_flatrates_quantity(@period.end_date, @invoice.id)
)
end
end

def edit
@period = Period.with(@invoice.period_from, @invoice.period_to)
order.accounting_posts.flat_map(&:flatrates).each do |flatrate|
next if @invoice.invoice_flatrates.pluck(:flatrate_id).include?(flatrate.id)

Rails.logger.info("flatrate #{flatrate.name} doesn't exist yet. Creating a new invoice_flatrate (with quantity 0)")
@invoice.invoice_flatrates.build(
flatrate: flatrate,
invoice: @invoice,
quantity: 0 # if it didn't exist before, we initialize it with 0
)
end
end

def sync
Expand Down Expand Up @@ -86,8 +113,31 @@ def billing_addresses
def filter_fields
from = model_params[:period_from]
to = model_params[:period_to]
@period = Period.with(from, to)

Rails.logger.info("FILTER, before: #{@invoice.invoice_flatrates.inspect}")

order.accounting_posts.flat_map(&:flatrates).each do |flatrate|
next if @invoice.invoice_flatrates.pluck(:flatrate_id).include?(flatrate.id)

Rails.logger.info("[filter_fields] flatrate #{flatrate.name} doesn't exist yet. Creating a new invoice_flatrate")
Rails.logger.info("[filter_fields] @period.end_date #{@period.end_date}")
@invoice.invoice_flatrates.build(
flatrate: flatrate,
invoice: @invoice,
quantity: flatrate.not_billed_flatrates_quantity(@period.end_date, @invoice.id)
)
end

Rails.logger.info("FILTER, after: #{@invoice.invoice_flatrates.inspect}")

Rails.logger.info("from: #{from.inspect}")
Rails.logger.info("to: #{to.inspect}")
Rails.logger.info("@period: #{@period.inspect}")

@employees = employees_for_period(from, to)
@work_items = work_items_for_period(from, to)

# replace employees_ids in entry with the list of actually selectable employees
entry.employee_ids = @employees.pluck(:id)
entry.work_item_ids = @work_items.pluck(:id)
Expand Down Expand Up @@ -130,6 +180,7 @@ def init_default_attrs(attrs)
attrs[:due_date] ||= l(due_date) if due_date.present?
attrs[:billing_address_id] ||= default_billing_address_id
attrs[:grouping] ||= last_grouping

if attrs[:employee_ids].blank?
attrs[:employee_ids] = employees_for_period(attrs[:period_from],
attrs[:period_to]).map(&:id)
Expand Down Expand Up @@ -158,6 +209,7 @@ def load_totals_paid
def load_associations
@employees = employees_for_period(entry.period_from, entry.period_to)
@work_items = work_items_for_period(entry.period_from, entry.period_to)
# @flatrates = flatrates_for_date(entry.period_to)
@billing_clients = Client.list
@billing_client = entry.billing_client
@billing_addresses = load_billing_addresses(@billing_client)
Expand Down
33 changes: 33 additions & 0 deletions app/domain/invoicing/small_invoice/entity/flatrate_position.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# frozen_string_literal: true

# Copyright (c) 2006-2017, Puzzle ITC GmbH. This file is part of
# PuzzleTime and licensed under the Affero General Public License version 3
# or later. See the COPYING file at the top-level directory or at
# https://github.com/puzzle/puzzletime.

module Invoicing
module SmallInvoice
module Entity
class FlatratePosition < Base
def to_hash
{
type: constant(:position_type_id),
number: nil,
name: "#{post.name} - #{entry.flatrate.name}",
description: nil,
cost: entry.flatrate.amount.try(:round, 2),
unit: entry.flatrate.unit,
amount: entry.quantity,
vat: Settings.defaults.vat
}
end

private

def post
entry.flatrate.accounting_post
end
end
end
end
end
21 changes: 16 additions & 5 deletions app/domain/invoicing/small_invoice/entity/invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,24 @@ module Invoicing
module SmallInvoice
module Entity
class Invoice < Base
attr_reader :positions
attr_reader :positions, :invoice_flatrates

def initialize(invoice, positions)
def initialize(invoice, positions, invoice_flatrates)
super(invoice)
@positions = positions
@invoice_flatrates = invoice_flatrates
end

def merged_positions
smallinvoice_positions = positions.collect do |p|
Invoicing::SmallInvoice::Entity::Position.new(p).to_hash
end

smallinvoice_flatrate_positions = invoice_flatrates.collect do |f|
Invoicing::SmallInvoice::Entity::FlatratePosition.new(f).to_hash
end

(smallinvoice_positions + smallinvoice_flatrate_positions).sort_by { |pos| pos[:name] }
end

def to_hash
Expand All @@ -39,9 +52,7 @@ def to_hash
paypal_url: constant(:paypay_url),
vat_included: constant(:vat_included),
totalamount: entry.total_amount.round(2),
positions: positions.collect do |p|
Invoicing::SmallInvoice::Entity::Position.new(p).to_hash
end
positions: merged_positions
}
end

Expand Down
4 changes: 2 additions & 2 deletions app/domain/invoicing/small_invoice/interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@
module Invoicing
module SmallInvoice
class Interface < Invoicing::Interface
def save_invoice(invoice, positions)
InvoiceStore.new(invoice).save(positions)
def save_invoice(invoice, positions, invoice_flatrates = [])
InvoiceStore.new(invoice).save(positions, invoice_flatrates)
end

def sync_invoice(invoice)
Expand Down
4 changes: 2 additions & 2 deletions app/domain/invoicing/small_invoice/invoice_store.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@ def initialize(invoice)
end

# Save an invoice with the given positions to remote and returns the invoicing_key
def save(positions)
def save(positions, invoice_flatrates)
assert_remote_client_exists

data = Invoicing::SmallInvoice::Entity::Invoice.new(invoice, positions).to_hash
data = Invoicing::SmallInvoice::Entity::Invoice.new(invoice, positions, invoice_flatrates).to_hash
if invoice.invoicing_key?
api.edit(:invoice, invoice.invoicing_key, data)
invoice.invoicing_key
Expand Down
16 changes: 16 additions & 0 deletions app/domain/invoicing/units.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# frozen_string_literal: true

module Invoicing
class Units
OPTIONS = {
'Pauschale' => 13,
'Stunde' => 1,
'Tag' => 2,
'Monat' => 3,
'Quartal' => 4,
'Semester' => 5,
'Jahr' => 6,
'-' => 14
}.freeze
end
end
4 changes: 4 additions & 0 deletions app/models/accounting_post.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
class AccountingPost < ApplicationRecord
include BelongingToWorkItem
include Closable
include Invoicing

### ASSOCIATIONS

Expand All @@ -35,6 +36,9 @@ class AccountingPost < ApplicationRecord

has_ancestor_through_work_item :order
has_ancestor_through_work_item :client
has_many :flatrates, inverse_of: :accounting_post, dependent: :destroy

accepts_nested_attributes_for :flatrates, allow_destroy: true

### CALLBACKS

Expand Down
54 changes: 54 additions & 0 deletions app/models/flatrate.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# frozen_string_literal: true

class Flatrate < ApplicationRecord
include Invoicing

belongs_to :accounting_post, inverse_of: :flatrates
has_many :invoice_flatrates, dependent: :nullify
has_many :invoices, through: :invoice_flatrates

validates_date :active_from
validates_date :active_to, allow_blank: true
validates :unit, inclusion: Invoicing::Units::OPTIONS.values

def label_verbose
"#{name} (#{accounting_post.name})"
end

(0..11).each do |i|
define_method(:"periodicity_#{i}") do
periodicity[i].to_i
rescue StandardError
0
end

define_method(:"periodicity_#{i}=") do |val|
current = periodicity.dup
current[i] = val.to_i
self.periodicity = current # triggers ActiveRecord change tracking
end
end

# Ensure periodicity is always an array of 12 integers
def periodicity
super&.map(&:to_i)&.fill(0, super.size...12) || Array.new(12, 0)
end

def periodicity=(vals)
super(Array(vals).map(&:to_i).fill(0, vals.size...12))
end

# takes in a date d and returns the amount of billed flatrates minus the amount of planned flatrates
# (according to flatrate schedule) since the beginning of the contract until min(d, contract end date)
def not_billed_flatrates_quantity(end_date, invoice_id)
billed_flatrate_quantity = InvoiceFlatrate.where(flatrate_id: id).where.not(invoice_id: invoice_id).sum(:quantity) || 0
stop_date = [end_date, active_to, accounting_post.order.contract.end_date].compact.min

accumulated_flatrate_quantity = 0
(active_from..stop_date).select { |d| d.day == 1 }.each do |month_date|
month_index = month_date.month - 1
accumulated_flatrate_quantity += periodicity[month_index]
end
[accumulated_flatrate_quantity - billed_flatrate_quantity, 0].max
end
end
Loading