cin7_core_api 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 +7 -0
- data/CHANGELOG.md +17 -0
- data/LICENSE.txt +21 -0
- data/README.md +262 -0
- data/lib/cin7_core_api/client.rb +104 -0
- data/lib/cin7_core_api/connection.rb +107 -0
- data/lib/cin7_core_api/errors.rb +45 -0
- data/lib/cin7_core_api/redactor.rb +183 -0
- data/lib/cin7_core_api/resources/accounts.rb +20 -0
- data/lib/cin7_core_api/resources/bank_accounts.rb +19 -0
- data/lib/cin7_core_api/resources/base.rb +49 -0
- data/lib/cin7_core_api/resources/credit_notes.rb +34 -0
- data/lib/cin7_core_api/resources/customer_credits.rb +18 -0
- data/lib/cin7_core_api/resources/customers.rb +22 -0
- data/lib/cin7_core_api/resources/invoices.rb +33 -0
- data/lib/cin7_core_api/resources/journals.rb +30 -0
- data/lib/cin7_core_api/resources/locations.rb +19 -0
- data/lib/cin7_core_api/resources/manual_journals.rb +17 -0
- data/lib/cin7_core_api/resources/me.rb +11 -0
- data/lib/cin7_core_api/resources/orders.rb +23 -0
- data/lib/cin7_core_api/resources/payments.rb +29 -0
- data/lib/cin7_core_api/resources/sales.rb +55 -0
- data/lib/cin7_core_api/resources/tax_rules.rb +16 -0
- data/lib/cin7_core_api/resources/webhooks.rb +26 -0
- data/lib/cin7_core_api/response.rb +26 -0
- data/lib/cin7_core_api/version.rb +5 -0
- data/lib/cin7_core_api.rb +27 -0
- metadata +169 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5c48f34c3de3d21ac9674e0bb133a3b9a16158ae0eb5087fcaafdf8e8543f794
|
|
4
|
+
data.tar.gz: e452799a7aa9ebf7b988d7146df5f01cc0f23c8be9c8213cda495dff6549653d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 0acaa3e83cfd47241d741a4fee553505f558ef091ae971e1f1a8753de40e510b89b03ea17bfc1d2aba60932261d1eddbfbbb20f8deb3d962b566261844bfca99
|
|
7
|
+
data.tar.gz: fbd694cb6d780b6c718540e02ab0ee6f923f2c33b93ffc65243ff6293453c438dc15b20903e3f8027fa1a3aaa4770931fa9995481ae7b623ef9ff22c9b7d8093
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
### 0.2.0 (release preparation)
|
|
6
|
+
|
|
7
|
+
- Add explicit sale update/undo, order, invoice, payment, sale manual-journal, standalone journal, tax-rule and webhook operations while preserving existing read methods.
|
|
8
|
+
- Send JSON payloads without rounding amounts or FX rates; distinguish parent sale IDs, document task IDs, payment IDs and webhook IDs.
|
|
9
|
+
- Expose request method/path and `ambiguous?` on errors for uncertain write outcomes after transport failures, server errors or invalid success responses. Never retry automatically.
|
|
10
|
+
- Sanitize credentials in response bodies and headers, omit sensitive transport causes and response details from error messages, and make object inspection safe for logging.
|
|
11
|
+
- Document document-replacement semantics, recovery lookup contracts, webhook constraints and release verification. Live financial behavior remains the caller's responsibility to verify.
|
|
12
|
+
|
|
13
|
+
## [0.1.0] - 2026-08-12
|
|
14
|
+
|
|
15
|
+
- Add tenant-scoped authentication for the CIN7 Core API v2.
|
|
16
|
+
- Add read-only clients for account, customer, location, sale, credit note, and payment resources.
|
|
17
|
+
- Add transparent responses and typed HTTP, transport, and JSON parsing errors.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PostCo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# CIN7 Core API
|
|
2
|
+
|
|
3
|
+
`cin7_core_api` is a small Ruby client for the [CIN7 Core API v2](https://dearinventory.docs.apiary.io/).
|
|
4
|
+
|
|
5
|
+
Version `0.2.0` adds explicit payment, order, invoice, journal, and webhook operations while preserving the `0.1.x` read methods. It performs one request per call, without automatic retries or accounting policy. The caller owns authorization, concurrency, document matching, financial calculations, and recovery after uncertain writes.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
Add the gem to your Gemfile:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
gem "cin7_core_api", github: "PostCo/cin7_core_api", ref: "<reviewed-full-commit-sha>"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then run:
|
|
16
|
+
|
|
17
|
+
```console
|
|
18
|
+
bundle install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Ruby 3.3 or newer is required.
|
|
22
|
+
|
|
23
|
+
The `0.2.0` source is prepared for review; this README does not assert that it has been published. Pin a reviewed source commit until a release is available.
|
|
24
|
+
|
|
25
|
+
## Authentication
|
|
26
|
+
|
|
27
|
+
Create a client using the Account ID and API Application Key from CIN7 Core's API setup page:
|
|
28
|
+
|
|
29
|
+
```ruby
|
|
30
|
+
client = Cin7CoreAPI::Client.new(
|
|
31
|
+
account_id: ENV.fetch("CIN7_CORE_ACCOUNT_ID"),
|
|
32
|
+
application_key: ENV.fetch("CIN7_CORE_APPLICATION_KEY")
|
|
33
|
+
)
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Credentials belong to one CIN7 Core company and should not be shared or logged. Configuration is held by the client instance, so an application can safely construct separate clients for different retailers.
|
|
37
|
+
|
|
38
|
+
For an approved sandbox or mock server, the URL and timeouts can be overridden:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
client = Cin7CoreAPI::Client.new(
|
|
42
|
+
account_id: "account-id",
|
|
43
|
+
application_key: "application-key",
|
|
44
|
+
base_url: "https://example.test/ExternalApi/v2/",
|
|
45
|
+
open_timeout: 2,
|
|
46
|
+
timeout: 10
|
|
47
|
+
)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
The gem returns `Cin7CoreAPI::Response` objects. Response JSON retains CIN7's original key names:
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
response = client.sales.list(page: 1, limit: 100, external_id: "shopify-order-id")
|
|
56
|
+
|
|
57
|
+
response.status # => 200
|
|
58
|
+
response.success? # => true
|
|
59
|
+
response.headers # => {"content-type" => "application/json", ...}
|
|
60
|
+
response.body["SaleList"]
|
|
61
|
+
response.body["Total"]
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Available read operations:
|
|
65
|
+
|
|
66
|
+
| Ruby operation | CIN7 Core endpoint |
|
|
67
|
+
| --- | --- |
|
|
68
|
+
| `client.me.retrieve` | `GET /me` |
|
|
69
|
+
| `client.sales.list` | `GET /saleList` |
|
|
70
|
+
| `client.sales.retrieve(id:)` | `GET /sale?ID=...` |
|
|
71
|
+
| `client.credit_notes.list` | `GET /saleCreditNoteList` |
|
|
72
|
+
| `client.credit_notes.for_sale(sale_id:)` | `GET /sale/creditnote?SaleID=...` |
|
|
73
|
+
| `client.payments.for_sale(sale_id:)` | `GET /sale/payment?SaleID=...` |
|
|
74
|
+
| `client.locations.list` | `GET /ref/location` |
|
|
75
|
+
| `client.accounts.list` | `GET /ref/account` |
|
|
76
|
+
| `client.bank_accounts.list` | `GET /ref/account/bank` |
|
|
77
|
+
| `client.customers.list` | `GET /customer` |
|
|
78
|
+
| `client.customer_credits.list` | `GET /ref/customer/credits` |
|
|
79
|
+
|
|
80
|
+
Ruby keyword arguments use `snake_case` and are explicitly translated to CIN7's parameter names:
|
|
81
|
+
|
|
82
|
+
```ruby
|
|
83
|
+
client.sales.list(
|
|
84
|
+
page: 2,
|
|
85
|
+
limit: 100,
|
|
86
|
+
updated_since: "2026-08-01T00:00:00.000",
|
|
87
|
+
order_status: "AUTHORISED"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
client.sales.retrieve(
|
|
91
|
+
id: "7d636591-9d84-4ff1-a4fb-b9ab6f8b7cd7",
|
|
92
|
+
include_transactions: true
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
client.credit_notes.for_sale(
|
|
96
|
+
sale_id: "7d636591-9d84-4ff1-a4fb-b9ab6f8b7cd7",
|
|
97
|
+
include_payment_info: true
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
client.customer_credits.list(
|
|
101
|
+
customer_id: "c786e00a-d745-4c4f-9f95-cf06149b4925",
|
|
102
|
+
show_used_credits: false
|
|
103
|
+
)
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Unknown keyword arguments raise `ArgumentError` rather than silently sending a misspelled CIN7 parameter.
|
|
107
|
+
|
|
108
|
+
`ExternalID` is an optional Cin7 field and may be null on native connector imports. It is not a guaranteed Shopify order identifier. Resolve and persist the parent sale ID and the specific invoice/credit-note task IDs using verified business references. Do not substitute a parent `SaleID` for an advanced sale's document `TaskID`.
|
|
109
|
+
|
|
110
|
+
## Pilot operations
|
|
111
|
+
|
|
112
|
+
Every operation returns the same `Cin7CoreAPI::Response` wrapper as the read methods. Payloads must be Hashes with Cin7 JSON field names as **String keys**. They are encoded as JSON, without case conversion, accounting calculations, FX rounding, or implicit document fields. Identifier presence is checked locally; Cin7 validates the remaining payload and state transitions.
|
|
113
|
+
|
|
114
|
+
| Ruby method | HTTP operation | Required payload identifiers / read filters |
|
|
115
|
+
| --- | --- | --- |
|
|
116
|
+
| `client.sales.update(payload:)` | `PUT /sale` | `ID` |
|
|
117
|
+
| `client.sales.undo(id:)` | `DELETE /sale?ID=...&Void=false` | Parent sale ID |
|
|
118
|
+
| `client.payments.create(payload:)` | `POST /sale/payment` | `TaskID` of the invoice or credit note |
|
|
119
|
+
| `client.payments.update(payload:)` | `PUT /sale/payment` | Payment `ID` |
|
|
120
|
+
| `client.payments.delete(id:)` | `DELETE /sale/payment?ID=...` | Payment ID |
|
|
121
|
+
| `client.orders.for_sale(sale_id:, **options)` | `GET /sale/order` | `sale_id`, `combine_additional_charges`, `include_product_info` |
|
|
122
|
+
| `client.orders.create(payload:)` | `POST /sale/order` | `SaleID` |
|
|
123
|
+
| `client.invoices.for_sale(sale_id:, **options)` | `GET /sale/invoice` | `sale_id`, `combine_additional_charges`, `include_product_info` |
|
|
124
|
+
| `client.invoices.create(payload:)` | `POST /sale/invoice` | `SaleID`, `TaskID` |
|
|
125
|
+
| `client.invoices.update(payload:)` | `PUT /sale/invoice` | `SaleID`, `TaskID` |
|
|
126
|
+
| `client.invoices.undo(task_id:)` | `DELETE /sale/invoice?TaskID=...&Void=false` | Invoice task ID |
|
|
127
|
+
| `client.manual_journals.for_sale(sale_id:)` | `GET /sale/manualJournal` | Parent sale ID |
|
|
128
|
+
| `client.manual_journals.create(payload:)` | `POST /sale/manualJournal` | `SaleID` |
|
|
129
|
+
| `client.journals.list(**filters)` | `GET /journal` | `page`, `limit`, `task_id`, `status`, `search` |
|
|
130
|
+
| `client.journals.retrieve(task_id:)` | `GET /journal?TaskID=...` | Journal task ID; retains the `Journals` response envelope |
|
|
131
|
+
| `client.journals.create(payload:)` | `POST /journal` | Server allocates the journal task ID |
|
|
132
|
+
| `client.journals.update(payload:)` | `PUT /journal` | `TaskID` |
|
|
133
|
+
| `client.tax_rules.list(**filters)` | `GET /ref/tax` | `page`, `limit`, `id`, `name`, `is_active`, `is_tax_for_sale`, `is_tax_for_purchase`, `account` |
|
|
134
|
+
| `client.webhooks.list` | `GET /webhooks` | No filters; returns the `Webhooks` envelope |
|
|
135
|
+
| `client.webhooks.create(payload:)` | `POST /webhooks` | Server allocates the subscription ID |
|
|
136
|
+
| `client.webhooks.update(payload:)` | `PUT /webhooks` | Subscription `ID` |
|
|
137
|
+
| `client.webhooks.delete(id:)` | `DELETE /webhooks?ID=...` | Subscription ID |
|
|
138
|
+
|
|
139
|
+
### Payments and document corrections
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
response = client.payments.create(payload: {
|
|
143
|
+
"TaskID" => matched_credit_note_task_id,
|
|
144
|
+
"Type" => "REFUND",
|
|
145
|
+
"Reference" => persisted_operation_reference,
|
|
146
|
+
"Amount" => 60.00,
|
|
147
|
+
"DatePaid" => "2026-09-29T00:00:00",
|
|
148
|
+
"Account" => merchant_liability_account_code,
|
|
149
|
+
"CurrencyRate" => 1.42541
|
|
150
|
+
})
|
|
151
|
+
payment_id = response.body.fetch("ID")
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
`PAYMENT` requires an authorized invoice; `REFUND` requires an authorized credit note. Amounts use customer-currency major units, not cents. `Account` is a chart-of-accounts code. Payments backed by `CreditID` cannot have their `Amount` or `Account` updated, and prepayments cannot be updated. The client preserves supplied FX precision, including five-place values such as `1.42541`; it does not truncate to the Blueprint's four-place annotation.
|
|
155
|
+
|
|
156
|
+
Order and invoice writes require the appropriate draft/not-available state. The method name `create` denotes POST: sale document POSTs can replace existing collections. Preserve all unrelated lines, charges, taxes and discounts. An invoice PUT can omit collections; supplying an empty collection deletes its contents. The all-zero `TaskID` explicitly requests a new invoice; the client never supplies it automatically.
|
|
157
|
+
|
|
158
|
+
Undo uses `Void=false` and can reverse downstream work and accounting. Snapshot documents/payments before removal or undo, and verify fulfilment, locks, export status, and the endpoint's eligibility first. The v2 Blueprint contains contradictory invoice-undo eligibility text; exposing the HTTP operation does not establish that it is safe for every simple or advanced sale. The client performs no automatic payment restoration, native credit-note creation, restocking, or fulfilment writes.
|
|
159
|
+
|
|
160
|
+
### Journals and setup
|
|
161
|
+
|
|
162
|
+
Sale manual-journal payloads contain `SaleID`, `Status` (`DRAFT`/`AUTHORISED`) and `Lines` of `Reference`, `Amount`, `Date`, `Debit`, `Credit`. Their amounts are in **company base currency**. Read and retain all existing lines when appending a bonus entry.
|
|
163
|
+
|
|
164
|
+
Standalone journals contain `Status` (`DRAFT`/`COMPLETED`), `Currency`, `CurrencyConversionRate`, `EffectiveDate`, `Narration`, `Notes` and `Lines` of `Debit`, `Credit`, `Reference`, `Amount`, `BaseAmount`. `Amount` uses the selected currency; `BaseAmount` uses company base currency. The caller owns conversions and rounding. `journals.list(search:)` searches `Narration`/`Notes` as well as journal number/status. Keep a durable unique business reference in searchable fields. An uncertain manual-journal result is not permission to create a standalone replacement.
|
|
165
|
+
|
|
166
|
+
Account and tax reads do not prove connector mappings. Validate active/payment-enabled liability accounts, merchant mappings, active sale tax rules, and `/me` lock/currency settings in the application. Journal availability can depend on the accounting integration; verify it before choosing a fallback.
|
|
167
|
+
|
|
168
|
+
### Webhooks
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
client.webhooks.create(payload: {
|
|
172
|
+
"Type" => "Sale/CreditNoteAuthorised",
|
|
173
|
+
"IsActive" => true,
|
|
174
|
+
"ExternalURL" => callback_url,
|
|
175
|
+
"ExternalAuthorizationType" => "bearerauth",
|
|
176
|
+
"ExternalBearerToken" => callback_token
|
|
177
|
+
})
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
The Blueprint supports bearer/basic authentication, not an HMAC signature contract. Webhooks require the Automation module and are limited to five subscriptions per event type. It documents six delivery attempts: first after one minute, then delays of 5, 10, 15, 20 and 25 minutes, followed by deactivation on repeated failure. Manage only subscriptions owned by your application. Use polling as recovery and fetch authoritative sale state after notifications; `SaleID`, `SaleTaskID`, and `TaskID` differ across event payloads.
|
|
181
|
+
|
|
182
|
+
## Errors and retries
|
|
183
|
+
|
|
184
|
+
Non-success responses raise an error with the response status, sanitized headers, and sanitized parsed body attached:
|
|
185
|
+
|
|
186
|
+
```ruby
|
|
187
|
+
begin
|
|
188
|
+
client.sales.retrieve(id: sale_id)
|
|
189
|
+
rescue Cin7CoreAPI::RateLimitError => error
|
|
190
|
+
error.status # => 429
|
|
191
|
+
error.retry_after # value of the Retry-After response header, when present
|
|
192
|
+
error.body # CIN7's parsed error body
|
|
193
|
+
end
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
Errors include:
|
|
197
|
+
|
|
198
|
+
- `Cin7CoreAPI::BadRequestError`
|
|
199
|
+
- `Cin7CoreAPI::AuthenticationError`
|
|
200
|
+
- `Cin7CoreAPI::ForbiddenError`
|
|
201
|
+
- `Cin7CoreAPI::NotFoundError`
|
|
202
|
+
- `Cin7CoreAPI::MethodNotAllowedError`
|
|
203
|
+
- `Cin7CoreAPI::RateLimitError`
|
|
204
|
+
- `Cin7CoreAPI::ServerError`
|
|
205
|
+
- `Cin7CoreAPI::TransportError`
|
|
206
|
+
- `Cin7CoreAPI::ParseError`
|
|
207
|
+
|
|
208
|
+
All inherit from `Cin7CoreAPI::Error`.
|
|
209
|
+
|
|
210
|
+
The gem does not retry requests automatically. CIN7 Core limits an API application to 60 calls per minute, and the calling application is better placed to apply queueing, idempotency, backoff, and retry policies.
|
|
211
|
+
|
|
212
|
+
For POST, PUT and DELETE, `error.ambiguous?` is true after transport failures, HTTP 5xx responses, or invalid/empty successful JSON responses (except HTTP 204). A write may already have applied. `error.request_method` is a Symbol (`:post`, `:put`, `:delete`, or `:get`), and `error.request_path` identifies the endpoint without query parameters. Reads and HTTP 4xx rejections return `ambiguous? == false`; this flag describes uncertainty about a mutation, not general retryability. HTTP 429 retains `retry_after` when present.
|
|
213
|
+
|
|
214
|
+
```ruby
|
|
215
|
+
begin
|
|
216
|
+
client.payments.create(payload: saved_payload)
|
|
217
|
+
rescue Cin7CoreAPI::Error => error
|
|
218
|
+
if error.ambiguous?
|
|
219
|
+
# Persist recovery state. Read payments for the parent sale and match the
|
|
220
|
+
# saved reference, task, type, amount, account, date and FX before retrying.
|
|
221
|
+
# A missing immediate readback does not prove the write failed.
|
|
222
|
+
else
|
|
223
|
+
# Handle the sanitized rejection according to application policy.
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Neither a payment reference nor a journal reference is a documented server-side idempotency key. The gem supplies no exactly-once guarantee. It does not switch journal strategies, undo a sale, or replay a write after failure.
|
|
229
|
+
|
|
230
|
+
### Sensitive data
|
|
231
|
+
|
|
232
|
+
The transport removes known API credentials and credential-bearing fields from response bodies/headers, including webhook tokens, passwords, usernames, authorization/cookie headers, and custom `ExternalHeaders`. It also filters known credential values echoed in other text. JSON field names stay intact; GUID identifiers are filtered only when the complete value matches a known credential. Outbound payloads are sent unchanged, and caller-owned Hashes are not mutated. Response and client/connection/resource inspection omit payloads and authentication state. Error messages omit response bodies; inspect `error.body` for sanitized details. Transport errors deliberately omit the upstream message and cause, which may contain the authenticated request.
|
|
233
|
+
|
|
234
|
+
Business `AccountID` and `BankAccountId` values remain GUIDs or null; they are distinct from the `api-auth-accountid` credential header. Callback `ExternalURL` values retain their spelling and escaping for subscription matching. URL userinfo and sensitive query/fragment parameters are filtered, as are complete credential values in URL components; incidental substrings from other subscriptions' credentials do not rewrite callback identity. URL credentials are also removed from free-text echoes. Malformed callback URLs are filtered entirely.
|
|
235
|
+
|
|
236
|
+
Keep webhook credentials in your own encrypted configuration: sanitized webhook responses are **not** suitable for round-tripping as update payloads. Redaction targets credentials; ordinary business data and customer information still require appropriate application logging controls.
|
|
237
|
+
|
|
238
|
+
## Development
|
|
239
|
+
|
|
240
|
+
```console
|
|
241
|
+
bundle install
|
|
242
|
+
bundle exec rake
|
|
243
|
+
bundle exec rake build
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
The default Rake task runs the RSpec suite and Standard Ruby.
|
|
247
|
+
|
|
248
|
+
For a checkout containing untracked research scripts, lint only the intended gem source, not research artifacts:
|
|
249
|
+
|
|
250
|
+
```console
|
|
251
|
+
mise exec -- bundle exec rspec
|
|
252
|
+
mise exec -- bundle exec standardrb --cache false $(git ls-files '*.rb' '*.gemspec' Gemfile Rakefile)
|
|
253
|
+
mise exec -- bundle exec rake build
|
|
254
|
+
```
|
|
255
|
+
|
|
256
|
+
Stage intended new source/spec files before using the tracked-file lint command. CI tests Ruby 3.3 and 3.4. Specs use Faraday's test adapter and do not verify live accounting behavior. Inspect the built gem's file list before release: the gemspec packages only `lib/**/*.rb`, README, changelog and license. Preserve untracked research files. Publication and push require a separate release decision; RubyGems MFA remains required.
|
|
257
|
+
|
|
258
|
+
## CIN7 references
|
|
259
|
+
|
|
260
|
+
- [CIN7 Core API v2 reference](https://dearinventory.docs.apiary.io/)
|
|
261
|
+
- [Connecting to the CIN7 Core API](https://help.core.cin7.com/hc/en-us/articles/9982480315407-Connecting-to-the-Cin7-Core-API)
|
|
262
|
+
- [CIN7 Core Shopify integration](https://help.core.cin7.com/hc/en-us/articles/11797034961039-Shopify-settings)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cin7CoreAPI
|
|
4
|
+
class Client
|
|
5
|
+
DEFAULT_BASE_URL = "https://inventory.dearsystems.com/ExternalApi/v2/"
|
|
6
|
+
DEFAULT_OPEN_TIMEOUT = 5
|
|
7
|
+
DEFAULT_TIMEOUT = 30
|
|
8
|
+
|
|
9
|
+
attr_reader :connection
|
|
10
|
+
|
|
11
|
+
def initialize(
|
|
12
|
+
account_id:,
|
|
13
|
+
application_key:,
|
|
14
|
+
base_url: DEFAULT_BASE_URL,
|
|
15
|
+
open_timeout: DEFAULT_OPEN_TIMEOUT,
|
|
16
|
+
timeout: DEFAULT_TIMEOUT,
|
|
17
|
+
adapter: Faraday.default_adapter
|
|
18
|
+
)
|
|
19
|
+
validate_credential!(:account_id, account_id)
|
|
20
|
+
validate_credential!(:application_key, application_key)
|
|
21
|
+
|
|
22
|
+
@connection = Connection.new(
|
|
23
|
+
account_id: account_id,
|
|
24
|
+
application_key: application_key,
|
|
25
|
+
base_url: base_url,
|
|
26
|
+
open_timeout: open_timeout,
|
|
27
|
+
timeout: timeout,
|
|
28
|
+
adapter: adapter
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def me
|
|
33
|
+
@me ||= Resources::Me.new(connection)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def sales
|
|
37
|
+
@sales ||= Resources::Sales.new(connection)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def credit_notes
|
|
41
|
+
@credit_notes ||= Resources::CreditNotes.new(connection)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def payments
|
|
45
|
+
@payments ||= Resources::Payments.new(connection)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def locations
|
|
49
|
+
@locations ||= Resources::Locations.new(connection)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def accounts
|
|
53
|
+
@accounts ||= Resources::Accounts.new(connection)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def bank_accounts
|
|
57
|
+
@bank_accounts ||= Resources::BankAccounts.new(connection)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def customers
|
|
61
|
+
@customers ||= Resources::Customers.new(connection)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def customer_credits
|
|
65
|
+
@customer_credits ||= Resources::CustomerCredits.new(connection)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def orders
|
|
69
|
+
@orders ||= Resources::Orders.new(connection)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def invoices
|
|
73
|
+
@invoices ||= Resources::Invoices.new(connection)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def manual_journals
|
|
77
|
+
@manual_journals ||= Resources::ManualJournals.new(connection)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def journals
|
|
81
|
+
@journals ||= Resources::Journals.new(connection)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def tax_rules
|
|
85
|
+
@tax_rules ||= Resources::TaxRules.new(connection)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def webhooks
|
|
89
|
+
@webhooks ||= Resources::Webhooks.new(connection)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def inspect
|
|
93
|
+
"#<#{self.class}>"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private
|
|
97
|
+
|
|
98
|
+
def validate_credential!(name, value)
|
|
99
|
+
return if value.is_a?(String) && !value.strip.empty?
|
|
100
|
+
|
|
101
|
+
raise ArgumentError, "#{name} must be a non-empty String"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Cin7CoreAPI
|
|
7
|
+
class Connection
|
|
8
|
+
ERROR_CLASSES = {
|
|
9
|
+
400 => BadRequestError,
|
|
10
|
+
401 => AuthenticationError,
|
|
11
|
+
403 => ForbiddenError,
|
|
12
|
+
404 => NotFoundError,
|
|
13
|
+
405 => MethodNotAllowedError,
|
|
14
|
+
429 => RateLimitError
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(account_id:, application_key:, base_url:, open_timeout:, timeout:, adapter:)
|
|
18
|
+
@redactor = Redactor.new([account_id, application_key])
|
|
19
|
+
@http = Faraday.new(url: normalize_base_url(base_url)) do |connection|
|
|
20
|
+
connection.headers["Accept"] = "application/json"
|
|
21
|
+
connection.headers["Content-Type"] = "application/json"
|
|
22
|
+
connection.headers["User-Agent"] = "cin7_core_api/#{VERSION}"
|
|
23
|
+
connection.headers["api-auth-accountid"] = account_id
|
|
24
|
+
connection.headers["api-auth-applicationkey"] = application_key
|
|
25
|
+
connection.options.open_timeout = open_timeout
|
|
26
|
+
connection.options.timeout = timeout
|
|
27
|
+
connection.adapter(*Array(adapter))
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def get(path, params: {})
|
|
32
|
+
request(:get, path, params: params)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def post(path, payload:)
|
|
36
|
+
request(:post, path, payload: payload)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def put(path, payload:)
|
|
40
|
+
request(:put, path, payload: payload)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete(path, params: {})
|
|
44
|
+
request(:delete, path, params: params)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def inspect
|
|
48
|
+
"#<#{self.class}>"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def normalize_base_url(base_url)
|
|
54
|
+
base_url.end_with?("/") ? base_url : "#{base_url}/"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def request(method, path, params: {}, payload: nil)
|
|
58
|
+
# Serialize before dispatch: invalid input is not an ambiguous remote write.
|
|
59
|
+
body = JSON.generate(payload) unless payload.nil?
|
|
60
|
+
redactor = @redactor.with_sensitive_values(payload)
|
|
61
|
+
context = {request_method: method, request_path: redactor.filter(path.split("?").first)}
|
|
62
|
+
write = method != :get
|
|
63
|
+
|
|
64
|
+
raw_response = @http.run_request(method, path, body, nil) do |request|
|
|
65
|
+
request.params.update(params)
|
|
66
|
+
end
|
|
67
|
+
parsed_body, valid_json = parse_body(raw_response.body)
|
|
68
|
+
values = {"headers" => normalized_headers(raw_response.headers), "body" => parsed_body}
|
|
69
|
+
sanitized = redactor.with_sensitive_values(values).filter(values)
|
|
70
|
+
response = Response.new(status: raw_response.status, headers: sanitized.fetch("headers"), body: sanitized.fetch("body"))
|
|
71
|
+
|
|
72
|
+
raise_for_status!(response, context, write: write)
|
|
73
|
+
if response.status != 204 && (!valid_json || (write && parsed_body.nil?))
|
|
74
|
+
raise ParseError.new("CIN7 Core returned invalid or empty JSON", response: response, ambiguous: write, **context)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
response
|
|
78
|
+
rescue Faraday::Error => error
|
|
79
|
+
# Faraday exceptions can retain the entire authenticated request in their cause.
|
|
80
|
+
raise TransportError.new("CIN7 Core transport failed (#{error.class})", ambiguous: write, **context), cause: nil
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def normalized_headers(headers)
|
|
84
|
+
headers.to_h.transform_keys { |key| key.to_s.downcase }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def parse_body(body)
|
|
88
|
+
return [nil, true] if body.nil? || body.empty?
|
|
89
|
+
|
|
90
|
+
[JSON.parse(body), true]
|
|
91
|
+
rescue JSON::ParserError
|
|
92
|
+
[body, false]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def raise_for_status!(response, context, write:)
|
|
96
|
+
return if response.success?
|
|
97
|
+
|
|
98
|
+
error_class = ERROR_CLASSES.fetch(response.status) do
|
|
99
|
+
(response.status >= 500) ? ServerError : Error
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Details remain available in the sanitized body, never in log-friendly messages.
|
|
103
|
+
raise error_class.new("CIN7 Core request failed with HTTP #{response.status}",
|
|
104
|
+
response: response, ambiguous: write && response.status >= 500, **context)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Cin7CoreAPI
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :response, :request_method, :request_path
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, response: nil, ambiguous: false, request_method: nil, request_path: nil)
|
|
8
|
+
@response = response
|
|
9
|
+
@ambiguous = ambiguous
|
|
10
|
+
@request_method = request_method
|
|
11
|
+
@request_path = request_path
|
|
12
|
+
super(message)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def ambiguous?
|
|
16
|
+
@ambiguous
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def status
|
|
20
|
+
response&.status
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def headers
|
|
24
|
+
response&.headers
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def body
|
|
28
|
+
response&.body
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def retry_after
|
|
32
|
+
headers&.fetch("retry-after", nil)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
class BadRequestError < Error; end
|
|
37
|
+
class AuthenticationError < Error; end
|
|
38
|
+
class ForbiddenError < Error; end
|
|
39
|
+
class NotFoundError < Error; end
|
|
40
|
+
class MethodNotAllowedError < Error; end
|
|
41
|
+
class RateLimitError < Error; end
|
|
42
|
+
class ServerError < Error; end
|
|
43
|
+
class TransportError < Error; end
|
|
44
|
+
class ParseError < Error; end
|
|
45
|
+
end
|