moco-ruby 1.2.0 → 1.4.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/.rubocop.yml +1 -0
- data/CHANGELOG.md +39 -0
- data/Gemfile.lock +11 -10
- data/README.md +145 -5
- data/Rakefile +6 -0
- data/lib/moco/client.rb +13 -2
- data/lib/moco/collection_proxy.rb +12 -6
- data/lib/moco/connection.rb +8 -2
- data/lib/moco/entities/absence_request.rb +10 -0
- data/lib/moco/entities/approval.rb +14 -0
- data/lib/moco/entities/base_entity.rb +10 -5
- data/lib/moco/entities/catalog_service.rb +13 -0
- data/lib/moco/entities/catalog_service_item.rb +7 -0
- data/lib/moco/entities/company.rb +10 -0
- data/lib/moco/entities/invoice.rb +5 -16
- data/lib/moco/entities/invoice_attachment.rb +34 -0
- data/lib/moco/entities/letter_paper.rb +23 -0
- data/lib/moco/entities/offer.rb +15 -9
- data/lib/moco/entities/offer_approval.rb +1 -1
- data/lib/moco/entities/offer_attachment.rb +34 -0
- data/lib/moco/entities/offer_confirmation.rb +18 -0
- data/lib/moco/entities/planned_purchase.rb +18 -0
- data/lib/moco/entities/project.rb +14 -5
- data/lib/moco/entities/purchase.rb +3 -3
- data/lib/moco/entities/purchase_budget.rb +6 -3
- data/lib/moco/entities/purchase_draft.rb +11 -4
- data/lib/moco/entities/recurring_expense.rb +8 -0
- data/lib/moco/entities/revenue_category.rb +10 -0
- data/lib/moco/entities/session.rb +58 -0
- data/lib/moco/entities/todo.rb +7 -0
- data/lib/moco/entities.rb +5 -5
- data/lib/moco/nested_collection_proxy.rb +3 -2
- data/lib/moco/sync.rb +7 -7
- data/lib/moco/version.rb +1 -1
- data/lib/moco.rb +11 -0
- data/sync_activity.rb +1 -1
- metadata +13 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 05ee5448c599c6d3aa08e1b3ee7f39937fdd83504e1448058101442f2ea0b9f8
|
|
4
|
+
data.tar.gz: 66fef5ab73ebc81eaa8a8559ec4fd492fccf75e301ff8e0c7de7e3f0a1442caa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 693ac37ba7c053009c6c86e390e34acc76a63c4f0bf63e26c42da9045b26447a8c032753dc86c21f324ac49fbe9f8fa387d65aa2d0093dd5335b268510d976e0
|
|
7
|
+
data.tar.gz: 8f6fe03c4d08fe908ea307c5ca00133b033ebb532100b2e40974a39ec64b2b29ebea3ce2f6ad2cc1836444fa82b4607944203cc63e5e53f03f8a673963496910
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.4.0] - 2026-10-06
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Offer confirmations with CRUD and PDF downloads; revenue categories with CRUD.
|
|
9
|
+
- Planned purchases, invoice/purchase approvals, absence requests, and read-only to-dos.
|
|
10
|
+
- Company archive/unarchive, recurring expense execution, purchase draft PDF downloads,
|
|
11
|
+
purchase budget annual targets, catalog service item operations, and offer customer approval helpers.
|
|
12
|
+
- Planned-versus-tracked report and optional Bearer authentication (`auth_scheme: :bearer`).
|
|
13
|
+
- Mocked regression tests for API resources, actions, fields, and filters; `rake test` runs the mocked suite.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Entity updates accept fields absent from the response; saved and reloaded entities expose response attributes.
|
|
17
|
+
- Nested entities use their parent collection for save, reload, and deletion.
|
|
18
|
+
- Account-level nested resources use their parent's API path.
|
|
19
|
+
|
|
20
|
+
### Documentation
|
|
21
|
+
- Document offer editing/deletion, draft creation/deletion, writable budgets and revenue
|
|
22
|
+
categories, and additional API fields and filters from the current OpenAPI reference.
|
|
23
|
+
|
|
24
|
+
## [1.3.0] - 2026-05-23
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
- New entities to cover remaining MOCO API v1 resources: `LetterPaper`
|
|
28
|
+
(read-only letterhead listing), `InvoiceAttachment`, `OfferAttachment`,
|
|
29
|
+
and `Session` (API key exchange/verification).
|
|
30
|
+
- `MOCO::Session.create(subdomain:, email:, password:)` exchanges credentials
|
|
31
|
+
for an API key without requiring an existing `Client`.
|
|
32
|
+
- `moco.session.verify` confirms the configured API key and returns the user
|
|
33
|
+
identity.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
- `Invoice#attachments` and `Offer#attachments` now return a
|
|
37
|
+
`NestedCollectionProxy` of typed `InvoiceAttachment` / `OfferAttachment`
|
|
38
|
+
entities, replacing the previous raw `add_attachment` / `delete_attachment`
|
|
39
|
+
helpers. Use `invoice.attachments.create(attachment: { filename:, base64: })`
|
|
40
|
+
and `invoice.attachments.find(id).destroy` instead.
|
|
41
|
+
- Documentation URLs updated from `hundertzehn.github.io/mocoapp-api-docs`
|
|
42
|
+
(legacy) to `docs.mocoapp.com/api/docs/v1` (current OpenAPI reference).
|
|
43
|
+
|
|
5
44
|
## [1.2.0] - 2026-01-14
|
|
6
45
|
|
|
7
46
|
### Added
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
moco-ruby (1.
|
|
4
|
+
moco-ruby (1.4.0)
|
|
5
5
|
activesupport (>= 7.0)
|
|
6
6
|
faraday (>= 2.0)
|
|
7
7
|
fuzzy_match (~> 2.1.0)
|
|
@@ -9,7 +9,7 @@ PATH
|
|
|
9
9
|
GEM
|
|
10
10
|
remote: https://rubygems.org/
|
|
11
11
|
specs:
|
|
12
|
-
activesupport (8.1.
|
|
12
|
+
activesupport (8.1.3)
|
|
13
13
|
base64
|
|
14
14
|
bigdecimal
|
|
15
15
|
concurrent-ruby (~> 1.0, >= 1.3.1)
|
|
@@ -22,11 +22,11 @@ GEM
|
|
|
22
22
|
securerandom (>= 0.3)
|
|
23
23
|
tzinfo (~> 2.0, >= 2.0.5)
|
|
24
24
|
uri (>= 0.13.1)
|
|
25
|
-
addressable (2.
|
|
25
|
+
addressable (2.9.0)
|
|
26
26
|
public_suffix (>= 2.0.2, < 8.0)
|
|
27
27
|
ast (2.4.3)
|
|
28
28
|
base64 (0.3.0)
|
|
29
|
-
bigdecimal (4.
|
|
29
|
+
bigdecimal (4.1.2)
|
|
30
30
|
concurrent-ruby (1.3.6)
|
|
31
31
|
connection_pool (3.0.2)
|
|
32
32
|
crack (1.0.1)
|
|
@@ -34,21 +34,22 @@ GEM
|
|
|
34
34
|
rexml
|
|
35
35
|
dotenv (2.8.1)
|
|
36
36
|
drb (2.2.3)
|
|
37
|
-
faraday (2.14.
|
|
37
|
+
faraday (2.14.2)
|
|
38
38
|
faraday-net_http (>= 2.0, < 3.5)
|
|
39
39
|
json
|
|
40
40
|
logger
|
|
41
|
-
faraday-net_http (3.4.
|
|
41
|
+
faraday-net_http (3.4.3)
|
|
42
42
|
net-http (~> 0.5)
|
|
43
43
|
fuzzy_match (2.1.0)
|
|
44
44
|
hashdiff (1.2.1)
|
|
45
45
|
i18n (1.14.8)
|
|
46
46
|
concurrent-ruby (~> 1.0)
|
|
47
|
-
json (2.
|
|
47
|
+
json (2.19.5)
|
|
48
48
|
language_server-protocol (3.17.0.5)
|
|
49
49
|
lint_roller (1.1.0)
|
|
50
50
|
logger (1.7.0)
|
|
51
|
-
minitest (6.0.
|
|
51
|
+
minitest (6.0.6)
|
|
52
|
+
drb (~> 2.0)
|
|
52
53
|
prism (~> 1.5)
|
|
53
54
|
net-http (0.9.1)
|
|
54
55
|
uri (>= 0.11.1)
|
|
@@ -57,8 +58,8 @@ GEM
|
|
|
57
58
|
ast (~> 2.4.1)
|
|
58
59
|
racc
|
|
59
60
|
power_assert (3.0.1)
|
|
60
|
-
prism (1.
|
|
61
|
-
public_suffix (7.0.
|
|
61
|
+
prism (1.9.0)
|
|
62
|
+
public_suffix (7.0.5)
|
|
62
63
|
racc (1.8.1)
|
|
63
64
|
rainbow (3.1.1)
|
|
64
65
|
rake (13.3.1)
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://badge.fury.io/rb/moco-ruby)
|
|
4
4
|
|
|
5
|
-
A Ruby Gem to interact with the [MOCO API](https://
|
|
5
|
+
A Ruby Gem to interact with the [MOCO API](https://docs.mocoapp.com/api/docs/v1). This gem provides a modern, Ruby-esque interface (`MOCO::Client`) for interacting with the MOCO API.
|
|
6
6
|
|
|
7
7
|
## Installation
|
|
8
8
|
|
|
@@ -176,6 +176,136 @@ profile = moco.profile
|
|
|
176
176
|
puts "Logged in as: #{profile.firstname} #{profile.lastname}"
|
|
177
177
|
```
|
|
178
178
|
|
|
179
|
+
### Sessions
|
|
180
|
+
|
|
181
|
+
Exchange email/password for an API key, or verify an existing key:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
# Exchange credentials for an API key (no Client needed)
|
|
185
|
+
session = MOCO::Session.create(
|
|
186
|
+
subdomain: "your-subdomain",
|
|
187
|
+
email: "you@example.com",
|
|
188
|
+
password: "secret"
|
|
189
|
+
)
|
|
190
|
+
api_key = session["api_key"]
|
|
191
|
+
|
|
192
|
+
# Verify the configured API key for an existing client
|
|
193
|
+
identity = moco.session.verify
|
|
194
|
+
puts "Authenticated as user #{identity['id']} (#{identity['uuid']})"
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Invoice / Offer Attachments
|
|
198
|
+
|
|
199
|
+
Attachments are nested under the parent document and use base64-encoded uploads:
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
require "base64"
|
|
203
|
+
|
|
204
|
+
invoice = moco.invoices.find(123)
|
|
205
|
+
invoice.attachments.all
|
|
206
|
+
invoice.attachments.create(
|
|
207
|
+
attachment: {
|
|
208
|
+
filename: "appendix.pdf",
|
|
209
|
+
base64: Base64.strict_encode64(File.read("appendix.pdf"))
|
|
210
|
+
}
|
|
211
|
+
)
|
|
212
|
+
invoice.attachments.find(42).destroy
|
|
213
|
+
|
|
214
|
+
# Offer attachments work the same way:
|
|
215
|
+
moco.offers.find(123).attachments.create(
|
|
216
|
+
attachment: { filename: "quote-details.pdf", base64: ... }
|
|
217
|
+
)
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### API Resources and Actions (2025–2026)
|
|
221
|
+
|
|
222
|
+
The [OpenAPI reference](https://docs.mocoapp.com/api/docs/v1) defines supported
|
|
223
|
+
operations, required fields, permissions, and filters. Collections accept API fields
|
|
224
|
+
unchanged through `create`, `update`, and `where`; response fields are available as
|
|
225
|
+
entity attributes. Only call operations documented for that resource (for example,
|
|
226
|
+
`todos` is read-only, and invoices do not have a general update endpoint).
|
|
227
|
+
|
|
228
|
+
```ruby
|
|
229
|
+
# Offer confirmations: list, find, create, update, delete, and PDF download
|
|
230
|
+
confirmation = moco.offer_confirmations.create(offer_id: 123, title: "Order confirmation")
|
|
231
|
+
confirmation.update(footer_raw: "<p>Thank you!</p>")
|
|
232
|
+
confirmation.pdf(letter_paper_id: 42)
|
|
233
|
+
moco.offers.find(123).confirmations.all
|
|
234
|
+
|
|
235
|
+
# Offers support full updates and deletion
|
|
236
|
+
moco.offers.update(123, title: "Website proposal", items: [...])
|
|
237
|
+
moco.offers.delete(123)
|
|
238
|
+
|
|
239
|
+
# Revenue categories: list, find, create, update, and delete
|
|
240
|
+
moco.revenue_categories.where(term: "Consulting").all
|
|
241
|
+
moco.revenue_categories.create(name: "Consulting", assignment_kind: "tasks_and_expenses")
|
|
242
|
+
|
|
243
|
+
# Purchase drafts: list, find, create, delete, and PDF download
|
|
244
|
+
moco.purchase_drafts.create(
|
|
245
|
+
title: "Supplier invoice",
|
|
246
|
+
file: { filename: "invoice.pdf", base64: Base64.strict_encode64(File.binread("invoice.pdf")) }
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Company archiving and early execution of recurring additional services
|
|
250
|
+
moco.companies.find(123).archive
|
|
251
|
+
moco.companies.find(123).unarchive
|
|
252
|
+
moco.projects.find(123).recurring_expenses.find(456).recur
|
|
253
|
+
|
|
254
|
+
# Purchase budgets support creation, updates, deletion, and annual targets
|
|
255
|
+
moco.purchase_budgets.where(year: 2026).all
|
|
256
|
+
budget = moco.purchase_budgets.create(title: "Infrastructure")
|
|
257
|
+
budget.update_target(target_amount: 12_000, year: 2026)
|
|
258
|
+
|
|
259
|
+
# Planned purchases support list, find, create, update, and delete
|
|
260
|
+
moco.planned_purchases.create(
|
|
261
|
+
date: "2026-08-01", title: "Hosting", net_total: 100, currency: "EUR", user_id: 123
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Invoice/purchase approvals: list, find, create, delete, approve, and deny
|
|
265
|
+
approval = moco.approvals.create(target_type: "purchase", target_id: 123, approver_id: 456)
|
|
266
|
+
approval.approve
|
|
267
|
+
# Or: approval.deny(note: "Please check the amount")
|
|
268
|
+
|
|
269
|
+
# Absence requests: list, find, create, and delete
|
|
270
|
+
moco.absence_requests.where(user_id: 123).all
|
|
271
|
+
moco.absence_requests.find(456).destroy(comment: "Cancelled")
|
|
272
|
+
|
|
273
|
+
# To-dos are read-only
|
|
274
|
+
moco.todos.where(completed: false).all
|
|
275
|
+
moco.todos.find(123)
|
|
276
|
+
|
|
277
|
+
# Catalog items are listed in the parent; individual items support CRUD
|
|
278
|
+
service = moco.catalog_services.find(123)
|
|
279
|
+
service.items.all
|
|
280
|
+
service.items.create(type: "item", title: "Consulting", quantity: 1, unit: "h", unit_price: 150)
|
|
281
|
+
service.items.find(456).update(unit_cost: 75)
|
|
282
|
+
|
|
283
|
+
# Online customer approval for offers
|
|
284
|
+
moco.offers.find(123).activate_customer_approval
|
|
285
|
+
moco.offers.find(123).customer_approval
|
|
286
|
+
moco.offers.find(123).deactivate_customer_approval
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
New fields need no schema configuration. Examples include `skip_favorite` on projects,
|
|
290
|
+
`task_id` on planning entries, `invoice_format`, `default_payment_means`,
|
|
291
|
+
`customer_vat_code_id`, `supplier_vat_code_id`, `bank_owner`, and `bank_bic` on companies,
|
|
292
|
+
`welcome_email` on users, and `created_at` on comments. User creation does not require
|
|
293
|
+
`password`. Filter users by `email` and schedules by `absence_request_id`.
|
|
294
|
+
|
|
295
|
+
Use `vat_code_id` on invoices and purchase items rather than the deprecated `tax`
|
|
296
|
+
value. Offer/invoice items accept `revenue_category_id`; offer items also accept
|
|
297
|
+
`unit_cost`. Project expenses accept `revenue_category_id`, `service_period_from`,
|
|
298
|
+
`service_period_to`, `planned_purchase_date`, and `cost_total_planned`.
|
|
299
|
+
Response fields such as activity `worked_seconds`, reminder `status` / `file_url`,
|
|
300
|
+
invoice `creditor_reference`, comment `impersonating_user`, and draft `net_total` /
|
|
301
|
+
`gross_total` are exposed automatically.
|
|
302
|
+
|
|
303
|
+
Bearer authentication is optional; Token authentication is the default:
|
|
304
|
+
|
|
305
|
+
```ruby
|
|
306
|
+
moco = MOCO::Client.new(subdomain: "your-subdomain", api_key: "your-api-key", auth_scheme: :bearer)
|
|
307
|
+
```
|
|
308
|
+
|
|
179
309
|
### Reports
|
|
180
310
|
|
|
181
311
|
Access read-only report endpoints:
|
|
@@ -190,6 +320,11 @@ utilization = moco.reports.utilization(from: "2024-01-01", to: "2024-12-31")
|
|
|
190
320
|
# Financial reports
|
|
191
321
|
cashflow = moco.reports.cashflow(from: "2024-01-01", to: "2024-03-31")
|
|
192
322
|
finance = moco.reports.finance(from: "2024-01-01", to: "2024-03-31")
|
|
323
|
+
|
|
324
|
+
# Planned versus tracked hours (optional date range and filters)
|
|
325
|
+
planned_vs_tracked = moco.reports.planned_vs_tracked(
|
|
326
|
+
from: "2026-01-01", to: "2026-01-31", user_ids: "123,456", project_id: 789
|
|
327
|
+
)
|
|
193
328
|
```
|
|
194
329
|
|
|
195
330
|
### Supported Entities
|
|
@@ -200,21 +335,24 @@ The gem supports all MOCO API entities with a Ruby-esque interface:
|
|
|
200
335
|
`Project`, `Activity`, `User`, `Company`, `Task`, `Invoice`, `Deal`, `Expense`, `WebHook`, `Schedule`, `Presence`, `Holiday`, `PlanningEntry`
|
|
201
336
|
|
|
202
337
|
**Business:**
|
|
203
|
-
`Contact`, `Offer`, `Purchase`, `Receipt`, `Comment`, `Tag`, `Tagging`, `DealCategory`, `ProjectGroup`, `Unit`
|
|
338
|
+
`Contact`, `Offer`, `OfferConfirmation`, `Purchase`, `PlannedPurchase`, `Receipt`, `Comment`, `Tag`, `Tagging`, `DealCategory`, `ProjectGroup`, `Unit`, `Approval`, `AbsenceRequest`, `Todo` (read-only)
|
|
204
339
|
|
|
205
340
|
**Account Settings:**
|
|
206
341
|
`CatalogService`, `CustomProperty`, `ExpenseTemplate`, `FixedCost`, `HourlyRate`, `InternalHourlyRate`, `TaskTemplate`, `UserRole`
|
|
207
342
|
|
|
208
343
|
**Financial:**
|
|
209
|
-
`VatCodeSale`, `VatCodePurchase`, `PurchaseCategory`, `PurchaseDraft`, `PurchaseBudget`, `PurchasePayment`
|
|
344
|
+
`VatCodeSale`, `VatCodePurchase`, `RevenueCategory`, `PurchaseCategory`, `PurchaseDraft`, `PurchaseBudget`, `PurchasePayment`
|
|
210
345
|
|
|
211
346
|
**Bookkeeping:**
|
|
212
347
|
`InvoiceBookkeepingExport`, `PurchaseBookkeepingExport`
|
|
213
348
|
|
|
214
349
|
**Nested Resources:**
|
|
215
|
-
`Employment`, `WorkTimeAdjustment`, `ProjectContract`, `PaymentSchedule`, `RecurringExpense`, `InvoicePayment`, `InvoiceReminder`, `OfferApproval`
|
|
350
|
+
`Employment`, `WorkTimeAdjustment`, `ProjectContract`, `PaymentSchedule`, `RecurringExpense`, `InvoicePayment`, `InvoiceReminder`, `OfferApproval`, `InvoiceAttachment`, `OfferAttachment`, `CatalogServiceItem`
|
|
351
|
+
|
|
352
|
+
**Misc:**
|
|
353
|
+
`LetterPaper` (read-only), `Session` (for API key exchange/verification)
|
|
216
354
|
|
|
217
|
-
Access them via the client using their plural, snake_case names (e.g., `moco.planning_entries`, `moco.vat_code_sales`).
|
|
355
|
+
Access them via the client using their plural, snake_case names (e.g., `moco.planning_entries`, `moco.vat_code_sales`, `moco.letter_papers`). Attachments are accessed via the parent: `invoice.attachments`, `offer.attachments`.
|
|
218
356
|
|
|
219
357
|
## Utilities
|
|
220
358
|
|
|
@@ -279,6 +417,8 @@ The gem includes unit tests (mocked) and integration tests (live API):
|
|
|
279
417
|
|
|
280
418
|
```bash
|
|
281
419
|
# Unit tests (mocked, fast)
|
|
420
|
+
bundle exec rake test
|
|
421
|
+
bundle exec ruby -Ilib -Itest test/test_api_updates.rb
|
|
282
422
|
bundle exec ruby -Ilib -Itest test/test_v2_api.rb
|
|
283
423
|
bundle exec ruby -Ilib -Itest test/test_new_entities.rb
|
|
284
424
|
|
data/Rakefile
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
require "bundler/gem_tasks"
|
|
4
4
|
require "rubocop/rake_task"
|
|
5
|
+
require "rake/testtask"
|
|
6
|
+
|
|
7
|
+
Rake::TestTask.new(:test) do |task|
|
|
8
|
+
task.libs << "lib" << "test"
|
|
9
|
+
task.test_files = FileList["test/test_v2_api.rb", "test/test_new_entities.rb", "test/test_api_updates.rb"]
|
|
10
|
+
end
|
|
5
11
|
|
|
6
12
|
RuboCop::RakeTask.new
|
|
7
13
|
|
data/lib/moco/client.rb
CHANGED
|
@@ -6,8 +6,8 @@ module MOCO
|
|
|
6
6
|
class Client
|
|
7
7
|
attr_reader :connection
|
|
8
8
|
|
|
9
|
-
def initialize(subdomain:, api_key:, debug: false)
|
|
10
|
-
@connection = Connection.new(self, subdomain, api_key, debug: debug)
|
|
9
|
+
def initialize(subdomain:, api_key:, debug: false, auth_scheme: :token)
|
|
10
|
+
@connection = Connection.new(self, subdomain, api_key, debug: debug, auth_scheme: auth_scheme)
|
|
11
11
|
@collections = {}
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -42,6 +42,13 @@ module MOCO
|
|
|
42
42
|
Profile.new(self, get("profile"))
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Session helper for verifying the configured API key.
|
|
46
|
+
# Use MOCO::Session.create(subdomain:, email:, password:) to exchange
|
|
47
|
+
# credentials for an API key without a Client.
|
|
48
|
+
def session
|
|
49
|
+
@session ||= Session.new(self)
|
|
50
|
+
end
|
|
51
|
+
|
|
45
52
|
# Reports namespace for read-only report endpoints
|
|
46
53
|
def reports
|
|
47
54
|
@reports ||= ReportsProxy.new(self)
|
|
@@ -95,6 +102,10 @@ module MOCO
|
|
|
95
102
|
@client.get("report/finance", params)
|
|
96
103
|
end
|
|
97
104
|
|
|
105
|
+
def planned_vs_tracked(from: nil, to: nil, **filters)
|
|
106
|
+
@client.get("report/planned_vs_tracked", { from:, to: }.merge(filters).compact)
|
|
107
|
+
end
|
|
108
|
+
|
|
98
109
|
# Get utilization report
|
|
99
110
|
# @param from [String] start date (YYYY-MM-DD) - required
|
|
100
111
|
# @param to [String] end date (YYYY-MM-DD) - required
|
|
@@ -112,18 +112,18 @@ module MOCO
|
|
|
112
112
|
klass = entity_class
|
|
113
113
|
return nil unless klass && klass <= MOCO::BaseEntity
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
build_entity(client.post(@base_path, attributes))
|
|
116
116
|
end
|
|
117
117
|
|
|
118
118
|
def update(id, attributes)
|
|
119
119
|
klass = entity_class
|
|
120
120
|
return nil unless klass && klass <= MOCO::BaseEntity
|
|
121
121
|
|
|
122
|
-
|
|
122
|
+
build_entity(client.put("#{@base_path}/#{id}", attributes))
|
|
123
123
|
end
|
|
124
124
|
|
|
125
|
-
def delete(id)
|
|
126
|
-
client.delete("#{@base_path}/#{id}")
|
|
125
|
+
def delete(id, params = {})
|
|
126
|
+
client.delete("#{@base_path}/#{id}", params)
|
|
127
127
|
end
|
|
128
128
|
|
|
129
129
|
# --- Internal Methods ---
|
|
@@ -178,6 +178,12 @@ module MOCO
|
|
|
178
178
|
|
|
179
179
|
private
|
|
180
180
|
|
|
181
|
+
def build_entity(data)
|
|
182
|
+
entity = entity_class.new(client, data)
|
|
183
|
+
entity.collection = self
|
|
184
|
+
entity
|
|
185
|
+
end
|
|
186
|
+
|
|
181
187
|
# Wraps the raw API response (Hash or Array of Hashes) into entity objects.
|
|
182
188
|
def wrap_response(response_body)
|
|
183
189
|
klass = entity_class
|
|
@@ -186,10 +192,10 @@ module MOCO
|
|
|
186
192
|
|
|
187
193
|
if response_body.is_a?(Array)
|
|
188
194
|
# Convert array of hashes to array of entity objects
|
|
189
|
-
response_body.map { |item_hash|
|
|
195
|
+
response_body.map { |item_hash| build_entity(item_hash) if item_hash.is_a?(Hash) }.compact
|
|
190
196
|
elsif response_body.is_a?(Hash)
|
|
191
197
|
# Wrap single hash response in an array for consistency internally
|
|
192
|
-
[
|
|
198
|
+
[build_entity(response_body)]
|
|
193
199
|
else
|
|
194
200
|
# Handle unexpected response types (like the String error we saw)
|
|
195
201
|
warn "Warning: Unexpected API response type received in wrap_response: #{response_body.class}. Expected Hash or Array."
|
data/lib/moco/connection.rb
CHANGED
|
@@ -9,7 +9,9 @@ module MOCO
|
|
|
9
9
|
class Connection
|
|
10
10
|
attr_reader :client, :subdomain, :api_key, :debug
|
|
11
11
|
|
|
12
|
-
def initialize(client, subdomain, api_key, debug: false)
|
|
12
|
+
def initialize(client, subdomain, api_key, debug: false, auth_scheme: :token)
|
|
13
|
+
raise ArgumentError, "auth_scheme must be :token or :bearer" unless %i[token bearer].include?(auth_scheme)
|
|
14
|
+
|
|
13
15
|
@client = client
|
|
14
16
|
@subdomain = subdomain
|
|
15
17
|
@api_key = api_key
|
|
@@ -17,7 +19,11 @@ module MOCO
|
|
|
17
19
|
@conn = Faraday.new do |f|
|
|
18
20
|
f.request :json
|
|
19
21
|
f.response :json
|
|
20
|
-
|
|
22
|
+
if auth_scheme == :bearer
|
|
23
|
+
f.request :authorization, "Bearer", @api_key
|
|
24
|
+
else
|
|
25
|
+
f.request :authorization, "Token", "token=#{@api_key}"
|
|
26
|
+
end
|
|
21
27
|
f.url_prefix = "https://#{@subdomain}.mocoapp.com/api/v1"
|
|
22
28
|
end
|
|
23
29
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MOCO
|
|
4
|
+
# Approval request for an invoice or purchase.
|
|
5
|
+
class Approval < BaseEntity
|
|
6
|
+
def approve
|
|
7
|
+
self.class.new(client, client.post("approvals/#{id}/approve"))
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def deny(note: nil)
|
|
11
|
+
self.class.new(client, client.post("approvals/#{id}/deny", { note: }.compact))
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -6,6 +6,7 @@ module MOCO
|
|
|
6
6
|
# Base class for all MOCO API entities
|
|
7
7
|
class BaseEntity
|
|
8
8
|
attr_reader :client, :attributes
|
|
9
|
+
attr_writer :collection
|
|
9
10
|
|
|
10
11
|
# Initializes an entity instance from raw API response data (Hash).
|
|
11
12
|
# Recursively processes nested hashes and arrays, converting known
|
|
@@ -76,10 +77,11 @@ module MOCO
|
|
|
76
77
|
# Check if the client responds to the collection method
|
|
77
78
|
if client.respond_to?(collection_name)
|
|
78
79
|
# Use the collection proxy to update the entity
|
|
79
|
-
updated_data = client.send(collection_name).update(id,
|
|
80
|
+
updated_data = (@collection || client.send(collection_name)).update(id, to_h)
|
|
80
81
|
|
|
81
82
|
# Update local attributes with the response data
|
|
82
83
|
@attributes = updated_data.attributes if updated_data
|
|
84
|
+
define_attribute_methods
|
|
83
85
|
else
|
|
84
86
|
warn "Warning: Client does not respond to collection '#{collection_name}' for saving entity."
|
|
85
87
|
end
|
|
@@ -92,8 +94,9 @@ module MOCO
|
|
|
92
94
|
def update(new_attributes)
|
|
93
95
|
# Update attributes
|
|
94
96
|
new_attributes.each do |key, value|
|
|
95
|
-
|
|
97
|
+
attributes[key.to_sym] = process_value(value, key.to_sym)
|
|
96
98
|
end
|
|
99
|
+
define_attribute_methods
|
|
97
100
|
|
|
98
101
|
# Save changes
|
|
99
102
|
save
|
|
@@ -101,7 +104,7 @@ module MOCO
|
|
|
101
104
|
|
|
102
105
|
# Deletes the entity from the API.
|
|
103
106
|
# Returns true on success, false on failure.
|
|
104
|
-
def destroy
|
|
107
|
+
def destroy(**params)
|
|
105
108
|
return false if id.nil? # Can't destroy without an ID
|
|
106
109
|
|
|
107
110
|
# Determine the collection name from the class name
|
|
@@ -110,7 +113,8 @@ module MOCO
|
|
|
110
113
|
# Check if the client responds to the collection method
|
|
111
114
|
if client.respond_to?(collection_name)
|
|
112
115
|
# Use the collection proxy to delete the entity
|
|
113
|
-
client.send(collection_name)
|
|
116
|
+
collection = @collection || client.send(collection_name)
|
|
117
|
+
params.empty? ? collection.delete(id) : collection.delete(id, params.compact)
|
|
114
118
|
true
|
|
115
119
|
else
|
|
116
120
|
warn "Warning: Client does not respond to collection '#{collection_name}' for destroying entity."
|
|
@@ -129,10 +133,11 @@ module MOCO
|
|
|
129
133
|
# Check if the client responds to the collection method
|
|
130
134
|
if client.respond_to?(collection_name)
|
|
131
135
|
# Use the collection proxy to find the entity
|
|
132
|
-
reloaded = client.send(collection_name).find(id)
|
|
136
|
+
reloaded = (@collection || client.send(collection_name)).find(id)
|
|
133
137
|
|
|
134
138
|
# Update attributes with the reloaded data
|
|
135
139
|
@attributes = reloaded.attributes if reloaded
|
|
140
|
+
define_attribute_methods
|
|
136
141
|
else
|
|
137
142
|
warn "Warning: Client does not respond to collection '#{collection_name}' for reloading entity."
|
|
138
143
|
end
|
|
@@ -47,8 +47,21 @@ module MOCO
|
|
|
47
47
|
"account/catalog_services"
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
def items
|
|
51
|
+
CatalogServiceItemsProxy.new(client, self, :items, "CatalogServiceItem")
|
|
52
|
+
end
|
|
53
|
+
|
|
50
54
|
def to_s
|
|
51
55
|
name.to_s
|
|
52
56
|
end
|
|
53
57
|
end
|
|
58
|
+
|
|
59
|
+
# Catalog items are listed inside the parent, not at a collection endpoint.
|
|
60
|
+
class CatalogServiceItemsProxy < NestedCollectionProxy
|
|
61
|
+
def load_records
|
|
62
|
+
@records = wrap_response(parent.attributes.fetch(:items, []).map { |item| item.is_a?(BaseEntity) ? item.to_h : item })
|
|
63
|
+
@loaded = true
|
|
64
|
+
@records
|
|
65
|
+
end
|
|
66
|
+
end
|
|
54
67
|
end
|
|
@@ -59,6 +59,16 @@ module MOCO
|
|
|
59
59
|
# )
|
|
60
60
|
#
|
|
61
61
|
class Company < BaseEntity
|
|
62
|
+
def archive
|
|
63
|
+
client.put("companies/#{id}/archive")
|
|
64
|
+
self
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def unarchive
|
|
68
|
+
client.put("companies/#{id}/unarchive")
|
|
69
|
+
self
|
|
70
|
+
end
|
|
71
|
+
|
|
62
72
|
# Associations
|
|
63
73
|
def projects
|
|
64
74
|
has_many(:projects)
|
|
@@ -9,7 +9,7 @@ module MOCO
|
|
|
9
9
|
# date - String, "YYYY-MM-DD" invoice date
|
|
10
10
|
# due_date - String, "YYYY-MM-DD" payment due date
|
|
11
11
|
# title - String, invoice title (e.g., "Invoice")
|
|
12
|
-
#
|
|
12
|
+
# vat_code_id - Integer, sales VAT code ID (tax percentage is deprecated)
|
|
13
13
|
# currency - String, 3-letter code (e.g., "EUR")
|
|
14
14
|
# items - Array of Hashes, invoice line items (see below)
|
|
15
15
|
#
|
|
@@ -47,7 +47,7 @@ module MOCO
|
|
|
47
47
|
# date: "2024-01-15",
|
|
48
48
|
# due_date: "2024-02-15",
|
|
49
49
|
# title: "Invoice",
|
|
50
|
-
#
|
|
50
|
+
# vat_code_id: 123,
|
|
51
51
|
# currency: "EUR",
|
|
52
52
|
# items: [
|
|
53
53
|
# { type: "title", title: "Services January 2024" },
|
|
@@ -90,21 +90,10 @@ module MOCO
|
|
|
90
90
|
self
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
-
#
|
|
93
|
+
# Fetches attachments for this invoice as a NestedCollectionProxy.
|
|
94
|
+
# Supports .all, .find(id), .create(attachment: { filename:, base64: }), and .destroy.
|
|
94
95
|
def attachments
|
|
95
|
-
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
# Add an attachment to the invoice
|
|
99
|
-
def add_attachment(file_data)
|
|
100
|
-
client.post("invoices/#{id}/attachments", file_data)
|
|
101
|
-
self
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
# Delete an attachment from the invoice
|
|
105
|
-
def delete_attachment(attachment_id)
|
|
106
|
-
client.delete("invoices/#{id}/attachments/#{attachment_id}")
|
|
107
|
-
self
|
|
96
|
+
MOCO::NestedCollectionProxy.new(client, self, :attachments, "InvoiceAttachment")
|
|
108
97
|
end
|
|
109
98
|
|
|
110
99
|
# Fetches payments for this invoice
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MOCO
|
|
4
|
+
# Represents an attachment on a MOCO invoice
|
|
5
|
+
#
|
|
6
|
+
# == Required attributes for create:
|
|
7
|
+
# attachment - Hash with the following keys:
|
|
8
|
+
# filename - String, file name including extension (e.g., "appendix.pdf")
|
|
9
|
+
# base64 - String, base64-encoded file content
|
|
10
|
+
#
|
|
11
|
+
# == Read-only attributes:
|
|
12
|
+
# id, title, created_at, updated_at
|
|
13
|
+
#
|
|
14
|
+
# == Usage:
|
|
15
|
+
# invoice = moco.invoices.find(123)
|
|
16
|
+
# invoice.attachments.all
|
|
17
|
+
# invoice.attachments.create(
|
|
18
|
+
# attachment: {
|
|
19
|
+
# filename: "appendix.pdf",
|
|
20
|
+
# base64: Base64.strict_encode64(File.read("appendix.pdf"))
|
|
21
|
+
# }
|
|
22
|
+
# )
|
|
23
|
+
# invoice.attachments.find(42).destroy
|
|
24
|
+
#
|
|
25
|
+
# == Note:
|
|
26
|
+
# The API only supports list (GET), create (POST), and delete (DELETE).
|
|
27
|
+
# Update is not available - delete and re-upload to replace.
|
|
28
|
+
#
|
|
29
|
+
class InvoiceAttachment < BaseEntity
|
|
30
|
+
def to_s
|
|
31
|
+
title.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MOCO
|
|
4
|
+
# Represents a MOCO letter paper (letterhead template used on invoices/offers PDFs)
|
|
5
|
+
# Read-only listing of letterheads configured in the MOCO account.
|
|
6
|
+
#
|
|
7
|
+
# == Read-only attributes:
|
|
8
|
+
# id, name, active, template, file, created_at, updated_at
|
|
9
|
+
#
|
|
10
|
+
# == Usage:
|
|
11
|
+
# moco.letter_papers.all
|
|
12
|
+
#
|
|
13
|
+
# == Note:
|
|
14
|
+
# The API only exposes a list endpoint (GET /letter_papers).
|
|
15
|
+
# Use a letter paper's `id` as `letter_paper_id` when fetching
|
|
16
|
+
# invoice/offer PDFs (e.g. GET /invoices/{id}.pdf?letter_paper_id=...).
|
|
17
|
+
#
|
|
18
|
+
class LetterPaper < BaseEntity
|
|
19
|
+
def to_s
|
|
20
|
+
name.to_s
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/moco/entities/offer.rb
CHANGED
|
@@ -85,20 +85,26 @@ module MOCO
|
|
|
85
85
|
reload
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
#
|
|
88
|
+
# Fetches attachments for this offer as a NestedCollectionProxy.
|
|
89
|
+
# Supports .all, .find(id), .create(attachment: { filename:, base64: }), and .destroy.
|
|
89
90
|
def attachments
|
|
90
|
-
|
|
91
|
+
MOCO::NestedCollectionProxy.new(client, self, :attachments, "OfferAttachment")
|
|
91
92
|
end
|
|
92
93
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
94
|
+
def confirmations
|
|
95
|
+
CollectionProxy.new(client, "offer_confirmations", "OfferConfirmation").where(offer_id: id)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def customer_approval
|
|
99
|
+
OfferApproval.new(client, client.get("offers/#{id}/customer_approval"))
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def activate_customer_approval
|
|
103
|
+
OfferApproval.new(client, client.post("offers/#{id}/customer_approval/activate"))
|
|
97
104
|
end
|
|
98
105
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
client.delete("offers/#{id}/attachments/#{attachment_id}")
|
|
106
|
+
def deactivate_customer_approval
|
|
107
|
+
client.post("offers/#{id}/customer_approval/deactivate")
|
|
102
108
|
self
|
|
103
109
|
end
|
|
104
110
|
|
|
@@ -23,7 +23,7 @@ module MOCO
|
|
|
23
23
|
# approval = moco.get("offers/123/customer_approval")
|
|
24
24
|
#
|
|
25
25
|
# # Deactivate (revoke access)
|
|
26
|
-
# moco.
|
|
26
|
+
# moco.post("offers/123/customer_approval/deactivate")
|
|
27
27
|
#
|
|
28
28
|
# == Note:
|
|
29
29
|
# Check signed_at to determine if the customer has signed.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MOCO
|
|
4
|
+
# Represents an attachment on a MOCO offer
|
|
5
|
+
#
|
|
6
|
+
# == Required attributes for create:
|
|
7
|
+
# attachment - Hash with the following keys:
|
|
8
|
+
# filename - String, file name including extension (e.g., "appendix.pdf")
|
|
9
|
+
# base64 - String, base64-encoded file content
|
|
10
|
+
#
|
|
11
|
+
# == Read-only attributes:
|
|
12
|
+
# id, title, created_at, updated_at
|
|
13
|
+
#
|
|
14
|
+
# == Usage:
|
|
15
|
+
# offer = moco.offers.find(123)
|
|
16
|
+
# offer.attachments.all
|
|
17
|
+
# offer.attachments.create(
|
|
18
|
+
# attachment: {
|
|
19
|
+
# filename: "appendix.pdf",
|
|
20
|
+
# base64: Base64.strict_encode64(File.read("appendix.pdf"))
|
|
21
|
+
# }
|
|
22
|
+
# )
|
|
23
|
+
# offer.attachments.find(42).destroy
|
|
24
|
+
#
|
|
25
|
+
# == Note:
|
|
26
|
+
# The API only supports list (GET), create (POST), and delete (DELETE).
|
|
27
|
+
# Update is not available - delete and re-upload to replace.
|
|
28
|
+
#
|
|
29
|
+
class OfferAttachment < BaseEntity
|
|
30
|
+
def to_s
|
|
31
|
+
title.to_s
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MOCO
|
|
4
|
+
# Confirmation document for an accepted offer.
|
|
5
|
+
class OfferConfirmation < BaseEntity
|
|
6
|
+
def pdf(letter_paper_id: nil)
|
|
7
|
+
client.get("offer_confirmations/#{id}.pdf", { letter_paper_id: }.compact)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def offer
|
|
11
|
+
association(:offer)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def user
|
|
15
|
+
association(:user)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module MOCO
|
|
4
|
+
# Planned outgoing expense, optionally recurring.
|
|
5
|
+
class PlannedPurchase < BaseEntity
|
|
6
|
+
def company
|
|
7
|
+
association(:company)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def user
|
|
11
|
+
association(:user)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def purchase_budget
|
|
15
|
+
association(:purchase_budget)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -82,15 +82,24 @@ module MOCO
|
|
|
82
82
|
MOCO::NestedCollectionProxy.new(client, self, :expenses, "Expense")
|
|
83
83
|
end
|
|
84
84
|
|
|
85
|
-
# Fetches tasks associated with this project.
|
|
86
|
-
#
|
|
87
|
-
# Data is fetched lazily when accessed (e.g., .all, .first, .each).
|
|
88
|
-
# Note: Embedded tasks from projects.assigned are available via attributes[:tasks]
|
|
89
|
-
# but may have incomplete fields compared to the dedicated endpoint.
|
|
85
|
+
# Fetches tasks associated with this project via the API.
|
|
86
|
+
# Returns a NestedCollectionProxy for lazy loading and CRUD operations.
|
|
90
87
|
def tasks
|
|
91
88
|
MOCO::NestedCollectionProxy.new(client, self, :tasks, "Task")
|
|
92
89
|
end
|
|
93
90
|
|
|
91
|
+
# Returns embedded tasks from the projects/assigned response, or nil.
|
|
92
|
+
# These have fewer fields than the full API response but avoid an
|
|
93
|
+
# extra API call, useful for limited-permission accounts.
|
|
94
|
+
def embedded_tasks
|
|
95
|
+
embedded = attributes[:tasks]
|
|
96
|
+
if embedded.is_a?(Array) && embedded.all? { |t| t.is_a?(MOCO::Task) }
|
|
97
|
+
embedded
|
|
98
|
+
else
|
|
99
|
+
[]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
94
103
|
# Fetches contracts associated with this project.
|
|
95
104
|
def contracts
|
|
96
105
|
MOCO::NestedCollectionProxy.new(client, self, :contracts, "ProjectContract")
|
|
@@ -10,7 +10,7 @@ module MOCO
|
|
|
10
10
|
# "bank_transfer", "direct_debit", "credit_card",
|
|
11
11
|
# "paypal", "cash", "bank_transfer_swiss_qr_esr"
|
|
12
12
|
# items - Array of item hashes (at least one required):
|
|
13
|
-
# { title: "Item", total: 100.0,
|
|
13
|
+
# { title: "Item", total: 100.0, vat_code_id: 123, tax_included: true }
|
|
14
14
|
#
|
|
15
15
|
# == Optional attributes:
|
|
16
16
|
# title - String, purchase title (auto-generated from items if omitted)
|
|
@@ -31,7 +31,7 @@ module MOCO
|
|
|
31
31
|
# == Item attributes:
|
|
32
32
|
# title - String, item description
|
|
33
33
|
# total - Float, item total amount
|
|
34
|
-
#
|
|
34
|
+
# vat_code_id - Integer, purchase VAT code ID (tax percentage is deprecated)
|
|
35
35
|
# tax_included - Boolean, whether total includes tax
|
|
36
36
|
# category_id - Integer, purchase category ID
|
|
37
37
|
#
|
|
@@ -47,7 +47,7 @@ module MOCO
|
|
|
47
47
|
# payment_method: "bank_transfer",
|
|
48
48
|
# company_id: 456,
|
|
49
49
|
# items: [
|
|
50
|
-
# { title: "Office supplies", total: 119.0,
|
|
50
|
+
# { title: "Office supplies", total: 119.0, vat_code_id: 123, tax_included: true }
|
|
51
51
|
# ],
|
|
52
52
|
# tags: ["Office"]
|
|
53
53
|
# )
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module MOCO
|
|
4
4
|
# Represents a MOCO purchase budget (Ausgaben – Budgets)
|
|
5
|
-
#
|
|
5
|
+
# Budget tracking for expense categories
|
|
6
6
|
#
|
|
7
7
|
# == Read-only attributes:
|
|
8
8
|
# id, title, year, target, exhausted, remaining,
|
|
@@ -19,8 +19,7 @@ module MOCO
|
|
|
19
19
|
# end
|
|
20
20
|
#
|
|
21
21
|
# == Note:
|
|
22
|
-
# Purchase budgets
|
|
23
|
-
# This endpoint provides read-only access for tracking.
|
|
22
|
+
# Purchase budgets support create, update, delete, and annual target amounts.
|
|
24
23
|
#
|
|
25
24
|
class PurchaseBudget < BaseEntity
|
|
26
25
|
# Custom path since it's nested under purchases
|
|
@@ -28,6 +27,10 @@ module MOCO
|
|
|
28
27
|
"purchases/budgets"
|
|
29
28
|
end
|
|
30
29
|
|
|
30
|
+
def update_target(target_amount:, year: nil)
|
|
31
|
+
self.class.new(client, client.put("purchases/budgets/#{id}/target", { target_amount:, year: }.compact))
|
|
32
|
+
end
|
|
33
|
+
|
|
31
34
|
def to_s
|
|
32
35
|
"PurchaseBudget #{id}: #{title} (#{year})"
|
|
33
36
|
end
|
|
@@ -2,15 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
module MOCO
|
|
4
4
|
# Represents a MOCO purchase draft
|
|
5
|
-
#
|
|
5
|
+
# Create drafts with a base64-encoded file; convert them in the MOCO interface.
|
|
6
6
|
#
|
|
7
|
-
# ==
|
|
7
|
+
# == Create attributes:
|
|
8
|
+
# title - String, required
|
|
9
|
+
# file - Optional Hash, { filename: "invoice.pdf", base64: "..." }
|
|
10
|
+
#
|
|
11
|
+
# == Response attributes:
|
|
8
12
|
# id, title, date, company (Hash), file_url,
|
|
9
13
|
# items (Array), created_at, updated_at
|
|
10
14
|
#
|
|
11
15
|
# == Note:
|
|
12
|
-
# Purchase drafts
|
|
13
|
-
# are uploaded/scanned in MOCO's inbox.
|
|
16
|
+
# Purchase drafts can be created through the API or MOCO's inbox.
|
|
14
17
|
# Convert to actual purchases via the MOCO interface.
|
|
15
18
|
#
|
|
16
19
|
class PurchaseDraft < BaseEntity
|
|
@@ -18,6 +21,10 @@ module MOCO
|
|
|
18
21
|
"purchases/drafts"
|
|
19
22
|
end
|
|
20
23
|
|
|
24
|
+
def pdf
|
|
25
|
+
client.get("purchases/drafts/#{id}.pdf")
|
|
26
|
+
end
|
|
27
|
+
|
|
21
28
|
def to_s
|
|
22
29
|
"Draft ##{id}"
|
|
23
30
|
end
|
|
@@ -43,6 +43,14 @@ module MOCO
|
|
|
43
43
|
# start_date and period cannot be modified after creation.
|
|
44
44
|
#
|
|
45
45
|
class RecurringExpense < BaseEntity
|
|
46
|
+
def recur(project_id: nil)
|
|
47
|
+
project_id ||= @collection.parent.id if @collection.is_a?(NestedCollectionProxy)
|
|
48
|
+
project_id ||= attributes[:project_id] || project&.id
|
|
49
|
+
raise ArgumentError, "project_id is required" unless project_id
|
|
50
|
+
|
|
51
|
+
client.post("projects/#{project_id}/recurring_expenses/#{id}/recur")
|
|
52
|
+
end
|
|
53
|
+
|
|
46
54
|
# Associations
|
|
47
55
|
def project
|
|
48
56
|
association(:project)
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module MOCO
|
|
7
|
+
# Represents a MOCO API session for authentication.
|
|
8
|
+
#
|
|
9
|
+
# The `/session` endpoint exchanges email/password credentials for an
|
|
10
|
+
# API key, and can verify an existing key.
|
|
11
|
+
#
|
|
12
|
+
# == Creating an API key (POST /session):
|
|
13
|
+
# result = MOCO::Session.create(
|
|
14
|
+
# subdomain: "your-account",
|
|
15
|
+
# email: "you@example.com",
|
|
16
|
+
# password: "secret"
|
|
17
|
+
# )
|
|
18
|
+
# result["api_key"] # => "6f95f9a0..."
|
|
19
|
+
# result["user_id"] # => 933590696
|
|
20
|
+
#
|
|
21
|
+
# == Verifying an existing key (GET /session):
|
|
22
|
+
# identity = moco.session.verify
|
|
23
|
+
# identity["id"] # => 933590696
|
|
24
|
+
# identity["uuid"] # => "7a60719d-..."
|
|
25
|
+
#
|
|
26
|
+
# == Note:
|
|
27
|
+
# `create` does not require an existing Client - it uses a temporary
|
|
28
|
+
# unauthenticated connection. `verify` uses the Client's configured
|
|
29
|
+
# API key.
|
|
30
|
+
#
|
|
31
|
+
class Session
|
|
32
|
+
class << self
|
|
33
|
+
# Exchange email/password for an API key. Does not require a Client.
|
|
34
|
+
# Returns a Hash: { "api_key" => "...", "user_id" => ... }
|
|
35
|
+
def create(subdomain:, email:, password:)
|
|
36
|
+
conn = Faraday.new(url: "https://#{subdomain}.mocoapp.com/api/v1") do |f|
|
|
37
|
+
f.request :json
|
|
38
|
+
f.response :json
|
|
39
|
+
end
|
|
40
|
+
response = conn.post("session", { email:, password: })
|
|
41
|
+
raise MOCO::Error, "Authentication failed: #{response.status}" unless response.success?
|
|
42
|
+
|
|
43
|
+
response.body
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
attr_reader :client
|
|
48
|
+
|
|
49
|
+
def initialize(client)
|
|
50
|
+
@client = client
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Verify the configured API key. Returns the identity Hash or raises on 401.
|
|
54
|
+
def verify
|
|
55
|
+
client.get("session")
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
data/lib/moco/entities.rb
CHANGED
|
@@ -44,7 +44,7 @@ module MOCO
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
# https://
|
|
47
|
+
# https://docs.mocoapp.com/api/docs/v1#tag/projects.html
|
|
48
48
|
# @deprecated Use MOCO::Project from entities/project.rb instead
|
|
49
49
|
class Project < BaseEntity
|
|
50
50
|
attr_accessor :id, :active, :name, :customer, :tasks
|
|
@@ -54,7 +54,7 @@ module MOCO
|
|
|
54
54
|
end
|
|
55
55
|
end
|
|
56
56
|
|
|
57
|
-
# https://
|
|
57
|
+
# https://docs.mocoapp.com/api/docs/v1#tag/project_tasks.html
|
|
58
58
|
# @deprecated Use MOCO::Task from entities/task.rb instead
|
|
59
59
|
class Task < BaseEntity
|
|
60
60
|
attr_accessor :id, :active, :name, :project_id, :billable
|
|
@@ -64,7 +64,7 @@ module MOCO
|
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
# https://
|
|
67
|
+
# https://docs.mocoapp.com/api/docs/v1#tag/activities.html
|
|
68
68
|
# @deprecated Use MOCO::Activity from entities/activity.rb instead
|
|
69
69
|
class Activity < BaseEntity
|
|
70
70
|
attr_accessor :id, :active, :date, :description, :project, :task, :seconds, :hours, :billable, :billed, :user,
|
|
@@ -79,13 +79,13 @@ module MOCO
|
|
|
79
79
|
end
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
# https://
|
|
82
|
+
# https://docs.mocoapp.com/api/docs/v1#tag/companies.html
|
|
83
83
|
# @deprecated Use MOCO::Company from entities/company.rb instead
|
|
84
84
|
class Customer < BaseEntity
|
|
85
85
|
attr_accessor :id, :name
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
# https://
|
|
88
|
+
# https://docs.mocoapp.com/api/docs/v1#tag/users.html
|
|
89
89
|
# @deprecated Use MOCO::User from entities/user.rb instead
|
|
90
90
|
class User < BaseEntity
|
|
91
91
|
attr_accessor :id, :firstname, :lastname
|
|
@@ -17,7 +17,8 @@ module MOCO
|
|
|
17
17
|
parent_type = ActiveSupport::Inflector.underscore(parent.class.name.split("::").last)
|
|
18
18
|
# Use simple tableized name, not entity_path (which might include 'projects/' prefix)
|
|
19
19
|
nested_path = ActiveSupport::Inflector.tableize(path_or_entity_name.to_s)
|
|
20
|
-
|
|
20
|
+
parent_path = parent.class.respond_to?(:entity_path) ? parent.class.entity_path : parent_type.pluralize
|
|
21
|
+
"#{parent_path}/#{parent.id}/#{nested_path}"
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
# Create a new entity in this nested collection
|
|
@@ -25,7 +26,7 @@ module MOCO
|
|
|
25
26
|
klass = entity_class
|
|
26
27
|
return nil unless klass && klass <= MOCO::BaseEntity
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
build_entity(client.post(@base_path, attributes))
|
|
29
30
|
end
|
|
30
31
|
|
|
31
32
|
# Delete all entities in this nested collection
|
data/lib/moco/sync.rb
CHANGED
|
@@ -364,7 +364,7 @@ module MOCO
|
|
|
364
364
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
|
|
365
365
|
|
|
366
366
|
def fetch_assigned_projects
|
|
367
|
-
# Use .projects.assigned for
|
|
367
|
+
# Use .projects.assigned for both source and target to get embedded tasks
|
|
368
368
|
source_filters = @filters.fetch(:source, {}).merge(active: "true")
|
|
369
369
|
# Get the proxy, then fetch all results into the instance variable
|
|
370
370
|
@source_projects = @source.projects.assigned.where(source_filters).all
|
|
@@ -372,19 +372,19 @@ module MOCO
|
|
|
372
372
|
@source_projects.each do |project|
|
|
373
373
|
debug_log " Source Project: #{project.id} - #{project.name} (#{project.identifier})"
|
|
374
374
|
debug_log " Tasks:"
|
|
375
|
-
project.
|
|
375
|
+
project.embedded_tasks.each do |task|
|
|
376
376
|
debug_log " Task: #{task.id} - #{task.name}"
|
|
377
377
|
end
|
|
378
378
|
end
|
|
379
379
|
|
|
380
380
|
target_filters = @filters.fetch(:target, {}).merge(active: "true")
|
|
381
381
|
# Get the proxy, then fetch all results into the instance variable
|
|
382
|
-
@target_projects = @target.projects.where(target_filters).all
|
|
382
|
+
@target_projects = @target.projects.assigned.where(target_filters).all
|
|
383
383
|
debug_log "Found #{@target_projects.size} target projects:"
|
|
384
384
|
@target_projects.each do |project|
|
|
385
385
|
debug_log " Target Project: #{project.id} - #{project.name} (#{project.identifier})"
|
|
386
386
|
debug_log " Tasks:"
|
|
387
|
-
project.
|
|
387
|
+
project.embedded_tasks.each do |task|
|
|
388
388
|
debug_log " Task: #{task.id} - #{task.name}"
|
|
389
389
|
end
|
|
390
390
|
end
|
|
@@ -401,7 +401,7 @@ module MOCO
|
|
|
401
401
|
@project_mapping[source_project.id] = target_project
|
|
402
402
|
debug_log "Mapped source project #{source_project.id} (#{source_project.name}) to target project #{target_project.id} (#{target_project.name})"
|
|
403
403
|
|
|
404
|
-
target_project.
|
|
404
|
+
target_project.embedded_tasks.each do |target_task|
|
|
405
405
|
source_task = match_task(target_task, source_project)
|
|
406
406
|
if source_task
|
|
407
407
|
@task_mapping[source_task.id] = target_task
|
|
@@ -520,7 +520,7 @@ module MOCO
|
|
|
520
520
|
return @default_task_cache[target_project.id] if @default_task_cache.key?(target_project.id)
|
|
521
521
|
|
|
522
522
|
# Search for the default task in the target project
|
|
523
|
-
default_task = target_project.
|
|
523
|
+
default_task = target_project.embedded_tasks.find { |task| task.name == @default_task_name }
|
|
524
524
|
|
|
525
525
|
# Cache the result (even if nil)
|
|
526
526
|
@default_task_cache[target_project.id] = default_task
|
|
@@ -545,7 +545,7 @@ module MOCO
|
|
|
545
545
|
|
|
546
546
|
def match_task(target_task, source_project)
|
|
547
547
|
# Get tasks from the source project (embedded in projects.assigned response)
|
|
548
|
-
tasks = source_project.
|
|
548
|
+
tasks = source_project.embedded_tasks
|
|
549
549
|
|
|
550
550
|
# Only proceed if we have tasks to match against
|
|
551
551
|
return nil if tasks.empty?
|
data/lib/moco/version.rb
CHANGED
data/lib/moco.rb
CHANGED
|
@@ -41,9 +41,16 @@ require_relative "moco/entities/tagging"
|
|
|
41
41
|
require_relative "moco/entities/deal_category"
|
|
42
42
|
require_relative "moco/entities/project_group"
|
|
43
43
|
require_relative "moco/entities/profile"
|
|
44
|
+
require_relative "moco/entities/offer_confirmation"
|
|
45
|
+
require_relative "moco/entities/revenue_category"
|
|
46
|
+
require_relative "moco/entities/planned_purchase"
|
|
47
|
+
require_relative "moco/entities/todo"
|
|
48
|
+
require_relative "moco/entities/absence_request"
|
|
49
|
+
require_relative "moco/entities/approval"
|
|
44
50
|
|
|
45
51
|
# Account-level entities
|
|
46
52
|
require_relative "moco/entities/catalog_service"
|
|
53
|
+
require_relative "moco/entities/catalog_service_item"
|
|
47
54
|
require_relative "moco/entities/custom_property"
|
|
48
55
|
require_relative "moco/entities/expense_template"
|
|
49
56
|
require_relative "moco/entities/fixed_cost"
|
|
@@ -63,7 +70,11 @@ require_relative "moco/entities/recurring_expense"
|
|
|
63
70
|
require_relative "moco/entities/invoice_payment"
|
|
64
71
|
require_relative "moco/entities/invoice_reminder"
|
|
65
72
|
require_relative "moco/entities/invoice_bookkeeping_export"
|
|
73
|
+
require_relative "moco/entities/invoice_attachment"
|
|
66
74
|
require_relative "moco/entities/offer_approval"
|
|
75
|
+
require_relative "moco/entities/offer_attachment"
|
|
76
|
+
require_relative "moco/entities/letter_paper"
|
|
77
|
+
require_relative "moco/entities/session"
|
|
67
78
|
require_relative "moco/entities/purchase_category"
|
|
68
79
|
require_relative "moco/entities/purchase_draft"
|
|
69
80
|
require_relative "moco/entities/purchase_payment"
|
data/sync_activity.rb
CHANGED
|
@@ -94,7 +94,7 @@ syncer = MOCO::Sync.new(
|
|
|
94
94
|
syncer.source_projects.each do |project|
|
|
95
95
|
if syncer.project_mapping[project.id]
|
|
96
96
|
puts "✅ Project #{project} --> #{syncer.project_mapping[project.id]}"
|
|
97
|
-
project.
|
|
97
|
+
project.embedded_tasks.each do |task|
|
|
98
98
|
if syncer.task_mapping[task.id]
|
|
99
99
|
puts " ✅ Task #{task} --> #{syncer.task_mapping[task.id]}"
|
|
100
100
|
else
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: moco-ruby
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Teal Bauer
|
|
@@ -78,9 +78,12 @@ files:
|
|
|
78
78
|
- lib/moco/collection_proxy.rb
|
|
79
79
|
- lib/moco/connection.rb
|
|
80
80
|
- lib/moco/entities.rb
|
|
81
|
+
- lib/moco/entities/absence_request.rb
|
|
81
82
|
- lib/moco/entities/activity.rb
|
|
83
|
+
- lib/moco/entities/approval.rb
|
|
82
84
|
- lib/moco/entities/base_entity.rb
|
|
83
85
|
- lib/moco/entities/catalog_service.rb
|
|
86
|
+
- lib/moco/entities/catalog_service_item.rb
|
|
84
87
|
- lib/moco/entities/comment.rb
|
|
85
88
|
- lib/moco/entities/company.rb
|
|
86
89
|
- lib/moco/entities/contact.rb
|
|
@@ -95,12 +98,17 @@ files:
|
|
|
95
98
|
- lib/moco/entities/hourly_rate.rb
|
|
96
99
|
- lib/moco/entities/internal_hourly_rate.rb
|
|
97
100
|
- lib/moco/entities/invoice.rb
|
|
101
|
+
- lib/moco/entities/invoice_attachment.rb
|
|
98
102
|
- lib/moco/entities/invoice_bookkeeping_export.rb
|
|
99
103
|
- lib/moco/entities/invoice_payment.rb
|
|
100
104
|
- lib/moco/entities/invoice_reminder.rb
|
|
105
|
+
- lib/moco/entities/letter_paper.rb
|
|
101
106
|
- lib/moco/entities/offer.rb
|
|
102
107
|
- lib/moco/entities/offer_approval.rb
|
|
108
|
+
- lib/moco/entities/offer_attachment.rb
|
|
109
|
+
- lib/moco/entities/offer_confirmation.rb
|
|
103
110
|
- lib/moco/entities/payment_schedule.rb
|
|
111
|
+
- lib/moco/entities/planned_purchase.rb
|
|
104
112
|
- lib/moco/entities/planning_entry.rb
|
|
105
113
|
- lib/moco/entities/presence.rb
|
|
106
114
|
- lib/moco/entities/profile.rb
|
|
@@ -119,11 +127,14 @@ files:
|
|
|
119
127
|
- lib/moco/entities/reports/cashflow.rb
|
|
120
128
|
- lib/moco/entities/reports/finance.rb
|
|
121
129
|
- lib/moco/entities/reports/utilization.rb
|
|
130
|
+
- lib/moco/entities/revenue_category.rb
|
|
122
131
|
- lib/moco/entities/schedule.rb
|
|
132
|
+
- lib/moco/entities/session.rb
|
|
123
133
|
- lib/moco/entities/tag.rb
|
|
124
134
|
- lib/moco/entities/tagging.rb
|
|
125
135
|
- lib/moco/entities/task.rb
|
|
126
136
|
- lib/moco/entities/task_template.rb
|
|
137
|
+
- lib/moco/entities/todo.rb
|
|
127
138
|
- lib/moco/entities/unit.rb
|
|
128
139
|
- lib/moco/entities/user.rb
|
|
129
140
|
- lib/moco/entities/user_role.rb
|
|
@@ -161,7 +172,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
161
172
|
- !ruby/object:Gem::Version
|
|
162
173
|
version: '0'
|
|
163
174
|
requirements: []
|
|
164
|
-
rubygems_version: 4.0.
|
|
175
|
+
rubygems_version: 4.0.20
|
|
165
176
|
specification_version: 4
|
|
166
177
|
summary: A Ruby Gem to interact with the MOCO API.
|
|
167
178
|
test_files: []
|