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 +4 -4
- data/CHANGELOG.md +15 -0
- data/Gemfile.lock +1 -1
- data/README.md +33 -14
- data/lib/pennylane/client.rb +32 -0
- data/lib/pennylane/configuration.rb +20 -0
- data/lib/pennylane/connection.rb +18 -0
- data/lib/pennylane/entities/base.rb +81 -0
- data/lib/pennylane/entities/change.rb +16 -0
- data/lib/pennylane/entities/customer.rb +28 -0
- data/lib/pennylane/entities/customer_invoice.rb +57 -0
- data/lib/pennylane/entities/e_invoice_import.rb +9 -0
- data/lib/pennylane/entities/matched_transaction.rb +15 -0
- data/lib/pennylane/entities/page.rb +46 -0
- data/lib/pennylane/entities/payment.rb +13 -0
- data/lib/pennylane/entities/settlement.rb +50 -0
- data/lib/pennylane/errors.rb +54 -0
- data/lib/pennylane/filter.rb +52 -0
- data/lib/pennylane/middleware/encode_json.rb +26 -0
- data/lib/pennylane/middleware/parse_json.rb +32 -0
- data/lib/pennylane/middleware/raise_error.rb +57 -0
- data/lib/pennylane/middleware/rate_limit.rb +46 -0
- data/lib/pennylane/multipart.rb +47 -0
- data/lib/pennylane/resources/base.rb +89 -0
- data/lib/pennylane/resources/customer_invoices.rb +129 -0
- data/lib/pennylane/resources/customers.rb +132 -0
- data/lib/pennylane/version.rb +1 -1
- metadata +23 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 94653df77c6a2fc7f58347338d0591fe5af9fb27567d55a50fddb4249b4f5bd9
|
|
4
|
+
data.tar.gz: e55bfa20edd8f782e88b95a4409e8a18ab13ee90eaeac9da94a7c3a275b4cb3b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
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.
|
|
47
|
-
|
|
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.
|
|
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: :
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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-
|
|
76
|
+
external_reference: "client-813"
|
|
59
77
|
)
|
|
60
78
|
```
|
|
61
79
|
|
|
62
|
-
|
|
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
|
|
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
|
|
88
|
-
|
|
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,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
|
data/lib/pennylane/version.rb
CHANGED
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.
|
|
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
|