pennylane-client 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a12edf1a99b16ac791ed08bab5a859674832996f5e272f04291dcd662f687a85
4
- data.tar.gz: 7c3f8372674c76c721500494a96dab63b95feae0a405a13674f5c14280f8e4fb
3
+ metadata.gz: 94653df77c6a2fc7f58347338d0591fe5af9fb27567d55a50fddb4249b4f5bd9
4
+ data.tar.gz: e55bfa20edd8f782e88b95a4409e8a18ab13ee90eaeac9da94a7c3a275b4cb3b
5
5
  SHA512:
6
- metadata.gz: 874a9901d154747bf9bb0b2c4b4e155ca63f8d0f7336801802dc41ac712d1ccd81f8b392cba67f67ad22a674ccc54fe1de7eef7a6f5324016b9a094c418d7d69
7
- data.tar.gz: bf570896b3893262744a3db6b8bce9b7beba5db8ac69c567a2ec2c4cd74232b8b66fc24c22d0da68a2b8f06a62d01c5097a9aa5e0dda76b0eff351778c765a61
6
+ metadata.gz: f385797cce13228dd684878e630b746fa2e5e2baf30bcc56ff88bdc63bb2c74c4e22fc5aa863abbd08c4a49cf5f8c4ee14ebcaf8309a89a5ec741c2b20bf9cc1
7
+ data.tar.gz: 217e0c3ee07cf1f95afe8a8110bd81639eeb38a7a80aca2dfbd6ad01458c6218fef9954fd93f84be3c0509c170a0fcd5e40417e4fc96b565e61931359e5820f7
data/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  ## [Unreleased]
2
2
 
3
+
4
+ ## 0.2.0
5
+
6
+ ### Ajouté
7
+ - `Customers#find_or_create_by` retrouve une entreprise par son SIREN ou son SIRET, et la crée
8
+ sinon.
9
+
10
+ ### Modifié
11
+ - `CustomerInvoices#import` : `customer_id` devient facultatif. Sans lui, la facture est déposée sans
12
+ `invoice_options`, et Pennylane décide seul de son rattachement à un client.
13
+ - `Customers#find_by_reg_no` rend le plus ancien client quand plusieurs portent le même `reg_no`.
14
+
15
+
16
+ ## 0.1.0
17
+
3
18
  ### Ajouté
4
19
  - `Pennylane.client(token:)` : un client par token, donc par société Pennylane.
5
20
  - `CustomerInvoices#import` dépose une facture Factur-X, rattachée au client `customer_id`. Un
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pennylane-client (0.1.0)
4
+ pennylane-client (0.2.0)
5
5
  faraday (>= 0.9, < 3.0)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -43,28 +43,45 @@ rejouée après le délai annoncé par l'API, `rate_limit_retries` fois au plus.
43
43
 
44
44
  ## Clients
45
45
 
46
- Une facture se dépose au nom d'un client Pennylane. La référence externe, unique dans une société,
47
- fait le lien avec le client de l'application hôte :
46
+ Une facture se dépose au nom d'un client Pennylane. Une entreprise se retrouve par son SIREN ou son
47
+ SIRET, et se crée si elle manque :
48
48
 
49
49
  ```ruby
50
- customer = client.customers.find_by_external_reference("client-812") ||
50
+ customer = client.customers.find_or_create_by(
51
+ "81234567600009", # SIRET ou SIREN
52
+ name: "SARL COTEAUX ET COMPAGNIE",
53
+ vat_number: "FR19812345676",
54
+ billing_address: { address: "123 avenue des Alpages", postal_code: "74000", city: "Annecy",
55
+ country_alpha2: "FR" }
56
+ )
57
+ ```
58
+
59
+ Les attributs ne servent qu'à la création, mais ils sont vérifiés à chaque appel, avant toute
60
+ requête. Un SIRET est ramené à son SIREN, que porte le client créé en `reg_no` ; la recherche essaie
61
+ les deux formes. Si plusieurs clients ont le même SIREN, le plus ancien est rendu. La recherche
62
+ suivie de la création n'est pas atomique : deux appels simultanés pour une entreprise absente
63
+ peuvent la créer deux fois.
64
+
65
+ Un particulier n'a pas de SIREN. Il se crée avec `create`, et se retrouve par la référence externe,
66
+ unique dans une société, qui fait le lien avec le client de l'application hôte :
67
+
68
+ ```ruby
69
+ customer = client.customers.find_by_external_reference("client-813") ||
51
70
  client.customers.create(
52
- customer_type: :company,
53
- name: "SARL COTEAUX ET COMPAGNIE",
54
- reg_no: "812345676",
55
- vat_number: "FR19812345676",
56
- billing_address: { address: "123 avenue des Alpages", postal_code: "74000", city: "Annecy",
71
+ customer_type: :individual,
72
+ first_name: "Jean",
73
+ last_name: "Dupont",
74
+ billing_address: { address: "3 rue Victor Hugo", postal_code: "69002", city: "Lyon",
57
75
  country_alpha2: "FR" },
58
- external_reference: "client-812"
76
+ external_reference: "client-813"
59
77
  )
60
78
  ```
61
79
 
62
- Un client `individual` prend `first_name` et `last_name` au lieu de `name`, et n'a ni `reg_no` ni
63
- `vat_number`. Les attributs sont vérifiés avant l'appel : l'API refuse un attribut inconnu ou
80
+ `create` vérifie lui aussi les attributs avant l'appel : l'API refuse un attribut inconnu ou
64
81
  manquant sans dire lequel. Une référence externe déjà prise lève une `Pennylane::RequestError`
65
82
  (422).
66
83
 
67
- `find_by_reg_no` retrouve un client par son SIREN ; `find`, `list` et `all` fonctionnent comme pour
84
+ `find_by_reg_no` retrouve un client par son `reg_no` exact ; `find`, `list` et `all` fonctionnent comme pour
68
85
  les factures, avec les filtres `id`, `customer_type`, `ledger_account_id`, `name`,
69
86
  `external_reference`, `reg_no` et `emails`, et un tri sur `id`.
70
87
 
@@ -84,8 +101,10 @@ rescue Pennylane::DuplicateInvoiceError => e
84
101
  end
85
102
  ```
86
103
 
87
- `import` accepte un chemin ou un IO ; un IO sans chemin demande `filename:`. `customer_id` est
88
- l'identifiant du client dans Pennylane.
104
+ `import` accepte un chemin ou un IO ; un IO sans chemin demande `filename:`. `customer_id`,
105
+ facultatif, rattache la facture à un client Pennylane ; Pennylane le reprend une fois le fichier lu.
106
+ Sans lui, Pennylane décide seul du rattachement, d'après le Factur-X — un cas pas encore mesuré en
107
+ sandbox.
89
108
 
90
109
  Pennylane refuse un fichier déjà déposé et donne l'identifiant de la facture existante : un dépôt
91
110
  interrompu avant sa réponse peut donc être rejoué sans risque. Mais il ne reconnaît le doublon qu'au
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ class Client
5
+ attr_reader :configuration
6
+
7
+ def initialize(token:, configuration: Pennylane.configuration)
8
+ raise ConfigurationError, "un token d'API Pennylane est requis" if token.nil? || token.to_s.strip.empty?
9
+
10
+ @token = token.to_s
11
+ @configuration = configuration
12
+ end
13
+
14
+ def customer_invoices
15
+ @customer_invoices ||= Resources::CustomerInvoices.new(connection)
16
+ end
17
+
18
+ def customers
19
+ @customers ||= Resources::Customers.new(connection)
20
+ end
21
+
22
+ def inspect
23
+ "#<#{self.class.name} token=[FILTERED]>"
24
+ end
25
+
26
+ private
27
+
28
+ def connection
29
+ @connection ||= Connection.build(configuration, @token)
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ class Configuration
5
+ DEFAULT_BASE_URL = "https://app.pennylane.com/api/external/v2"
6
+ DEFAULT_TIMEOUT = 30
7
+ DEFAULT_OPEN_TIMEOUT = 10
8
+ DEFAULT_RATE_LIMIT_RETRIES = 2
9
+
10
+ attr_accessor :base_url, :timeout, :open_timeout, :adapter, :rate_limit_retries
11
+
12
+ def initialize
13
+ @base_url = DEFAULT_BASE_URL
14
+ @timeout = DEFAULT_TIMEOUT
15
+ @open_timeout = DEFAULT_OPEN_TIMEOUT
16
+ @rate_limit_retries = DEFAULT_RATE_LIMIT_RETRIES
17
+ @adapter = Faraday.default_adapter
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Connection
5
+ # Middlewares ordering matters here
6
+ def self.build(configuration, token)
7
+ Faraday.new(url: "#{configuration.base_url}/", headers: { "Authorization" => "Bearer #{token}" }) do |faraday|
8
+ faraday.use Middleware::EncodeJson
9
+ faraday.use Middleware::RateLimit, retries: configuration.rate_limit_retries
10
+ faraday.use Middleware::RaiseError
11
+ faraday.use Middleware::ParseJson
12
+ faraday.options.timeout = configuration.timeout
13
+ faraday.options.open_timeout = configuration.open_timeout
14
+ faraday.adapter configuration.adapter
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+ require "time"
6
+
7
+ module Pennylane
8
+ module Entities
9
+ class Base
10
+ class << self
11
+ private
12
+
13
+ def fields(*names)
14
+ names.each { |name| define_method(name) { payload[name.to_s] } }
15
+ end
16
+
17
+ # Les montants arrivent en chaînes décimales : BigDecimal les garde exacts.
18
+ def amounts(*names)
19
+ names.each { |name| define_method(name) { parse_amount(payload[name.to_s]) } }
20
+ end
21
+
22
+ def dates(*names)
23
+ names.each { |name| define_method(name) { parse_date(payload[name.to_s]) } }
24
+ end
25
+
26
+ def times(*names)
27
+ names.each { |name| define_method(name) { parse_time(payload[name.to_s]) } }
28
+ end
29
+ end
30
+
31
+ attr_reader :payload
32
+
33
+ def initialize(payload = nil)
34
+ @payload = payload.is_a?(Hash) ? payload : {}
35
+ end
36
+
37
+ def to_h
38
+ payload
39
+ end
40
+
41
+ def ==(other)
42
+ other.instance_of?(self.class) && other.payload == payload
43
+ end
44
+ alias eql? ==
45
+
46
+ def hash
47
+ [self.class, payload].hash
48
+ end
49
+
50
+ private
51
+
52
+ def dig_payload(*keys)
53
+ keys.reduce(payload) { |value, key| value.is_a?(Hash) ? value[key] : nil }
54
+ end
55
+
56
+ def parse_amount(value)
57
+ return nil if value.nil? || value.to_s.empty?
58
+
59
+ BigDecimal(value.to_s)
60
+ rescue ArgumentError
61
+ nil
62
+ end
63
+
64
+ def parse_date(value)
65
+ return nil if value.nil? || value.to_s.empty?
66
+
67
+ Date.iso8601(value.to_s)
68
+ rescue ArgumentError
69
+ nil
70
+ end
71
+
72
+ def parse_time(value)
73
+ return nil if value.nil? || value.to_s.empty?
74
+
75
+ Time.iso8601(value.to_s)
76
+ rescue ArgumentError
77
+ nil
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Entities
5
+ class Change < Base
6
+ OPERATIONS = %w[insert update delete].freeze
7
+
8
+ fields :id, :operation
9
+ times :processed_at, :created_at, :updated_at
10
+
11
+ OPERATIONS.each do |value|
12
+ define_method(:"#{value}?") { operation == value }
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Entities
5
+ class Customer < Base
6
+ fields :id, :customer_type, :name, :first_name, :last_name, :reg_no, :vat_number, :external_reference,
7
+ :emails, :phone, :recipient, :reference, :notes, :payment_conditions, :billing_iban, :billing_language,
8
+ :billing_address, :delivery_address, :electronic_billing_address
9
+ times :created_at, :updated_at
10
+
11
+ def company?
12
+ customer_type == "company"
13
+ end
14
+
15
+ def individual?
16
+ customer_type == "individual"
17
+ end
18
+
19
+ def ledger_account_id
20
+ dig_payload("ledger_account", "id")
21
+ end
22
+
23
+ def to_s
24
+ name || [first_name, last_name].compact.join(" ")
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Entities
5
+ class CustomerInvoice < Base
6
+ STATUSES = %w[
7
+ archived incomplete cancelled paid partially_paid partially_cancelled upcoming late draft
8
+ credit_note proforma shipping_order purchasing_order estimate_pending estimate_accepted
9
+ estimate_invoiced estimate_denied
10
+ ].freeze
11
+
12
+ E_INVOICING_STATUSES = %w[
13
+ submitted sent accepted approved refused rejected in_dispute partially_collected collected
14
+ ].freeze
15
+
16
+ fields :id, :invoice_number, :label, :currency, :status, :external_reference, :filename, :public_file_url,
17
+ :schematron_validation_status
18
+ amounts :amount, :currency_amount, :currency_amount_before_tax, :currency_tax, :tax,
19
+ :remaining_amount_with_tax, :remaining_amount_without_tax
20
+ dates :date, :deadline
21
+ times :created_at, :updated_at, :archived_at
22
+
23
+ # `paid?` et `draft?` lisent les champs booléens du même nom, pas le statut.
24
+ (STATUSES - %w[paid draft]).each do |value|
25
+ define_method(:"#{value}?") { status == value }
26
+ end
27
+
28
+ def paid?
29
+ payload["paid"] == true
30
+ end
31
+
32
+ def draft?
33
+ payload["draft"] == true
34
+ end
35
+
36
+ def factur_x?
37
+ payload["factur_x"] == true
38
+ end
39
+
40
+ def customer_id
41
+ dig_payload("customer", "id")
42
+ end
43
+
44
+ def e_invoicing_status
45
+ dig_payload("e_invoicing", "status")
46
+ end
47
+
48
+ def e_invoicing_reason
49
+ dig_payload("e_invoicing", "reason")
50
+ end
51
+
52
+ def to_s
53
+ [invoice_number, status].compact.join(" ")
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Entities
5
+ class EInvoiceImport < Base
6
+ fields :id, :url
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Entities
5
+ # Transaction bancaire rapprochée de la facture. Ses montants sont ceux de la transaction
6
+ # entière, qui peut solder plusieurs factures : la part affectée à une facture se lit dans
7
+ # son `remaining_amount_with_tax`.
8
+ class MatchedTransaction < Base
9
+ fields :id, :label, :currency
10
+ amounts :amount, :currency_amount, :fee, :currency_fee, :outstanding_balance
11
+ dates :date
12
+ times :created_at, :updated_at, :archived_at
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Entities
5
+ class Page < Base
6
+ include Enumerable
7
+
8
+ attr_reader :item_class
9
+
10
+ def initialize(payload, item_class:)
11
+ super(payload)
12
+ @item_class = item_class
13
+ end
14
+
15
+ def items
16
+ @items ||= Array(payload["items"]).map { |item| item_class.new(item) }
17
+ end
18
+
19
+ def each(&block)
20
+ items.each(&block)
21
+ end
22
+
23
+ def more?
24
+ payload["has_more"] == true
25
+ end
26
+
27
+ def next_cursor
28
+ payload["next_cursor"]
29
+ end
30
+
31
+ def size
32
+ items.size
33
+ end
34
+ alias length size
35
+
36
+ def empty?
37
+ items.empty?
38
+ end
39
+
40
+ def ==(other)
41
+ super && other.item_class == item_class
42
+ end
43
+ alias eql? ==
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Entities
5
+ # Paiement passé par un moyen de paiement Pennylane (Qonto, lien de paiement, GoCardless,
6
+ # Stripe). Un virement reçu hors de Pennylane apparaît en MatchedTransaction.
7
+ class Payment < Base
8
+ fields :id, :label, :currency, :status
9
+ amounts :currency_amount
10
+ times :created_at, :updated_at
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Entities
5
+ # Règlement d'une facture : ce qui en a été encaissé, et par quels paiements.
6
+ #
7
+ # Le montant encaissé se lit dans le reste à payer, que Pennylane tient à jour à chaque
8
+ # rapprochement bancaire. Le détail vient des transactions rapprochées, dont chacune porte le
9
+ # montant de la transaction entière : leur somme n'égale le montant encaissé que si aucune n'est
10
+ # partagée avec une autre facture, d'où `consistent?`.
11
+ class Settlement
12
+ attr_reader :invoice, :payments
13
+
14
+ def initialize(invoice, transactions)
15
+ @invoice = invoice
16
+ @payments = transactions.sort_by { |transaction| [transaction.date || Date.new(0), transaction.id.to_i] }
17
+ end
18
+
19
+ def paid?
20
+ invoice.paid?
21
+ end
22
+
23
+ def total
24
+ invoice.currency_amount
25
+ end
26
+
27
+ def remaining_amount
28
+ invoice.remaining_amount_with_tax
29
+ end
30
+
31
+ def settled_amount
32
+ total - remaining_amount if total && remaining_amount
33
+ end
34
+
35
+ def payments_amount
36
+ payments.sum(BigDecimal("0")) { |payment| payment.currency_amount || BigDecimal("0") }
37
+ end
38
+
39
+ def consistent?
40
+ !settled_amount.nil? && payments_amount == settled_amount
41
+ end
42
+
43
+ # « Marquer comme payée » lève le drapeau `paid` sans toucher au reste à payer ni enregistrer
44
+ # de paiement : la facture se dit payée alors qu'un reliquat n'est couvert par aucun encaissement.
45
+ def marked_paid?
46
+ paid? && !remaining_amount.nil? && remaining_amount.positive?
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class ApiError < Error
9
+ attr_reader :status, :body
10
+
11
+ def initialize(message, status: nil, body: nil)
12
+ @status = status
13
+ @body = body
14
+ super(message)
15
+ end
16
+
17
+ # Renseignés sur les erreurs de validation des paramètres seulement, de la forme
18
+ # `{"message", "code", "field", "payload"}` ; les autres erreurs ont la forme `{"status", "error"}`.
19
+ def code
20
+ body["code"] if body.is_a?(Hash)
21
+ end
22
+
23
+ def field
24
+ body["field"] if body.is_a?(Hash)
25
+ end
26
+ end
27
+
28
+ class RequestError < ApiError; end
29
+ class AuthenticationError < ApiError; end
30
+ class AuthorizationError < ApiError; end
31
+ class NotFoundError < ApiError; end
32
+ class ConflictError < ApiError; end
33
+ class ServerError < ApiError; end
34
+
35
+ # Pennylane refuse de réimporter un fichier identique à celui d'une facture existante, dont il
36
+ # donne l'identifiant.
37
+ class DuplicateInvoiceError < ConflictError
38
+ attr_reader :invoice_id
39
+
40
+ def initialize(message, invoice_id: nil, **options)
41
+ @invoice_id = invoice_id
42
+ super(message, **options)
43
+ end
44
+ end
45
+
46
+ class RateLimitError < ApiError
47
+ attr_reader :retry_after
48
+
49
+ def initialize(message, retry_after: nil, **options)
50
+ @retry_after = retry_after
51
+ super(message, **options)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "json"
5
+
6
+ module Pennylane
7
+ # Traduit `{ invoice_number: "F-1", id: { in: [1, 2] } }` en paramètre `filter` de l'API. Une
8
+ # valeur nue vaut `eq`.
9
+ class Filter
10
+ COMPARISON = %w[lt lteq gt gteq eq not_eq in not_in].freeze
11
+ INCLUSION = %w[eq not_eq in not_in].freeze
12
+
13
+ def initialize(operators_by_field)
14
+ @operators_by_field = operators_by_field
15
+ end
16
+
17
+ def to_param(filters)
18
+ return nil if filters.nil? || filters.empty?
19
+
20
+ JSON.generate(filters.flat_map { |field, condition| conditions(field.to_s, condition) })
21
+ end
22
+
23
+ private
24
+
25
+ def conditions(field, condition)
26
+ operators = @operators_by_field.fetch(field) do
27
+ raise ArgumentError, "filtre inconnu : #{field.inspect} (attendus : #{@operators_by_field.keys.join(", ")})"
28
+ end
29
+ condition = { eq: condition } unless condition.is_a?(Hash)
30
+
31
+ condition.map do |operator, value|
32
+ unless operators.include?(operator.to_s)
33
+ raise ArgumentError,
34
+ "opérateur #{operator.to_s.inspect} refusé sur #{field.inspect} (attendus : #{operators.join(", ")})"
35
+ end
36
+
37
+ { "field" => field, "operator" => operator.to_s, "value" => serialize(value) }
38
+ end
39
+ end
40
+
41
+ # Mesuré en sandbox : un booléen JSON est refusé, avec un message trompeur (« Value "true" is
42
+ # not allowed… Allowed values are "true, false" ») ; seule sa forme textuelle passe.
43
+ def serialize(value)
44
+ case value
45
+ when Array then value.map { |item| serialize(item) }
46
+ when true, false then value.to_s
47
+ when Date, Time then value.iso8601
48
+ else value
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pennylane
6
+ module Middleware
7
+ # Faraday n'embarque de middleware JSON qu'à partir de la 1.0. Un corps déjà sérialisé, comme
8
+ # celui d'un envoi multipart, passe tel quel.
9
+ class EncodeJson < Faraday::Middleware
10
+ CONTENT_TYPE = "Content-Type"
11
+ MIME = "application/json"
12
+
13
+ def call(env)
14
+ encode(env) unless env.body.nil? || env.body.is_a?(String)
15
+ @app.call(env)
16
+ end
17
+
18
+ private
19
+
20
+ def encode(env)
21
+ env.request_headers[CONTENT_TYPE] ||= MIME
22
+ env.body = JSON.generate(env.body)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Pennylane
6
+ module Middleware
7
+ # Faraday n'embarque de middleware JSON qu'à partir de la 1.0.
8
+ class ParseJson < Faraday::Middleware
9
+ CONTENT_TYPE = /\bjson\z/.freeze
10
+
11
+ def call(env)
12
+ @app.call(env).on_complete { |response| response.body = parse(response) }
13
+ end
14
+
15
+ private
16
+
17
+ def parse(env)
18
+ body = env.body
19
+ return body unless json?(env) && body.is_a?(String) && !body.strip.empty?
20
+
21
+ JSON.parse(body)
22
+ rescue JSON::ParserError
23
+ body
24
+ end
25
+
26
+ def json?(env)
27
+ type = env.response_headers && env.response_headers["content-type"]
28
+ type.to_s.split(";").first.to_s.strip.match?(CONTENT_TYPE)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Middleware
5
+ class RaiseError < Faraday::Middleware
6
+ ERRORS = {
7
+ 400 => RequestError,
8
+ 401 => AuthenticationError,
9
+ 403 => AuthorizationError,
10
+ 404 => NotFoundError,
11
+ 409 => ConflictError,
12
+ 422 => RequestError,
13
+ 429 => RateLimitError
14
+ }.freeze
15
+
16
+ RETRY_AFTER_HEADER = "retry-after"
17
+
18
+ # Appelé sur l'objet réponse : `on_complete` n'existe pas sur Faraday::Middleware avant la 1.0.
19
+ def call(env)
20
+ @app.call(env).on_complete { |response| check(response) }
21
+ end
22
+
23
+ private
24
+
25
+ def check(env)
26
+ return if env.status < 400
27
+
28
+ error_class = ERRORS[env.status] || (env.status >= 500 ? ServerError : ApiError)
29
+ options = { status: env.status, body: env.body }
30
+ options[:retry_after] = retry_after(env) if error_class == RateLimitError
31
+ raise error_class.new(message_for(env), **options)
32
+ end
33
+
34
+ def message_for(env)
35
+ detail = detail_for(env.body)
36
+ detail ||= env.reason_phrase if env.respond_to?(:reason_phrase)
37
+ [env.status, detail, request_label(env)].compact.join(" ")
38
+ end
39
+
40
+ def detail_for(body)
41
+ text = body.is_a?(Hash) ? body["message"] || body["error"] : body
42
+ text = text.to_s.gsub(/\s+/, " ").strip
43
+ text.empty? ? nil : text[0, 500]
44
+ end
45
+
46
+ # La requête est écartée pour garder un message stable : elle porte filtres et curseurs.
47
+ def request_label(env)
48
+ "(#{env.method.to_s.upcase} #{env.url.path})" if env.url
49
+ end
50
+
51
+ def retry_after(env)
52
+ value = env.response_headers && env.response_headers[RETRY_AFTER_HEADER]
53
+ Integer(value.to_s, 10) if value.to_s.match?(/\A\d+\z/)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Middleware
5
+ # Pennylane accepte 25 requêtes par fenêtre de 5 secondes et par token, puis répond 429 avec
6
+ # un `retry-after` en secondes. Une requête refusée n'a pas été traitée : la rejouer est sans
7
+ # risque, dépôt compris.
8
+ class RateLimit < Faraday::Middleware
9
+ DEFAULT_WAIT = 1
10
+
11
+ # Options en hash et non en mots-clés : Faraday 0.9 les transmet en argument positionnel.
12
+ def initialize(app, options = {})
13
+ super(app)
14
+ @retries = options.fetch(:retries)
15
+ @sleeper = options.fetch(:sleeper) { Kernel.method(:sleep) }
16
+ end
17
+
18
+ def call(env)
19
+ body = env.body
20
+ attempts = 0
21
+
22
+ begin
23
+ reset(env, body)
24
+ @app.call(env)
25
+ rescue RateLimitError => e
26
+ raise if attempts >= @retries
27
+
28
+ attempts += 1
29
+ @sleeper.call(e.retry_after || DEFAULT_WAIT)
30
+ retry
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Le corps de `env` porte la requête à l'aller et la réponse au retour : sans restauration,
37
+ # la seconde tentative posterait la réponse de la première.
38
+ def reset(env, body)
39
+ env.body = body
40
+ env.response = nil
41
+ env.status = nil
42
+ env.response_headers = nil
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module Pennylane
7
+ # Faraday 2 a sorti l'envoi multipart de son cœur (gem faraday-multipart) : le corps est assemblé
8
+ # ici pour rester sur la seule dépendance Faraday, de la 0.9 à la 2.
9
+ class Multipart
10
+ CRLF = "\r\n"
11
+
12
+ attr_reader :boundary
13
+
14
+ def initialize(boundary: "pennylane-#{SecureRandom.hex(16)}")
15
+ @boundary = boundary
16
+ @parts = []
17
+ end
18
+
19
+ def add_file(name, content, filename:, content_type:)
20
+ add(name, content, content_type: content_type, filename: filename)
21
+ end
22
+
23
+ def add_json(name, value)
24
+ add(name, JSON.generate(value), content_type: "application/json")
25
+ end
26
+
27
+ def content_type
28
+ "multipart/form-data; boundary=#{boundary}"
29
+ end
30
+
31
+ def body
32
+ body = String.new(encoding: Encoding::BINARY)
33
+ @parts.each { |part| body << "--#{boundary}#{CRLF}".b << part << CRLF.b }
34
+ body << "--#{boundary}--#{CRLF}".b
35
+ end
36
+
37
+ private
38
+
39
+ def add(name, content, content_type:, filename: nil)
40
+ disposition = %(form-data; name="#{name}")
41
+ disposition += %(; filename="#{filename.to_s.gsub(/["\\\r\n]/, "_")}") if filename
42
+ headers = "Content-Disposition: #{disposition}#{CRLF}Content-Type: #{content_type}#{CRLF}#{CRLF}"
43
+ @parts << (headers.b << content.to_s.b)
44
+ self
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Resources
5
+ class Base
6
+ def initialize(connection)
7
+ @connection = connection
8
+ end
9
+
10
+ def inspect
11
+ "#<#{self.class.name}>"
12
+ end
13
+
14
+ private
15
+
16
+ attr_reader :connection
17
+
18
+ def get(path, params = {})
19
+ connection.get(path, params.compact).body
20
+ end
21
+
22
+ def post(path, body)
23
+ connection.post(path, body).body
24
+ end
25
+
26
+ def post_multipart(path, multipart)
27
+ response = connection.post(path) do |request|
28
+ request.headers["Content-Type"] = multipart.content_type
29
+ request.body = multipart.body
30
+ end
31
+ response.body
32
+ end
33
+
34
+ def page(path, params, item_class)
35
+ Entities::Page.new(get(path, params), item_class: item_class)
36
+ end
37
+
38
+ def paginate(&fetch_page)
39
+ Enumerator.new do |yielder|
40
+ cursor = nil
41
+ loop do
42
+ current = fetch_page.call(cursor)
43
+ current.each { |item| yielder << item }
44
+ break unless current.more? && current.next_cursor
45
+
46
+ cursor = current.next_cursor
47
+ end
48
+ end
49
+ end
50
+
51
+ # Chaque ressource listable déclare FILTER, SORT_FIELDS et MAX_LIMIT.
52
+ def list_params(filters, sort, limit)
53
+ {
54
+ "filter" => self.class::FILTER.to_param(filters),
55
+ "sort" => sort_param(sort),
56
+ "limit" => limit_param(limit, self.class::MAX_LIMIT)
57
+ }
58
+ end
59
+
60
+ def sort_param(sort)
61
+ return nil if sort.nil?
62
+
63
+ fields = self.class::SORT_FIELDS
64
+ value = sort.to_s
65
+ return value if fields.include?(value.sub(/\A[+-]/, ""))
66
+
67
+ raise ArgumentError, "tri impossible sur #{sort.inspect} (attendus : #{fields.join(", ")})"
68
+ end
69
+
70
+ def limit_param(limit, max)
71
+ return nil if limit.nil?
72
+
73
+ value = Integer(limit.to_s, 10)
74
+ raise ArgumentError unless value.between?(1, max)
75
+
76
+ value
77
+ rescue ArgumentError
78
+ raise ArgumentError, "limit hors bornes : #{limit.inspect} (1 à #{max})"
79
+ end
80
+
81
+ def normalize_id(id, label = "identifiant")
82
+ value = id.respond_to?(:id) ? id.id : id
83
+ return Integer(value.to_s, 10) if value.to_s.match?(/\A[1-9]\d*\z/)
84
+
85
+ raise ArgumentError, "#{label} invalide : #{id.inspect}"
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Resources
5
+ class CustomerInvoices < Base
6
+ FILTER = Filter.new(
7
+ "id" => Filter::COMPARISON,
8
+ "date" => Filter::COMPARISON,
9
+ "customer_id" => Filter::COMPARISON,
10
+ "billing_subscription_id" => Filter::COMPARISON,
11
+ "quote_id" => Filter::COMPARISON,
12
+ "invoice_number" => Filter::INCLUSION,
13
+ "flow_id" => Filter::INCLUSION,
14
+ "draft" => %w[eq],
15
+ "credit_note" => %w[eq],
16
+ "external_reference" => %w[eq],
17
+ "category_id" => %w[in]
18
+ )
19
+
20
+ SORT_FIELDS = %w[id date].freeze
21
+ MAX_LIMIT = 100
22
+ MAX_CHANGES_LIMIT = 1000
23
+ PDF_SIGNATURE = "%PDF-"
24
+ DUPLICATE_ID = /document with ID (\d+) already exists/.freeze
25
+
26
+ # Un fichier déjà déposé est refusé en DuplicateInvoiceError : Pennylane reconnaît le doublon à
27
+ # son fichier (« already exists with such attachment »). Le fichier est lu en différé : juste
28
+ # après le dépôt, la facture n'a ni numéro ni montant, et `find_by_invoice_number` ne la trouve
29
+ # pas encore.
30
+ def import(file, customer_id: nil, filename: nil)
31
+ content, path_filename = read_pdf(file)
32
+ filename ||= path_filename
33
+ raise ArgumentError, "filename est requis pour un fichier sans chemin" if filename.nil?
34
+
35
+ multipart = Multipart.new.add_file("file", content, filename: filename, content_type: "application/pdf")
36
+ unless customer_id.nil?
37
+ multipart.add_json("invoice_options", "customer_id" => normalize_id(customer_id, "customer_id"))
38
+ end
39
+ Entities::EInvoiceImport.new(post_multipart("customer_invoices/e_invoices/imports", multipart))
40
+ rescue ConflictError => e
41
+ raise DuplicateInvoiceError.new(e.message, invoice_id: e.message[DUPLICATE_ID, 1]&.to_i,
42
+ status: e.status, body: e.body)
43
+ end
44
+
45
+ def find(id)
46
+ Entities::CustomerInvoice.new(get("customer_invoices/#{normalize_id(id)}"))
47
+ end
48
+
49
+ # Pennylane accepte deux factures de même numéro si leurs fichiers diffèrent : la plus ancienne
50
+ # est rendue, l'API listant par défaut les plus récentes d'abord.
51
+ def find_by_invoice_number(invoice_number)
52
+ raise ArgumentError, "numéro de facture manquant" if invoice_number.to_s.strip.empty?
53
+
54
+ list(filters: { invoice_number: invoice_number.to_s }, sort: "id", limit: 1).first
55
+ end
56
+
57
+ def find_many(ids)
58
+ ids = ids.map { |id| normalize_id(id) }.uniq
59
+ ids.each_slice(MAX_LIMIT).flat_map { |slice| list(filters: { id: { in: slice } }, limit: MAX_LIMIT).to_a }
60
+ end
61
+
62
+ def list(filters: {}, sort: nil, limit: nil, cursor: nil)
63
+ page("customer_invoices", list_params(filters, sort, limit).merge("cursor" => cursor),
64
+ Entities::CustomerInvoice)
65
+ end
66
+
67
+ # Le curseur ne retient pas les filtres : ils repartent avec chaque page.
68
+ def all(filters: {}, sort: nil, limit: MAX_LIMIT)
69
+ params = list_params(filters, sort, limit)
70
+ paginate { |cursor| page("customer_invoices", params.merge("cursor" => cursor), Entities::CustomerInvoice) }
71
+ end
72
+
73
+ def payments(invoice_id)
74
+ each_of("customer_invoices/#{normalize_id(invoice_id)}/payments", Entities::Payment).to_a
75
+ end
76
+
77
+ def matched_transactions(invoice_id)
78
+ each_of("customer_invoices/#{normalize_id(invoice_id)}/matched_transactions", Entities::MatchedTransaction).to_a
79
+ end
80
+
81
+ # Accepte une facture déjà lue, par exemple via `find_many`, pour éviter de la relire.
82
+ def settlement(invoice)
83
+ invoice = find(invoice) unless invoice.is_a?(Entities::CustomerInvoice)
84
+ Entities::Settlement.new(invoice, matched_transactions(invoice.id))
85
+ end
86
+
87
+ # Journal des créations, modifications et suppressions, du plus ancien au plus récent.
88
+ # L'API ne le conserve que quatre semaines : un `since` plus ancien est refusé en 422.
89
+ def changes(since: nil, limit: MAX_CHANGES_LIMIT)
90
+ params = { "start_date" => start_date_param(since), "limit" => limit_param(limit, MAX_CHANGES_LIMIT) }
91
+ # La date et le curseur s'excluent : la date ne sert qu'à la première page.
92
+ paginate do |cursor|
93
+ page("changelogs/customer_invoices", cursor ? params.merge("start_date" => nil, "cursor" => cursor) : params,
94
+ Entities::Change)
95
+ end
96
+ end
97
+
98
+ private
99
+
100
+ def each_of(path, item_class)
101
+ paginate { |cursor| page(path, { "limit" => MAX_LIMIT, "cursor" => cursor }, item_class) }
102
+ end
103
+
104
+ def start_date_param(since)
105
+ time = case since
106
+ when nil then return nil
107
+ when Time then since
108
+ when DateTime then since.to_time
109
+ when Date then Time.utc(since.year, since.month, since.day)
110
+ else raise ArgumentError, "since doit être une Time, une DateTime ou une Date : #{since.inspect}"
111
+ end
112
+ time.utc.iso8601(6)
113
+ end
114
+
115
+ # Un PDF peut légalement précéder sa signature de quelques octets.
116
+ def read_pdf(file)
117
+ content, filename =
118
+ if file.respond_to?(:read)
119
+ [file.read, file.respond_to?(:path) && file.path ? File.basename(file.path) : nil]
120
+ else
121
+ [File.binread(file.to_s), File.basename(file.to_s)]
122
+ end
123
+ raise ArgumentError, "le fichier n'est pas un PDF" unless content.to_s.b[0, 1024].include?(PDF_SIGNATURE)
124
+
125
+ [content, filename]
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pennylane
4
+ module Resources
5
+ class Customers < Base
6
+ FILTER = Filter.new(
7
+ "id" => Filter::COMPARISON,
8
+ "customer_type" => %w[eq not_eq],
9
+ "ledger_account_id" => %w[eq not_eq],
10
+ "name" => %w[start_with eq],
11
+ "external_reference" => %w[start_with eq not_eq in not_in],
12
+ "reg_no" => Filter::INCLUSION,
13
+ "emails" => %w[in]
14
+ )
15
+
16
+ SORT_FIELDS = %w[id].freeze
17
+ MAX_LIMIT = 100
18
+
19
+ COMMON_ATTRIBUTES = %w[
20
+ ledger_account phone billing_address delivery_address payment_conditions billing_iban recipient reference
21
+ notes emails external_reference billing_language
22
+ ].freeze
23
+ ATTRIBUTES = {
24
+ "company" => %w[name vat_number reg_no] + COMMON_ATTRIBUTES,
25
+ "individual" => %w[first_name last_name] + COMMON_ATTRIBUTES
26
+ }.freeze
27
+ REQUIRED_ATTRIBUTES = {
28
+ "company" => %w[name billing_address],
29
+ "individual" => %w[first_name last_name billing_address]
30
+ }.freeze
31
+ ADDRESS_FIELDS = %w[address postal_code city country_alpha2].freeze
32
+ ADDRESSES = %w[billing_address delivery_address].freeze
33
+ SIREN = /\A\d{9}\z/.freeze
34
+ SIRET = /\A\d{14}\z/.freeze
35
+
36
+ def find(id)
37
+ Entities::Customer.new(get("customers/#{normalize_id(id)}"))
38
+ end
39
+
40
+ # La référence externe est unique dans une société : c'est la clé de correspondance avec les
41
+ # clients de l'application hôte.
42
+ def find_by_external_reference(external_reference)
43
+ raise ArgumentError, "référence externe manquante" if external_reference.to_s.strip.empty?
44
+
45
+ list(filters: { external_reference: external_reference.to_s }, limit: 1).first
46
+ end
47
+
48
+ def find_by_reg_no(reg_no)
49
+ raise ArgumentError, "numéro d'immatriculation manquant" if reg_no.to_s.strip.empty?
50
+
51
+ list(filters: { reg_no: reg_no.to_s }, sort: "id", limit: 1).first
52
+ end
53
+
54
+ # Retrouve une entreprise par son SIREN, ou par le SIREN de son SIRET, et la crée sinon avec
55
+ # `attributes`, vérifiés avant tout appel. Le client créé porte le SIREN en `reg_no`. Parmi
56
+ # plusieurs clients de même SIREN, le plus ancien est rendu.
57
+ def find_or_create_by(siren_or_siret, **attributes)
58
+ reg_nos = reg_nos_for(siren_or_siret)
59
+ attributes = attributes.transform_keys(&:to_s)
60
+ if attributes.key?("reg_no")
61
+ raise ArgumentError, "reg_no est tiré du SIREN ou du SIRET passé en premier argument"
62
+ end
63
+
64
+ attributes["reg_no"] = reg_nos.first
65
+ check_attributes!("company", attributes)
66
+
67
+ list(filters: { reg_no: { in: reg_nos } }, sort: "id", limit: 1).first || post_customer("company", attributes)
68
+ end
69
+
70
+ def list(filters: {}, sort: nil, limit: nil, cursor: nil)
71
+ page("customers", list_params(filters, sort, limit).merge("cursor" => cursor), Entities::Customer)
72
+ end
73
+
74
+ def all(filters: {}, sort: nil, limit: MAX_LIMIT)
75
+ params = list_params(filters, sort, limit)
76
+ paginate { |cursor| page("customers", params.merge("cursor" => cursor), Entities::Customer) }
77
+ end
78
+
79
+ # Vérifié avant l'appel : l'API refuse un attribut inconnu ou manquant d'un unique « NotOneOf »
80
+ # qui ne nomme pas le champ en cause.
81
+ def create(customer_type:, **attributes)
82
+ type = customer_type.to_s
83
+ attributes = attributes.transform_keys(&:to_s)
84
+ check_attributes!(type, attributes)
85
+
86
+ post_customer(type, attributes)
87
+ end
88
+
89
+ private
90
+
91
+ def post_customer(type, attributes)
92
+ Entities::Customer.new(post("customers", attributes.merge("customer_type" => type)))
93
+ end
94
+
95
+ # Le SIRET est cherché avec son SIREN : `reg_no` peut avoir été saisi sous l'une ou l'autre forme.
96
+ def reg_nos_for(siren_or_siret)
97
+ value = siren_or_siret.to_s.delete(" ")
98
+ return [value] if value.match?(SIREN)
99
+ return [value[0, 9], value] if value.match?(SIRET)
100
+
101
+ raise ArgumentError, "SIREN (9 chiffres) ou SIRET (14 chiffres) attendu : #{siren_or_siret.inspect}"
102
+ end
103
+
104
+ def check_attributes!(type, attributes)
105
+ allowed = ATTRIBUTES.fetch(type) do
106
+ raise ArgumentError, "type de client inconnu : #{type.inspect} (attendus : #{ATTRIBUTES.keys.join(", ")})"
107
+ end
108
+ refuse!(attributes.keys - allowed, "attributs inconnus pour un client #{type}")
109
+ refuse!(REQUIRED_ATTRIBUTES.fetch(type).select { |name| blank?(attributes[name]) },
110
+ "attributs requis pour un client #{type}")
111
+
112
+ ADDRESSES.each { |name| check_address!(name, attributes[name]) if attributes.key?(name) }
113
+ end
114
+
115
+ def check_address!(name, address)
116
+ raise ArgumentError, "#{name} doit être un hash" unless address.is_a?(Hash)
117
+
118
+ address = address.transform_keys(&:to_s)
119
+ refuse!(address.keys - ADDRESS_FIELDS, "champs inconnus dans #{name}")
120
+ refuse!(ADDRESS_FIELDS.select { |field| blank?(address[field]) }, "champs requis dans #{name}")
121
+ end
122
+
123
+ def refuse!(names, message)
124
+ raise ArgumentError, "#{message} : #{names.join(", ")}" if names.any?
125
+ end
126
+
127
+ def blank?(value)
128
+ value.nil? || (value.respond_to?(:empty?) && value.empty?) || value.to_s.strip.empty?
129
+ end
130
+ end
131
+ end
132
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pennylane
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pennylane-client
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hôtentic
@@ -51,6 +51,28 @@ files:
51
51
  - bin/setup
52
52
  - lib/pennylane-client.rb
53
53
  - lib/pennylane.rb
54
+ - lib/pennylane/client.rb
55
+ - lib/pennylane/configuration.rb
56
+ - lib/pennylane/connection.rb
57
+ - lib/pennylane/entities/base.rb
58
+ - lib/pennylane/entities/change.rb
59
+ - lib/pennylane/entities/customer.rb
60
+ - lib/pennylane/entities/customer_invoice.rb
61
+ - lib/pennylane/entities/e_invoice_import.rb
62
+ - lib/pennylane/entities/matched_transaction.rb
63
+ - lib/pennylane/entities/page.rb
64
+ - lib/pennylane/entities/payment.rb
65
+ - lib/pennylane/entities/settlement.rb
66
+ - lib/pennylane/errors.rb
67
+ - lib/pennylane/filter.rb
68
+ - lib/pennylane/middleware/encode_json.rb
69
+ - lib/pennylane/middleware/parse_json.rb
70
+ - lib/pennylane/middleware/raise_error.rb
71
+ - lib/pennylane/middleware/rate_limit.rb
72
+ - lib/pennylane/multipart.rb
73
+ - lib/pennylane/resources/base.rb
74
+ - lib/pennylane/resources/customer_invoices.rb
75
+ - lib/pennylane/resources/customers.rb
54
76
  - lib/pennylane/version.rb
55
77
  - pennylane-client.gemspec
56
78
  homepage: https://hotentic.com