moco-ruby 1.0.0 → 1.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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +36 -1
  3. data/Gemfile.lock +47 -43
  4. data/README.md +63 -24
  5. data/copy_project.rb +337 -0
  6. data/lib/moco/client.rb +58 -0
  7. data/lib/moco/connection.rb +45 -22
  8. data/lib/moco/entities/activity.rb +31 -1
  9. data/lib/moco/entities/catalog_service.rb +54 -0
  10. data/lib/moco/entities/comment.rb +61 -0
  11. data/lib/moco/entities/company.rb +57 -2
  12. data/lib/moco/entities/contact.rb +56 -0
  13. data/lib/moco/entities/custom_property.rb +49 -0
  14. data/lib/moco/entities/deal.rb +38 -2
  15. data/lib/moco/entities/deal_category.rb +27 -0
  16. data/lib/moco/entities/employment.rb +55 -0
  17. data/lib/moco/entities/expense.rb +37 -2
  18. data/lib/moco/entities/expense_template.rb +39 -0
  19. data/lib/moco/entities/fixed_cost.rb +30 -0
  20. data/lib/moco/entities/holiday.rb +33 -2
  21. data/lib/moco/entities/hourly_rate.rb +33 -0
  22. data/lib/moco/entities/internal_hourly_rate.rb +32 -0
  23. data/lib/moco/entities/invoice.rb +81 -1
  24. data/lib/moco/entities/invoice_bookkeeping_export.rb +33 -0
  25. data/lib/moco/entities/invoice_payment.rb +51 -0
  26. data/lib/moco/entities/invoice_reminder.rb +51 -0
  27. data/lib/moco/entities/offer.rb +122 -0
  28. data/lib/moco/entities/offer_approval.rb +42 -0
  29. data/lib/moco/entities/payment_schedule.rb +48 -0
  30. data/lib/moco/entities/planning_entry.rb +43 -2
  31. data/lib/moco/entities/presence.rb +34 -2
  32. data/lib/moco/entities/profile.rb +24 -0
  33. data/lib/moco/entities/project.rb +76 -2
  34. data/lib/moco/entities/project_contract.rb +50 -0
  35. data/lib/moco/entities/project_group.rb +38 -0
  36. data/lib/moco/entities/purchase.rb +90 -0
  37. data/lib/moco/entities/purchase_bookkeeping_export.rb +34 -0
  38. data/lib/moco/entities/purchase_budget.rb +47 -0
  39. data/lib/moco/entities/purchase_category.rb +38 -0
  40. data/lib/moco/entities/purchase_draft.rb +25 -0
  41. data/lib/moco/entities/purchase_payment.rb +51 -0
  42. data/lib/moco/entities/receipt.rb +55 -0
  43. data/lib/moco/entities/recurring_expense.rb +55 -0
  44. data/lib/moco/entities/reports/absences.rb +16 -0
  45. data/lib/moco/entities/reports/cashflow.rb +16 -0
  46. data/lib/moco/entities/reports/finance.rb +16 -0
  47. data/lib/moco/entities/reports/utilization.rb +16 -0
  48. data/lib/moco/entities/schedule.rb +39 -2
  49. data/lib/moco/entities/tag.rb +30 -0
  50. data/lib/moco/entities/tagging.rb +27 -0
  51. data/lib/moco/entities/task.rb +25 -2
  52. data/lib/moco/entities/task_template.rb +38 -0
  53. data/lib/moco/entities/unit.rb +36 -0
  54. data/lib/moco/entities/user.rb +50 -2
  55. data/lib/moco/entities/user_role.rb +29 -0
  56. data/lib/moco/entities/vat_code_purchase.rb +29 -0
  57. data/lib/moco/entities/vat_code_sale.rb +29 -0
  58. data/lib/moco/entities/web_hook.rb +32 -2
  59. data/lib/moco/entities/work_time_adjustment.rb +51 -0
  60. data/lib/moco/sync.rb +112 -6
  61. data/lib/moco/version.rb +1 -1
  62. data/lib/moco-ruby.rb +6 -0
  63. data/lib/moco.rb +51 -1
  64. data/moco.gemspec +5 -3
  65. data/sync_activity.rb +8 -2
  66. metadata +54 -14
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 94fd15c735a242e23f7a1e20dd49408d12dbfbb21f1665ca64bfd85ba3251cae
4
- data.tar.gz: 710f5ce6be51b2c363c6d2b3df871d755e5b21e90e9ad53cbb535ac249b46b6f
3
+ metadata.gz: 7527d151ba5f10b9406932d5f6e488d73897fab49ef9ab9a89341166adde7d42
4
+ data.tar.gz: ce1efd8024e78ab0541229172735ac27c8ec4595eeaf86d3183bc09d72b59504
5
5
  SHA512:
6
- metadata.gz: 272eede87d5a02636ca02354ea0ef592f0d21662378fb6817b68cc6fa73e66922b43623f6ac5da5c4c76ac3db9c6ebaee12a340fcc9920df93104303f96668fd
7
- data.tar.gz: 12f55e014641e51ecf2f666c04bff0a5f330ae68b91cf63e70060b6f85e5fc759eed517854060190651581a7ffba5bfa8345fdd25b8e2e59a96193a32cdece9a
6
+ metadata.gz: ecad34b5401422a64351c4814c262ad9328bde6cdfa096f2295ae58b5aa56aa015dbd2c06905a6c212a6efa3b13236c45df12dd8534c15861d61dbb5071630bd
7
+ data.tar.gz: fe46f175939e575b1b63459a10b2ababb5137e4fae30230e59ea45185c996f6e52e9b56ecc11184a2a8fc8a7c28deda74d134594a0940cc3358d7100b4cd735b
data/CHANGELOG.md CHANGED
@@ -2,6 +2,39 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.2.0] - 2026-01-14
6
+
7
+ ### Added
8
+ - Complete MOCO API coverage with 48+ entity types
9
+ - New entities: Offer, Contact, Tagging, DealCategory, CatalogService, CustomProperty,
10
+ ExpenseTemplate, FixedCost, HourlyRate, InternalHourlyRate, TaskTemplate, UserRole,
11
+ VatCodeSale, VatCodePurchase, Profile, WorkTimeAdjustment, ProjectContract,
12
+ PaymentSchedule, RecurringExpense, InvoicePayment, InvoiceReminder, OfferApproval,
13
+ PurchaseCategory, PurchaseDraft, ProjectGroup, InvoiceBookkeepingExport,
14
+ PurchaseBookkeepingExport, PurchaseBudget, PurchasePayment
15
+ - Inline attribute documentation for all entity classes
16
+ - Reports API support via `moco.reports.absences`, `.cashflow`, `.finance`, `.utilization`
17
+ - Integration tests for all entity types
18
+ - GitHub Actions CI for tests and auto-release
19
+ - Ruby 4.0 support
20
+
21
+ ### Fixed
22
+ - Debug output now correctly shows request body for POST/PUT/PATCH requests
23
+
24
+ ## [1.1.0] - 2025-11-15
25
+
26
+ ### Added
27
+ - Support for limited-permission accounts with auto-create missing tasks
28
+ - `--default-task` flag for limited-permission accounts
29
+ - `copy_project` tool for copying projects between MOCO instances
30
+
31
+ ### Fixed
32
+ - Enable auto-require for Bundler
33
+ - Loosen dependency constraints for Rails 8 compatibility
34
+
35
+ ### Security
36
+ - Update rexml to 3.4.4 to fix CVE-2025-58767
37
+
5
38
  ## [1.0.0] - 2025-10-08
6
39
 
7
40
  ### Fixed
@@ -125,7 +158,9 @@
125
158
  ## [0.1.0] - 2024-02-27
126
159
  - Initial release
127
160
 
128
- [Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0...HEAD
161
+ [Unreleased]: https://github.com/starsong-consulting/moco-ruby/compare/v1.2.0...HEAD
162
+ [1.2.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.1.0...v1.2.0
163
+ [1.1.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0...v1.1.0
129
164
  [1.0.0]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.beta...v1.0.0
130
165
  [1.0.0.beta]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha...v1.0.0.beta
131
166
  [1.0.0.alpha]: https://github.com/starsong-consulting/moco-ruby/compare/v1.0.0.alpha-initial...v1.0.0.alpha
data/Gemfile.lock CHANGED
@@ -1,67 +1,70 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- moco-ruby (1.0.0)
5
- activesupport (~> 7.0)
6
- faraday (~> 2.9.0)
4
+ moco-ruby (1.2.0)
5
+ activesupport (>= 7.0)
6
+ faraday (>= 2.0)
7
7
  fuzzy_match (~> 2.1.0)
8
8
 
9
9
  GEM
10
10
  remote: https://rubygems.org/
11
11
  specs:
12
- activesupport (7.2.2.1)
12
+ activesupport (8.1.2)
13
13
  base64
14
- benchmark (>= 0.3)
15
14
  bigdecimal
16
15
  concurrent-ruby (~> 1.0, >= 1.3.1)
17
16
  connection_pool (>= 2.2.5)
18
17
  drb
19
18
  i18n (>= 1.6, < 2)
19
+ json
20
20
  logger (>= 1.4.2)
21
21
  minitest (>= 5.1)
22
22
  securerandom (>= 0.3)
23
23
  tzinfo (~> 2.0, >= 2.0.5)
24
- addressable (2.8.7)
25
- public_suffix (>= 2.0.2, < 7.0)
24
+ uri (>= 0.13.1)
25
+ addressable (2.8.8)
26
+ public_suffix (>= 2.0.2, < 8.0)
26
27
  ast (2.4.3)
27
- base64 (0.2.0)
28
- benchmark (0.4.0)
29
- bigdecimal (3.1.9)
30
- concurrent-ruby (1.3.5)
31
- connection_pool (2.5.0)
32
- crack (1.0.0)
28
+ base64 (0.3.0)
29
+ bigdecimal (4.0.1)
30
+ concurrent-ruby (1.3.6)
31
+ connection_pool (3.0.2)
32
+ crack (1.0.1)
33
33
  bigdecimal
34
34
  rexml
35
35
  dotenv (2.8.1)
36
- drb (2.2.1)
37
- faraday (2.9.2)
38
- faraday-net_http (>= 2.0, < 3.2)
39
- faraday-net_http (3.1.1)
40
- net-http
36
+ drb (2.2.3)
37
+ faraday (2.14.0)
38
+ faraday-net_http (>= 2.0, < 3.5)
39
+ json
40
+ logger
41
+ faraday-net_http (3.4.2)
42
+ net-http (~> 0.5)
41
43
  fuzzy_match (2.1.0)
42
- hashdiff (1.1.2)
43
- i18n (1.14.7)
44
+ hashdiff (1.2.1)
45
+ i18n (1.14.8)
44
46
  concurrent-ruby (~> 1.0)
45
- json (2.10.2)
46
- language_server-protocol (3.17.0.4)
47
+ json (2.18.0)
48
+ language_server-protocol (3.17.0.5)
47
49
  lint_roller (1.1.0)
48
50
  logger (1.7.0)
49
- minitest (5.25.5)
50
- net-http (0.6.0)
51
- uri
52
- parallel (1.26.3)
53
- parser (3.3.7.4)
51
+ minitest (6.0.1)
52
+ prism (~> 1.5)
53
+ net-http (0.9.1)
54
+ uri (>= 0.11.1)
55
+ parallel (1.27.0)
56
+ parser (3.3.10.0)
54
57
  ast (~> 2.4.1)
55
58
  racc
56
- power_assert (2.0.5)
57
- prism (1.4.0)
58
- public_suffix (6.0.1)
59
+ power_assert (3.0.1)
60
+ prism (1.7.0)
61
+ public_suffix (7.0.2)
59
62
  racc (1.8.1)
60
63
  rainbow (3.1.1)
61
- rake (13.2.1)
62
- regexp_parser (2.10.0)
63
- rexml (3.4.1)
64
- rubocop (1.75.2)
64
+ rake (13.3.1)
65
+ regexp_parser (2.11.3)
66
+ rexml (3.4.4)
67
+ rubocop (1.82.1)
65
68
  json (~> 2.3)
66
69
  language_server-protocol (~> 3.17.0.2)
67
70
  lint_roller (~> 1.1.0)
@@ -69,23 +72,23 @@ GEM
69
72
  parser (>= 3.3.0.2)
70
73
  rainbow (>= 2.2.2, < 4.0)
71
74
  regexp_parser (>= 2.9.3, < 3.0)
72
- rubocop-ast (>= 1.44.0, < 2.0)
75
+ rubocop-ast (>= 1.48.0, < 2.0)
73
76
  ruby-progressbar (~> 1.7)
74
77
  unicode-display_width (>= 2.4.0, < 4.0)
75
- rubocop-ast (1.44.0)
78
+ rubocop-ast (1.49.0)
76
79
  parser (>= 3.3.7.2)
77
- prism (~> 1.4)
80
+ prism (~> 1.7)
78
81
  ruby-progressbar (1.13.0)
79
82
  securerandom (0.4.1)
80
- test-unit (3.6.8)
83
+ test-unit (3.7.7)
81
84
  power_assert
82
85
  tzinfo (2.0.6)
83
86
  concurrent-ruby (~> 1.0)
84
- unicode-display_width (3.1.4)
85
- unicode-emoji (~> 4.0, >= 4.0.4)
86
- unicode-emoji (4.0.4)
87
- uri (1.0.3)
88
- webmock (3.25.1)
87
+ unicode-display_width (3.2.0)
88
+ unicode-emoji (~> 4.1)
89
+ unicode-emoji (4.2.0)
90
+ uri (1.1.1)
91
+ webmock (3.26.1)
89
92
  addressable (>= 2.8.0)
90
93
  crack (>= 0.3.2)
91
94
  hashdiff (>= 0.4.0, < 2.0.0)
@@ -93,6 +96,7 @@ GEM
93
96
  PLATFORMS
94
97
  arm64-darwin-22
95
98
  arm64-darwin-23
99
+ x86_64-linux
96
100
 
97
101
  DEPENDENCIES
98
102
  dotenv (~> 2.8)
data/README.md CHANGED
@@ -167,25 +167,54 @@ puts "Billable tasks: #{billable_tasks.map(&:name).join(', ')}"
167
167
  dev_task = project.tasks.find_by(name: "Development")
168
168
  ```
169
169
 
170
+ ### Profile
171
+
172
+ Access the current user's profile:
173
+
174
+ ```ruby
175
+ profile = moco.profile
176
+ puts "Logged in as: #{profile.firstname} #{profile.lastname}"
177
+ ```
178
+
179
+ ### Reports
180
+
181
+ Access read-only report endpoints:
182
+
183
+ ```ruby
184
+ # Absences report
185
+ absences = moco.reports.absences(year: 2024)
186
+
187
+ # Utilization report (requires date range)
188
+ utilization = moco.reports.utilization(from: "2024-01-01", to: "2024-12-31")
189
+
190
+ # Financial reports
191
+ cashflow = moco.reports.cashflow(from: "2024-01-01", to: "2024-03-31")
192
+ finance = moco.reports.finance(from: "2024-01-01", to: "2024-03-31")
193
+ ```
194
+
170
195
  ### Supported Entities
171
196
 
172
197
  The gem supports all MOCO API entities with a Ruby-esque interface:
173
198
 
174
- - `Project`
175
- - `Activity`
176
- - `User`
177
- - `Company`
178
- - `Task`
179
- - `Invoice`
180
- - `Deal`
181
- - `Expense`
182
- - `WebHook`
183
- - `Schedule`
184
- - `Presence`
185
- - `Holiday`
186
- - `PlanningEntry`
187
-
188
- Access them via the moco using their plural, snake_case names (e.g., `moco.planning_entries`).
199
+ **Core:**
200
+ `Project`, `Activity`, `User`, `Company`, `Task`, `Invoice`, `Deal`, `Expense`, `WebHook`, `Schedule`, `Presence`, `Holiday`, `PlanningEntry`
201
+
202
+ **Business:**
203
+ `Contact`, `Offer`, `Purchase`, `Receipt`, `Comment`, `Tag`, `Tagging`, `DealCategory`, `ProjectGroup`, `Unit`
204
+
205
+ **Account Settings:**
206
+ `CatalogService`, `CustomProperty`, `ExpenseTemplate`, `FixedCost`, `HourlyRate`, `InternalHourlyRate`, `TaskTemplate`, `UserRole`
207
+
208
+ **Financial:**
209
+ `VatCodeSale`, `VatCodePurchase`, `PurchaseCategory`, `PurchaseDraft`, `PurchaseBudget`, `PurchasePayment`
210
+
211
+ **Bookkeeping:**
212
+ `InvoiceBookkeepingExport`, `PurchaseBookkeepingExport`
213
+
214
+ **Nested Resources:**
215
+ `Employment`, `WorkTimeAdjustment`, `ProjectContract`, `PaymentSchedule`, `RecurringExpense`, `InvoicePayment`, `InvoiceReminder`, `OfferApproval`
216
+
217
+ Access them via the client using their plural, snake_case names (e.g., `moco.planning_entries`, `moco.vat_code_sales`).
189
218
 
190
219
  ## Utilities
191
220
 
@@ -226,26 +255,36 @@ Usage: sync_activity.rb [options] source_subdomain target_subdomain
226
255
  --match-project-threshold VALUE
227
256
  Fuzzy match threshold for projects (0.0 - 1.0), default 0.8
228
257
  --match-task-threshold VALUE Fuzzy match threshold for tasks (0.0 - 1.0), default 0.45
258
+ --default-task TASK_NAME Map unmatched tasks to this default task instead of creating new tasks
259
+ -d, --debug Enable debug output
229
260
  -h, --help Show this message
230
261
  ```
231
262
  **Example:** `sync_activity.rb --from 2024-04-01 --to 2024-04-10 --dry-run source-instance target-instance`
232
263
 
264
+ **Using Default Task Mapping:** If your target account has limited permissions and cannot create tasks, or if you want to consolidate multiple source tasks into a single target task, use the `--default-task` flag:
265
+
266
+ ```bash
267
+ sync_activity.rb --from 2024-04-01 --to 2024-04-10 --default-task "Other" source-instance target-instance
268
+ ```
269
+
270
+ This will map any unmatched source tasks to a task named "Other" in the corresponding target project, avoiding the need to create new tasks.
271
+
233
272
  ## Development
234
273
 
235
274
  After checking out the repo, run `bin/setup` to install dependencies.
236
275
 
237
276
  ### Running Tests
238
277
 
239
- The gem includes a comprehensive test suite with both unit tests (mocked) and integration tests (live API):
278
+ The gem includes unit tests (mocked) and integration tests (live API):
240
279
 
241
280
  ```bash
242
- # Run all tests
243
- ruby test/test_v2_api.rb # Unit tests (mocked, fast)
244
- ruby test/test_comprehensive.rb # Integration tests (requires .env)
245
- ruby test/test_holidays_expenses.rb # Holidays & Expenses tests (requires .env)
281
+ # Unit tests (mocked, fast)
282
+ bundle exec ruby -Ilib -Itest test/test_v2_api.rb
283
+ bundle exec ruby -Ilib -Itest test/test_new_entities.rb
246
284
 
247
- # Or run individually
248
- ruby test/test_v2_api.rb
285
+ # Integration tests (requires .env credentials)
286
+ bundle exec ruby -Ilib -Itest test/test_integration.rb
287
+ bundle exec ruby -Ilib -Itest test/test_comprehensive.rb
249
288
  ```
250
289
 
251
290
  For integration tests, create a `.env` file with your test instance credentials:
@@ -254,7 +293,7 @@ MOCO_API_TEST_SUBDOMAIN=your-test-subdomain
254
293
  MOCO_API_TEST_API_KEY=your-test-api-key
255
294
  ```
256
295
 
257
- **Note:** The MOCO API has rate limits (120 requests per 2 minutes on standard plans). Integration tests make real API calls.
296
+ **Note:** The MOCO API has rate limits. The gem automatically retries rate-limited requests with exponential backoff.
258
297
 
259
298
  ### Installation
260
299
 
@@ -268,4 +307,4 @@ Bug reports and pull requests are welcome on GitHub at https://github.com/starso
268
307
 
269
308
  ## License
270
309
 
271
- The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
310
+ The gem is available as open source under the terms of the [Apache License, Version 2.0](https://www.apache.org/licenses/LICENSE-2.0).
data/copy_project.rb ADDED
@@ -0,0 +1,337 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+ require "yaml"
6
+ require "fuzzy_match"
7
+ require_relative "lib/moco"
8
+
9
+ options = {
10
+ dry_run: false,
11
+ verbose: false,
12
+ copy_activities: true
13
+ }
14
+
15
+ OptionParser.new do |opts|
16
+ opts.banner = "Usage: #{$PROGRAM_NAME} [options] source_instance target_instance project_identifier"
17
+
18
+ opts.on("-n", "--dry-run", "Show what would be copied without making changes") do
19
+ options[:dry_run] = true
20
+ end
21
+
22
+ opts.on("-v", "--verbose", "Enable verbose output") do
23
+ options[:verbose] = true
24
+ end
25
+
26
+ opts.on("--no-activities", "Skip copying activities (only copy project and tasks)") do
27
+ options[:copy_activities] = false
28
+ end
29
+
30
+ opts.on("-h", "--help", "Show this message") do
31
+ puts opts
32
+ exit
33
+ end
34
+ end.parse!
35
+
36
+ source_instance = ARGV.shift
37
+ target_instance = ARGV.shift
38
+ project_identifier = ARGV.shift
39
+
40
+ if source_instance.nil? || target_instance.nil? || project_identifier.nil?
41
+ warn "Error: source_instance, target_instance, and project_identifier are required"
42
+ warn "Usage: #{$PROGRAM_NAME} [options] source_instance target_instance project_identifier"
43
+ exit 1
44
+ end
45
+
46
+ # Load configuration
47
+ config = YAML.load_file("config.yml")
48
+ source_config = config["instances"].fetch(source_instance, nil)
49
+ target_config = config["instances"].fetch(target_instance, nil)
50
+
51
+ if source_config.nil?
52
+ warn "Error: Source instance '#{source_instance}' not found in config.yml"
53
+ exit 1
54
+ end
55
+
56
+ if target_config.nil?
57
+ warn "Error: Target instance '#{target_instance}' not found in config.yml"
58
+ exit 1
59
+ end
60
+
61
+ # Initialize clients
62
+ puts "Connecting to instances..."
63
+ source_client = MOCO::Client.new(subdomain: source_instance, api_key: source_config["api_key"])
64
+ target_client = MOCO::Client.new(subdomain: target_instance, api_key: target_config["api_key"])
65
+
66
+ def log(message, verbose: false, options:)
67
+ return if verbose && !options[:verbose]
68
+ puts message
69
+ end
70
+
71
+ def get_id(obj)
72
+ return nil if obj.nil?
73
+ return obj[:id] if obj.is_a?(Hash)
74
+ return obj.id if obj.respond_to?(:id)
75
+ nil
76
+ end
77
+
78
+ def find_or_create_customer(source_customer, target_client, options)
79
+ log("Looking for customer '#{source_customer.name}' in target...", options: options)
80
+
81
+ # Try to find the customer by name
82
+ target_customers = target_client.companies.all
83
+ target_customer = target_customers.find { |c| c.name == source_customer.name }
84
+
85
+ if target_customer
86
+ log("✅ Found existing customer: #{target_customer.name} (ID: #{target_customer.id})", options: options)
87
+ return target_customer
88
+ end
89
+
90
+ log("Customer not found, creating new customer...", options: options)
91
+
92
+ if options[:dry_run]
93
+ log("🔍 [DRY RUN] Would create customer: #{source_customer.name}", options: options)
94
+ return nil
95
+ end
96
+
97
+ # Create the customer
98
+ customer_attrs = {
99
+ name: source_customer.name,
100
+ type: "customer"
101
+ }
102
+
103
+ # Add optional attributes if they exist
104
+ customer_attrs[:type] = source_customer.type if source_customer.respond_to?(:type) && source_customer.type
105
+ customer_attrs[:currency] = source_customer.currency if source_customer.respond_to?(:currency) && source_customer.currency
106
+ customer_attrs[:website] = source_customer.website if source_customer.respond_to?(:website) && source_customer.website
107
+ customer_attrs[:address] = source_customer.address if source_customer.respond_to?(:address) && source_customer.address
108
+ customer_attrs[:info] = source_customer.info if source_customer.respond_to?(:info) && source_customer.info
109
+ customer_attrs[:custom_properties] = source_customer.custom_properties if source_customer.respond_to?(:custom_properties) && source_customer.custom_properties
110
+ customer_attrs[:labels] = source_customer.labels if source_customer.respond_to?(:labels) && source_customer.labels
111
+
112
+ log("Creating customer: #{customer_attrs[:name]}", verbose: true, options: options)
113
+ new_customer = target_client.companies.create(customer_attrs)
114
+ log("✅ Created customer: #{new_customer.name} (ID: #{new_customer.id})", options: options)
115
+
116
+ new_customer
117
+ end
118
+
119
+ def copy_tasks(source_project, target_project, target_client, options)
120
+ log("\nCopying tasks...", options: options)
121
+ task_mapping = {}
122
+
123
+ source_tasks = source_project.tasks
124
+ log("Found #{source_tasks.count} tasks in source project", options: options)
125
+
126
+ source_tasks.each do |source_task|
127
+ log(" Task: #{source_task.name}", verbose: true, options: options)
128
+
129
+ if options[:dry_run]
130
+ log(" 🔍 [DRY RUN] Would create task: #{source_task.name}", options: options)
131
+ next
132
+ end
133
+
134
+ # Create the task in the target project
135
+ task_attrs = {
136
+ name: source_task.name,
137
+ billable: source_task.billable,
138
+ active: source_task.active
139
+ }
140
+
141
+ # Add optional attributes if they exist
142
+ source_task_attrs = source_task.instance_variable_get(:@attributes)
143
+ task_attrs[:budget] = source_task_attrs[:budget] if source_task_attrs[:budget]
144
+ task_attrs[:hourly_rate] = source_task_attrs[:hourly_rate] if source_task_attrs[:hourly_rate]
145
+
146
+ log(" Creating task: #{task_attrs[:name]}", verbose: true, options: options)
147
+ # Use the NestedCollectionProxy to create the task
148
+ task_proxy = MOCO::NestedCollectionProxy.new(target_client, target_project, :tasks, "Task")
149
+ new_task = task_proxy.create(task_attrs)
150
+ task_mapping[source_task.id] = new_task
151
+ log(" ✅ Created task: #{new_task.name} (ID: #{new_task.id})", options: options)
152
+ end
153
+
154
+ task_mapping
155
+ end
156
+
157
+ def copy_activities(source_project, target_project, task_mapping, source_client, target_client, options)
158
+ log("\nCopying activities...", options: options)
159
+
160
+ # Get all activities for the source project
161
+ source_activities = source_client.activities.where(project_id: source_project.id).all
162
+ log("Found #{source_activities.count} activities in source project", options: options)
163
+
164
+ created_count = 0
165
+ skipped_count = 0
166
+
167
+ source_activities.each do |source_activity|
168
+ log(" Activity: #{source_activity.date} - #{source_activity.hours}h - #{source_activity.description}", verbose: true, options: options)
169
+
170
+ if options[:dry_run]
171
+ log(" 🔍 [DRY RUN] Would create activity: #{source_activity.date} - #{source_activity.hours}h", options: options)
172
+ next
173
+ end
174
+
175
+ # Map the task
176
+ target_task = task_mapping[source_activity.task&.id]
177
+
178
+ if source_activity.task && !target_task
179
+ log(" ⚠️ Skipping activity - task not mapped: #{source_activity.task.name}", options: options)
180
+ skipped_count += 1
181
+ next
182
+ end
183
+
184
+ # Create the activity in the target project
185
+ activity_attrs = {
186
+ date: source_activity.date,
187
+ hours: source_activity.hours,
188
+ description: source_activity.description,
189
+ project_id: target_project.id,
190
+ billable: source_activity.billable,
191
+ tag: source_activity.tag,
192
+ remote_service: source_activity.remote_service,
193
+ remote_id: source_activity.id.to_s # Store original ID for reference
194
+ }
195
+
196
+ activity_attrs[:task_id] = target_task.id if target_task
197
+
198
+ log(" Creating activity: #{activity_attrs[:date]} - #{activity_attrs[:hours]}h", verbose: true, options: options)
199
+
200
+ begin
201
+ new_activity = target_client.activities.create(activity_attrs)
202
+ created_count += 1
203
+ log(" ✅ Created activity: #{new_activity.date} - #{new_activity.hours}h (ID: #{new_activity.id})", verbose: true, options: options)
204
+ rescue => e
205
+ log(" ❌ Error creating activity: #{e.message}", options: options)
206
+ skipped_count += 1
207
+ end
208
+ end
209
+
210
+ log("\n✅ Created #{created_count} activities", options: options)
211
+ log("⚠️ Skipped #{skipped_count} activities", options: options) if skipped_count > 0
212
+ end
213
+
214
+ # Main execution
215
+ begin
216
+ log("=" * 80, options: options)
217
+ log("MOCO Project Copy Tool", options: options)
218
+ log("=" * 80, options: options)
219
+ log("Source: #{source_instance}", options: options)
220
+ log("Target: #{target_instance}", options: options)
221
+ log("Project: #{project_identifier}", options: options)
222
+ log("Mode: #{options[:dry_run] ? 'DRY RUN' : 'LIVE'}", options: options)
223
+ log("=" * 80, options: options)
224
+
225
+ # Find the source project
226
+ log("\nFinding source project...", options: options)
227
+ source_project = source_client.projects.where(identifier: project_identifier).all.first
228
+
229
+ if source_project.nil?
230
+ warn "Error: Project '#{project_identifier}' not found in source instance"
231
+ exit 1
232
+ end
233
+
234
+ log("✅ Found project: #{source_project.name} (ID: #{source_project.id})", options: options)
235
+ log(" Identifier: #{source_project.identifier}", verbose: true, options: options)
236
+ log(" Status: #{source_project.active ? 'Active' : 'Inactive'}", verbose: true, options: options)
237
+
238
+ # Get the customer
239
+ source_customer = source_project.customer
240
+ if source_customer.nil?
241
+ warn "Error: Project has no associated customer"
242
+ exit 1
243
+ end
244
+
245
+ log(" Customer: #{source_customer.name} (ID: #{source_customer.id})", options: options)
246
+
247
+ # Find or create the customer in the target
248
+ target_customer = find_or_create_customer(source_customer, target_client, options)
249
+
250
+ if options[:dry_run]
251
+ log("\n🔍 [DRY RUN] Would create project: #{source_project.name}", options: options)
252
+ log(" Identifier: #{source_project.identifier}", options: options)
253
+ log(" Customer: #{source_customer.name}", options: options)
254
+
255
+ # Still show what tasks would be copied
256
+ copy_tasks(source_project, source_project, target_client, options)
257
+
258
+ if options[:copy_activities]
259
+ log("\n🔍 [DRY RUN] Would copy activities...", options: options)
260
+ source_activities = source_client.activities.where(project_id: source_project.id).all
261
+ log(" Found #{source_activities.count} activities to copy", options: options)
262
+ end
263
+
264
+ log("\n" + "=" * 80, options: options)
265
+ log("DRY RUN COMPLETE - No changes made", options: options)
266
+ log("=" * 80, options: options)
267
+ exit 0
268
+ end
269
+
270
+ # Create the project in the target
271
+ log("\nCreating project in target...", options: options)
272
+
273
+ # Access attributes directly from the source project
274
+ source_attrs = source_project.instance_variable_get(:@attributes)
275
+
276
+ project_attrs = {
277
+ name: source_project.name,
278
+ identifier: source_project.identifier,
279
+ customer_id: target_customer.id,
280
+ currency: source_project.currency,
281
+ billable: source_project.billable,
282
+ fixed_price: source_attrs[:fixed_price] || false,
283
+ retainer: source_attrs[:retainer] || false,
284
+ finish_date: source_attrs[:finish_date],
285
+ start_date: source_attrs[:start_date]
286
+ }
287
+
288
+ # Get a default user from the target instance as leader
289
+ # TODO: implement proper user mapping between instances (match by name/email)
290
+ target_users = target_client.users.all
291
+ if target_users.empty?
292
+ raise "No users found in target instance - cannot set project leader"
293
+ end
294
+ project_attrs[:leader_id] = target_users.first.id
295
+ log("Using default leader: #{target_users.first.firstname} #{target_users.first.lastname} (ID: #{target_users.first.id})", verbose: true, options: options)
296
+
297
+ # Add optional attributes if they exist
298
+ project_attrs[:co_leader_id] = get_id(source_attrs[:co_leader]) if source_attrs[:co_leader]
299
+ project_attrs[:budget] = source_attrs[:budget] if source_attrs[:budget]
300
+ project_attrs[:budget_monthly] = source_attrs[:budget_monthly] if source_attrs[:budget_monthly]
301
+ project_attrs[:budget_expenses] = source_attrs[:budget_expenses] if source_attrs[:budget_expenses]
302
+ project_attrs[:hourly_rate] = source_attrs[:hourly_rate] if source_attrs[:hourly_rate]
303
+ project_attrs[:custom_properties] = source_attrs[:custom_properties] if source_attrs[:custom_properties]
304
+ project_attrs[:labels] = source_attrs[:labels] if source_attrs[:labels]
305
+ project_attrs[:tags] = source_attrs[:tags] if source_attrs[:tags]
306
+ project_attrs[:info] = source_attrs[:info] if source_attrs[:info]
307
+ project_attrs[:billing_address] = source_attrs[:billing_address] if source_attrs[:billing_address]
308
+ project_attrs[:billing_variant] = source_attrs[:billing_variant] if source_attrs[:billing_variant]
309
+
310
+ log("Creating project: #{project_attrs[:name]}", verbose: true, options: options)
311
+ target_project = target_client.projects.create(project_attrs)
312
+ log("✅ Created project: #{target_project.name} (ID: #{target_project.id})", options: options)
313
+
314
+ # Copy tasks
315
+ task_mapping = copy_tasks(source_project, target_project, target_client, options)
316
+
317
+ # Copy activities if requested
318
+ if options[:copy_activities]
319
+ copy_activities(source_project, target_project, task_mapping, source_client, target_client, options)
320
+ else
321
+ log("\nSkipping activities (--no-activities specified)", options: options)
322
+ end
323
+
324
+ log("\n" + "=" * 80, options: options)
325
+ log("✅ PROJECT COPY COMPLETE", options: options)
326
+ log("=" * 80, options: options)
327
+ log("Source project: #{source_project.name} (#{source_instance})", options: options)
328
+ log("Target project: #{target_project.name} (#{target_instance})", options: options)
329
+ log("Target project ID: #{target_project.id}", options: options)
330
+ log("Target project URL: https://#{target_instance}.mocoapp.com/projects/#{target_project.id}", options: options)
331
+ log("=" * 80, options: options)
332
+
333
+ rescue => e
334
+ warn "\n❌ Error: #{e.message}"
335
+ warn e.backtrace.join("\n") if options[:verbose]
336
+ exit 1
337
+ end