Skip to content

Commit

Permalink
Create external invoice model
Browse files Browse the repository at this point in the history
  • Loading branch information
codez committed Jul 16, 2024
1 parent c3e702b commit d360244
Show file tree
Hide file tree
Showing 19 changed files with 228 additions and 106 deletions.
23 changes: 23 additions & 0 deletions app/abilities/external_invoice_ability.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# frozen_string_literal: true

# Copyright (c) 2024, Schweizer Alpen-Club. This file is part of
# hitobito_sac_cas 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/hitobito/hitobito_sac_cas

class ExternalInvoiceAbility < AbilityDsl::Base
include AbilityDsl::Constraints::Person
include SacCas::AbilityConstraints

on(ExternalInvoice) do
permission(:layer_and_below_full).may(:manage).if_backoffice
end

def person
subject.person
end

def if_backoffice
SacCas::SAC_BACKOFFICE_ROLES.any? { |r| role_type?(r) }
end
end
3 changes: 2 additions & 1 deletion app/abilities/sac_cas/person_ability.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ module SacCas::PersonAbility
class_side(:create_households).if_backoffice
permission(:read_all_people).may(:read_all_people, :show).everybody
permission(:layer_and_below_full)
.may(:index_invoices, :create_membership_invoice)
.may(:index_external_invoices, :create_membership_invoice)
.if_backoffice
permission(:any).may(:index_invoices).none
permission(:any)
.may(:set_sac_family_main_person)
.if_person_is_adult_and_all_household_members_writable
Expand Down
25 changes: 13 additions & 12 deletions app/domain/invoices/abacus/membership_invoice.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,39 +9,40 @@ module Invoices
module Abacus
class MembershipInvoice
MEMBERSHIP_CARD_FIELD_INDEX = 11
INVOICE_KIND = :membership

# member is an Invoices::SacMembership::Member object
# role is a membership or new membership Role
attr_reader :member, :role
attr_reader :member, :role, :send_on

delegate :context, to: :member
delegate :date, :sac, :config, to: :context

def initialize(member, role)
def initialize(member, role, send_on: nil)
@member = member
@role = role
@send_on = send_on
end

def invoice # rubocop:disable Metrics/MethodLength
@invoice ||=
I18n.with_locale(member.language) do
Invoice.create!(
recipient: member.person,
group: sac,
title: I18n.t("invoices.sac_memberships.title", year: date.year),
ExternalInvoice::SacMembership.create!(
person: member.person,
year: date.year,
state: :draft,
total: positions.sum(&:amount),
issued_at: date,
sent_at: date,
invoice_kind: INVOICE_KIND,
sac_membership_year: date.year
sent_at: send_on || date,
link: role
)
end
end

def handle_abacus_exception(e)
# TODO: set invoice state to error
# @invoice&.update!(state: :error)
return unless @invoice

@invoice.update!(state: :error)
@invoice.hitobito_log_entries.create!(message: e.message, level: :error, category: "rechnungen")
end

def sales_order
Expand Down
2 changes: 2 additions & 0 deletions app/domain/invoices/abacus/membership_invoice_batcher.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

module Invoices
module Abacus
# This class is currently used for POC purposes.
# Parts may be re-used in production but the final process may be different.
# TODO: implement transmit people to handle updates
# TODO: handle errors in parts. Map request parts to response parts (for subject assocs)?
class MembershipInvoiceBatcher
Expand Down
9 changes: 6 additions & 3 deletions app/domain/invoices/abacus/membership_invoice_generator.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@

module Invoices
module Abacus
# This class is currently used for POC purposes.
# Parts may be re-used in production but the final process may be different.
class MembershipInvoiceGenerator
attr_reader :person, :role, :date
attr_reader :person, :role, :date, :send_on

def initialize(person, date: nil, role: nil, client: nil)
def initialize(person, date: nil, role: nil, client: nil, send_on: nil)
@person = person
@date = date || Time.zone.today
@send_on = send_on || @date
@role = role || current_role
@client = client
end
Expand Down Expand Up @@ -46,7 +49,7 @@ def member
end

def membership_invoice
@membership_invoice ||= MembershipInvoice.new(member, role)
@membership_invoice ||= MembershipInvoice.new(member, role, send_on: send_on)
end

def subject
Expand Down
18 changes: 11 additions & 7 deletions app/domain/invoices/abacus/sales_order.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,13 @@ class SalesOrder < Entity
BACKLOG_ID = 0
TYPE = "Product"
INVOICE_KINDS = {
membership: "R",
sac_membership: "R",
course: "C"
}.with_indifferent_access.freeze

attr_reader :positions, :additional_user_fields

def initialize(invoice, positions, additional_user_fields = {})
def initialize(invoice, positions = [], additional_user_fields = {})
super(invoice)
@positions = positions
@additional_user_fields = additional_user_fields
Expand All @@ -35,7 +35,11 @@ def abacus_key
end

def assign_abacus_key(data)
entity.update_column(:abacus_sales_order_key, data.fetch(:sales_order_id)) # rubocop:disable Rails/SkipsModelValidations
entity.update!(abacus_sales_order_key: data.fetch(:sales_order_id), state: :open)
end

def set_cancelled
entity.update!(state: :cancelled)
end

def full_attrs
Expand All @@ -49,12 +53,12 @@ def full_attrs
def sales_order_attrs
{
# customer id is defined to be the same as subject id
customer_id: entity.recipient.abacus_subject_key,
customer_id: entity.person.abacus_subject_key,
order_date: entity.issued_at,
delivery_date: entity.sent_at,
total_amount: entity.total.to_f,
document_code_invoice: INVOICE_KINDS[entity.invoice_kind],
language: entity.recipient.language,
document_code_invoice: INVOICE_KINDS.fetch(entity.type_key),
language: entity.person.language,
user_fields: order_user_fields
}
end
Expand All @@ -63,7 +67,7 @@ def order_user_fields
{
user_field1: entity.id.to_s,
user_field2: SOURCE_SYSTEM,
user_field3: entity.recipient.correspondence == "digital"
user_field3: entity.person.correspondence == "digital"
}.merge(additional_user_fields)
end

Expand Down
5 changes: 5 additions & 0 deletions app/domain/invoices/abacus/sales_order_interface.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ def fetch(abacus_key)
client.get(:sales_order, abacus_key, "$expand" => "Positions")
end

def cancel(sales_order)
client.update(:sales_order, sales_order.abacus_key, {user_fields: {user_field21: true}})
sales_order.set_cancelled
end

private

def create_batch_request(sales_orders)
Expand Down
25 changes: 25 additions & 0 deletions app/models/external_invoice.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

# Copyright (c) 2024, Schweizer Alpen-Club. This file is part of
# hitobito_sac_cas 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/hitobito/hitobito_sac_cas

class ExternalInvoice < ActiveRecord::Base
STATES = %w[draft open payed cancelled error]

include I18nEnums

belongs_to :person
belongs_to :link, polymorphic: true, optional: true
has_many :hitobito_log_entries, as: :subject, dependent: :nullify

i18n_enum :state, STATES, scopes: true, queries: true

validates_by_schema
validates :state, inclusion: {in: STATES}

def type_key
self.class.name.demodulize.underscore
end
end
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
# or later. See the COPYING file at the top-level directory or at
# https://github.com/hitobito/hitobito_sac_cas

InvoiceConfig.seed_once(
:group_id,
group_id: Group.root.id,
payment_slip: 'no_ps'
)
class ExternalInvoice::Course < ExternalInvoice
# link is an Event::Participation object for a Event::Course
end
14 changes: 14 additions & 0 deletions app/models/external_invoice/sac_membership.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# frozen_string_literal: true

# Copyright (c) 2024, Schweizer Alpen-Club. This file is part of
# hitobito_sac_cas 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/hitobito/hitobito_sac_cas

class ExternalInvoice::SacMembership < ExternalInvoice
# link is a Role object for an existing or new membership

def to_s
I18n.t("invoices.sac_memberships.title", year: year)
end
end
20 changes: 0 additions & 20 deletions app/models/sac_cas/invoice.rb

This file was deleted.

21 changes: 21 additions & 0 deletions db/migrate/20240716110009_create_external_invoices.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
class CreateExternalInvoices < ActiveRecord::Migration[6.1]
def change
create_table :external_invoices do |t|
t.belongs_to :person, null: false, index: true
t.string :type, null: false
t.string :state, null: false, default: 'draft'
t.date :issued_at
t.date :sent_at
t.decimal :total, precision: 12, scale: 2, null: false, default: 0.0
t.belongs_to :link, polymorphic: true, index: true, null: true
t.integer :year
t.integer :abacus_sales_order_key
t.timestamps
end

remove_column :invoices, :abacus_sales_order_key, :integer
remove_column :invoices, :invoice_kind, :string
remove_column :invoices, :sac_membership_year, :integer
remove_column :invoices, :event_participation_id, :integer
end
end
32 changes: 29 additions & 3 deletions doc/abacus.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,40 @@ Persönliche Zugänge werden vom SAC erstellt. Für die 2FA wird zum Login die [

Die Dokumentation findet sich auf dem [Abacus API Hub](https://apihub.abacus.ch/endpoints/2024).

Unter `app/domain/invoices/abacus/client.rb` befindet sich der Client, welcher mit Abacus kommuniziert. Die konkreten Endpoints werden über die Klassen `SubjectInterface` und `SalesOrderInterface` implementiert.
Unter `Invoices::Abacus::Client` befindet sich der Client, welcher mit Abacus kommuniziert. Die konkreten Endpoints werden über die Klassen `SubjectInterface` und `SalesOrderInterface` implementiert.

Via `config/abacus.yml` werden die Zugangsdaten zur Konfiguration der Verbindung eingelesen. Siehe `config/abacus.example.yml` für die Struktur.
Diese Datei wird beim Deployment über ein Secret angelegt. Zur Entwicklung kann die Datei lokal (im SAC Wagon) angelegt werden.

hitobito legt für jede Person, für welche eine Rechnung erstellt werden soll, ein entsprechendes `Subject` inklusive `Address`, `Communication` und `Customer` an.
Die zugehörige ID wird in hitobito im Attribut `abacus_subject_key` gespeichert.

Um eine Rechnung zu generieren, werden im Abacus entsprechende `SalesOrder` und zugehörige `SalesOrderPositions` angelegt. In hitobito wird dafür eine `Invoice` erstellt, allerdings ohne `InvoiceItems`, da die Datenstruktur zu fest abweicht und in hitobito nicht benötigt wird. Zum Abbilden von Positionen für Abacus Rechnungen existiert die Klasse `InvoicePosition`.
Um eine Rechnung zu generieren, werden im Abacus entsprechende `SalesOrder` und zugehörige `SalesOrderPositions` angelegt. In hitobito wird dafür eine `ExternalInvoice` erstellt. Zum Abbilden von Positionen für Abacus Rechnungen existiert die Klasse `InvoicePosition`.

Requests können entweder einzeln oder über einen [Batch Request](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#_Toc31359017) abgesetzt werden. Batch Requests werden über `client.batch` initiiert, worauf die einzelnen Teilrequests in einem Block aufgezeichnet werden. Am Ende werden alle Teile in ein HTTP Multipart Body eingefügt und dieses an den Batch Endpoint geschicht. Die Antwort ist wiederum ein Multipart Body, dessen Teile der Reihenfolge des Requests entsprechen.
Requests können entweder einzeln oder über einen [Batch Request](https://docs.oasis-open.org/odata/odata/v4.01/odata-v4.01-part1-protocol.html#_Toc31359017) abgesetzt werden. Batch Requests werden über `Invoices::Abacus::Client#batch` initiiert, worauf die einzelnen Teilrequests in einem Block aufgezeichnet werden. Am Ende werden alle Teile in ein HTTP Multipart Body eingefügt und dieses an den Batch Endpoint geschicht. Die Antwort ist wiederum ein Multipart Body, dessen Teile der Reihenfolge des Requests entsprechen.

## Mitgliedschaftsrechnungen

Mitgliedschaftsrechnungen werden vom SAC Wagon automatisch zusammengestellt.
Diese werden entweder einzeln für ein Mitglied oder über das Jahresinkasso für alle Mitglieder erzeugt und an Abacus übermittelt.

Die Konfiguration erfolgt primär über die beiden Models
`SacMembershipConfig` für übergreifende Gebühren und Einstellungen sowie
`SacSectionMembershipConfig` für Sektionsspezifische Gebühren und Parameter.

Um die für die Verrechnung notwendigen Daten einer Person zusammen zu stellen,
dient die Klasse `Invoices::SacMembership::Member`.
Analog besteht für die SAC Sektionsdaten die Klass `Invoices::SacMembership::Section`.

Die verschiedenen Positionen, welche auf einer Mitgliedsschaftrechnung erscheinen,
sind in `Invoices::SacMembership::Positions` definiert.
Über den `Invoices::SacMembership::PositionGenerator` werden je nach Rolle
(Mitglied Stammsektion, Mitglied Zusatzsektion oder Neuanmeldung) die entsprechenden Positionen zusammengestellt.

Das Erzeugen der Rechnung erfolgt über `Invoices::Abacus::MembershipInvoice`,
welche eine `ExternalInvoice::SacMembership` erstellt und die für die Abacus-Schnittstelle notwendigen Daten
in einem `Invoices::Abacus::SalesOrder` generiert.

Die Orchestrierung der Rechnungserzeugung erfolgt für Einzelrechnungen über `Invoices::Abacus::MembershipInvoiceGenerator` bzw.
für mehrere Rechnungen aufs Mal (Jahresinkasso) über `Invoices::Abacus::MembershipInvoiceBatcher`. Diese beiden Klassen
senden die Daten via `Invoice::Abacus::SalesOrderInterface` an das Abacus API.
4 changes: 2 additions & 2 deletions lib/hitobito_sac_cas/wagon.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ class Wagon < Rails::Engine
Event::CloseApplicationsJob,
Roles::TerminateTourenleiterJob
]
HitobitoLogEntry.categories += %w[neuanmeldungen]
HitobitoLogEntry.categories += %w[neuanmeldungen rechnungen]

# extend application classes here
Event.prepend SacCas::Event
Expand All @@ -48,7 +48,6 @@ class Wagon < Rails::Engine
Household.prepend SacCas::Household
HouseholdMember.prepend SacCas::HouseholdMember
Households::MemberValidator.prepend SacCas::Households::MemberValidator
Invoice.prepend SacCas::Invoice
Person.include SacCas::Person
Person::Address.prepend SacCas::Person::Address
People::Membership::Verifier.prepend SacCas::People::Membership::Verifier
Expand Down Expand Up @@ -76,6 +75,7 @@ class Wagon < Rails::Engine
Ability.store.register Event::LevelAbility
Ability.store.register CostCenterAbility
Ability.store.register CostUnitAbility
Ability.store.register ExternalInvoiceAbility
Ability.store.register ExternalTrainingAbility
Ability.store.register SacMembershipConfigAbility
Ability.store.register SacSectionMembershipConfigAbility
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@

expect do
post :create, params: {group_id: groups(:bluemlisalp_mitglieder).id, person_id: person.id, date: "2015-03-01"}
end.to change { Invoice.count }.by(1)
end.to change { ExternalInvoice.count }.by(1)

expect(response).to redirect_to(group_person_path(groups(:bluemlisalp_mitglieder).id, person.id))
expect(flash[:alert]).to be_nil
Expand All @@ -51,7 +51,7 @@

expect do
post :create, params: {group_id: groups(:bluemlisalp_mitglieder).id, person_id: person.id, date: "2015-03-01"}
end.to change { Invoice.count }.by(1)
end.to change { ExternalInvoice.count }.by(1)

expect(response).to redirect_to(group_person_path(groups(:bluemlisalp_mitglieder).id, person.id))
expect(flash[:alert]).to eq("Die Rechnung konnte nicht an Abacus übermittelt werden. Fehlermeldung: Something went wrong")
Expand Down
Loading

0 comments on commit d360244

Please sign in to comment.