This document helps AI assistants understand the project architecture and make consistent updates.
A Swift CLI tool that migrates data from FreshBooks to Zoho Books. Uses async/await, actors for thread-safe API access, and a mapper pattern for data transformation.
Sources/ZohoMigration/
├── main.swift # CLI entry point (ArgumentParser)
├── Config/
│ └── Configuration.swift # JSON config loading, OAuth credentials
├── API/
│ ├── FreshBooksAPI.swift # Actor - fetches from FreshBooks (read-only)
│ ├── ZohoAPI.swift # Actor - creates in Zoho Books
│ └── OAuthHelper.swift # Actor - token management for both APIs
├── Migration/
│ └── MigrationService.swift # Orchestrates migrations, maintains ID mappings
├── Mappers/
│ └── *Mapper.swift # Stateless transformation functions
└── Models/
├── FreshBooks/
│ └── FB*.swift # Source models with response wrappers
└── Zoho/
└── ZB*.swift # Target models with create request variants
Follow these steps to add support for migrating a new entity (e.g., "Widget"):
import Foundation
struct FBWidgetResponse: Codable {
let response: FBWidgetResponseBody
}
struct FBWidgetResponseBody: Codable {
let result: FBWidgetResult
}
struct FBWidgetResult: Codable {
let widgets: [FBWidget]
let page: Int
let pages: Int
let perPage: Int
let total: Int
enum CodingKeys: String, CodingKey {
case widgets
case page, pages
case perPage = "per_page"
case total
}
}
struct FBWidget: Codable, Identifiable {
let id: Int
let name: String?
let visState: Int? // 0 = active, non-zero = archived/deleted
enum CodingKeys: String, CodingKey {
case id, name
case visState = "vis_state"
}
var displayName: String {
name ?? "Widget \(id)"
}
}import Foundation
struct ZBWidgetResponse: Codable {
let code: Int
let message: String
let widget: ZBWidget?
}
struct ZBWidget: Codable {
var widgetId: String?
var name: String
enum CodingKeys: String, CodingKey {
case widgetId = "widget_id"
case name
}
}
struct ZBWidgetCreateRequest: Codable {
var name: String
enum CodingKeys: String, CodingKey {
case name
}
}import Foundation
struct WidgetMapper {
static func map(_ widget: FBWidget) -> ZBWidgetCreateRequest {
ZBWidgetCreateRequest(
name: widget.name ?? "Widget \(widget.id)"
)
}
}func fetchWidgets() async throws -> [FBWidget] {
let endpoint = "/accounting/account/\(accountId)/widgets/widgets"
// ... pagination pattern (copy from existing methods)
}func createWidget(_ widget: ZBWidgetCreateRequest) async throws -> ZBWidget? {
if dryRun {
print(" [DRY RUN] Would create widget: \(widget.name)")
return nil
}
// ... POST to /widgets endpoint (copy pattern from existing methods)
}- Add ID mapping property:
private var widgetIdMapping: [Int: String] = [:] - Add to
migrateAll()in correct dependency order - Add
migrateWidgets()method following existing patterns
- Add
Widgets.selfto subcommands array inMigratestruct - Add new command struct (see existing commands like
Customers,Invoices,Payments):
struct Widgets: AsyncParsableCommand {
static let configuration = CommandConfiguration(
abstract: "Migrate widgets"
)
@OptionGroup var options: MigrationOptions
func run() async throws {
let service = try createMigrationService(options: options)
try await service.migrateWidgets()
}
}- Add to "Data Migrated" list
- Add to "Migrate Specific Entities" examples
- Update "Migration Order" section
- Add any required Zoho OAuth scopes
The tool has two main command groups:
migrate: Migration commands (migrate all,migrate customers, etc.)auth: OAuth token refresh commands (auth freshbooks,auth zoho)
The Auth command in main.swift handles interactive OAuth flows and auto-updates config.json.
FreshBooks nests data: response.result.{entities}. Always create three structs:
FB{Entity}Response→FB{Entity}ResponseBody→FB{Entity}Result
Zoho returns code (0 = success) and message. Create:
ZB{Entity}Response(for API responses)ZB{Entity}(the entity itself)ZB{Entity}CreateRequest(subset of fields for creation)
FreshBooks uses visState to track deleted/archived items:
visState == 0orvisState == nil→ active (migrate)visState != 0→ skip
MigrationService maintains [Int: String] dictionaries mapping FreshBooks IDs to Zoho IDs. Use these when entities have dependencies (e.g., invoices need customer IDs).
If a mapper needs ID mappings, pass them as parameters:
static func map(_ entity: FBEntity, customerIdMapping: [Int: String]) -> ZBEntityCreateRequest?Return nil if required mapping is missing.
For entities that reference other entities by name (not ID), use the on-the-fly creation pattern:
- At migration start, fetch existing Zoho entities and build a name→ID cache
- During migration, check if referenced entity exists in cache
- If not, create it in Zoho and add to cache
- Use the cached/new ID for the dependent entity
Example: Expense migration creates vendors on-the-fly when they don't exist (see MigrationService.migrateExpenses()).
- Base:
https://api.freshbooks.com - Pattern:
/accounting/account/{accountId}/{resource}/{resource} - Auth:
Bearer {token}header - Pagination:
?page=N&per_page=100
- Base: Region-dependent (see
ZohoConfig.baseURL) - Pattern:
/books/v3/{resource}?organization_id={orgId} - Auth:
Zoho-oauthtoken {token}header - Rate limit: 100 requests/minute (auto-handled)
- Contacts:
/contacts - Invoices:
/invoices - Expenses:
/expenses - Items:
/items - Taxes:
/settings/taxes - Payments:
/customerpayments - Chart of Accounts:
/chartofaccounts
# Build
swift build
# Dry run (validates without making changes)
swift run ZohoMigration migrate all --dry-run --verbose
# Run specific migration
swift run ZohoMigration migrate widgets --dry-run