From 533faea7d176bcf747e3c5727f85618c59506d52 Mon Sep 17 00:00:00 2001 From: Dino Constantinou Date: Sat, 3 Oct 2026 09:46:59 +0100 Subject: [PATCH 1/3] Add Journal Sets Co-Authored-By: Claude Opus 5.5 --- .../JournalSet/JournalSetCreateCommand.swift | 56 ++++++ .../JournalSet/JournalSetDeleteCommand.swift | 37 ++++ .../JournalSet/JournalSetListCommand.swift | 60 ++++++ .../JournalSetOpeningBalancesCommand.swift | 28 +++ .../JournalSet/JournalSetShowCommand.swift | 64 +++++++ .../JournalSet/JournalSetUpdateCommand.swift | 72 ++++++++ .../Commands/JournalSetCommand.swift | 16 ++ Sources/FreeAgentCLI/FreeAgentCLI.swift | 1 + .../Models/JournalEntryArgument.swift | 149 +++++++++++++++ .../JournalSetIntegrationTests.swift | 106 +++++++++++ .../JournalSetCreateCommandTests.swift | 29 +++ .../JournalSetUpdateCommandTests.swift | 70 +++++++ .../Models/JournalEntryArgumentTests.swift | 88 +++++++++ openapi/components/schemas/JournalEntry.yaml | 41 +++++ .../schemas/JournalEntryPayload.yaml | 45 +++++ openapi/components/schemas/JournalSet.yaml | 36 ++++ .../schemas/JournalSetCreatePayload.yaml | 22 +++ .../schemas/JournalSetListResponse.yaml | 8 + .../schemas/JournalSetResponse.yaml | 6 + .../schemas/JournalSetUpdatePayload.yaml | 19 ++ .../components/schemas/OpeningBalance.yaml | 13 ++ openapi/openapi.yaml | 23 +++ openapi/paths/journal_sets.yaml | 172 ++++++++++++++++++ 23 files changed, 1161 insertions(+) create mode 100644 Sources/FreeAgentCLI/Commands/JournalSet/JournalSetCreateCommand.swift create mode 100644 Sources/FreeAgentCLI/Commands/JournalSet/JournalSetDeleteCommand.swift create mode 100644 Sources/FreeAgentCLI/Commands/JournalSet/JournalSetListCommand.swift create mode 100644 Sources/FreeAgentCLI/Commands/JournalSet/JournalSetOpeningBalancesCommand.swift create mode 100644 Sources/FreeAgentCLI/Commands/JournalSet/JournalSetShowCommand.swift create mode 100644 Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift create mode 100644 Sources/FreeAgentCLI/Commands/JournalSetCommand.swift create mode 100644 Sources/FreeAgentCLI/Models/JournalEntryArgument.swift create mode 100644 Tests/FreeAgentAPIIntegrationTests/JournalSetIntegrationTests.swift create mode 100644 Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetCreateCommandTests.swift create mode 100644 Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetUpdateCommandTests.swift create mode 100644 Tests/FreeAgentCLITests/Models/JournalEntryArgumentTests.swift create mode 100644 openapi/components/schemas/JournalEntry.yaml create mode 100644 openapi/components/schemas/JournalEntryPayload.yaml create mode 100644 openapi/components/schemas/JournalSet.yaml create mode 100644 openapi/components/schemas/JournalSetCreatePayload.yaml create mode 100644 openapi/components/schemas/JournalSetListResponse.yaml create mode 100644 openapi/components/schemas/JournalSetResponse.yaml create mode 100644 openapi/components/schemas/JournalSetUpdatePayload.yaml create mode 100644 openapi/components/schemas/OpeningBalance.yaml create mode 100644 openapi/paths/journal_sets.yaml diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetCreateCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetCreateCommand.swift new file mode 100644 index 0000000..b063bb5 --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetCreateCommand.swift @@ -0,0 +1,56 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct JournalSetCreateCommand: MutatingCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a journal set", + discussion: """ + The entries must balance: their debit values, negative for credits, add up to zero. User categories \ + such as 901 and 907 need a user, and capital asset categories 601 to 607 a capital asset type. + + Example: + --entry category=907,user=1,debit-value=250 + --entry category=999,debit-value=-250,description="Suspense, to clear" + """ + ) + + @Option(name: .long, help: "Date of the entries (YYYY-MM-DD)") + var datedOn: String + + @Option(name: .long, help: "Description") + var description: String + + @Option(name: .long, help: "Tag identifying sets your tool creates - tagged sets are read-only in FreeAgent") + var tag: String? + + @Option(name: .long, help: ArgumentHelp(JournalEntryArgument.help), transform: JournalEntryArgument.init) + var entry: [JournalEntryArgument] + + @Flag(help: "Print the request instead of sending it") + var dryRun = false + + @Flag(name: .long, help: "Output JSON") + var json = false + + func perform(client: Client) async throws -> Components.Schemas.JournalSetResponse { + let journalSetPayload = Components.Schemas.JournalSetCreatePayload( + datedOn: datedOn, + description: description, + tag: tag, + journalEntries: entry.map(\.payload) + ) + + let input = Operations.CreateJournalSet.Input( + body: .json(.init(journalSet: journalSetPayload)) + ) + + return try await client.createJournalSet(input) + .created.body.json + } + + func success(for response: Components.Schemas.JournalSetResponse) -> String { + "Created journal set \(response.journalSet.url)" + } +} diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetDeleteCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetDeleteCommand.swift new file mode 100644 index 0000000..e009bee --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetDeleteCommand.swift @@ -0,0 +1,37 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct JournalSetDeleteCommand: DestructiveCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a journal set" + ) + + @Argument(help: "Journal set ID or URL") + var id: ResourceID + + @Flag(help: "Print the request instead of sending it") + var dryRun = false + + @Flag(help: "Skip the confirmation prompt") + var yes = false + + var confirmation: String { + "Delete journal set \(id)?" + } + + func perform(client: Client) async throws -> EmptyResponse { + let input = Operations.DeleteJournalSet.Input( + path: .init(id: id.value) + ) + + _ = try await client.deleteJournalSet(input).ok + + return EmptyResponse() + } + + func success(for _: EmptyResponse) -> String { + "Deleted journal set \(id)" + } +} diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetListCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetListCommand.swift new file mode 100644 index 0000000..4970bad --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetListCommand.swift @@ -0,0 +1,60 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct JournalSetListCommand: AsyncPaginatedListCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List journal sets", + discussion: "Sets are listed by date, with the undated opening balances set first." + ) + + static let noun = "journal sets" + + static var columns: [Field] { + Field("ID") { .id(url: $0.url) } + Field("Dated On") { .date($0.datedOn) } + Field("Description") { .text($0.description) } + Field("Tag") { .text($0.tag) } + Field("Entries") { .number($0.journalEntries?.count) } + } + + @Option(name: .long, help: "Show journal sets dated on or after this date (YYYY-MM-DD)") + var fromDate: String? + + @Option(name: .long, help: "Show journal sets dated on or before this date (YYYY-MM-DD)") + var toDate: String? + + @Option(name: .long, help: "Show journal sets updated since this date or timestamp") + var updatedSince: String? + + @Option(name: .long, help: "Filter by tag, ignoring case") + var tag: String? + + @OptionGroup var pagination: PaginationOptions + + @Flag(name: .long, help: "Output JSON") + var json = false + + func fetch( + client: Client, + page: Int + ) async throws -> (response: Components.Schemas.JournalSetListResponse, totalCount: Int?) { + let ok = try await client.listJournalSets( + .init(query: .init( + fromDate: fromDate, + toDate: toDate, + updatedSince: updatedSince, + tag: tag, + page: page, + perPage: pagination.size + )) + ).ok + + return (try ok.body.json, ok.headers.xTotalCount) + } + + func items(in response: Components.Schemas.JournalSetListResponse) -> [Components.Schemas.JournalSet] { + response.journalSets + } +} diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetOpeningBalancesCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetOpeningBalancesCommand.swift new file mode 100644 index 0000000..0d671ff --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetOpeningBalancesCommand.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct JournalSetOpeningBalancesCommand: ShowCommand { + static let configuration = CommandConfiguration( + commandName: "opening-balances", + abstract: "Show the opening balances journal set", + discussion: "Its entries change through journal-set update with its ID; its date and description are locked." + ) + + static let title = "Opening Balances" + + static let sections = JournalSetShowCommand.sections + + static let tables = JournalSetShowCommand.tables + + @Flag(name: .long, help: "Output JSON") + var json = false + + func fetch(client: Client) async throws -> Components.Schemas.JournalSetResponse { + try await client.showOpeningBalances().ok.body.json + } + + func record(in response: Components.Schemas.JournalSetResponse) -> Components.Schemas.JournalSet { + response.journalSet + } +} diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetShowCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetShowCommand.swift new file mode 100644 index 0000000..080ee2f --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetShowCommand.swift @@ -0,0 +1,64 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct JournalSetShowCommand: ShowCommand { + static let configuration = CommandConfiguration( + commandName: "show", + abstract: "Show journal set details" + ) + + static let title = "Journal Set" + + static let tables = [ + FieldTable("Entries", items: \.journalEntries) { + Field("ID") { .id(url: $0.url) } + Field("Category") { .id(url: $0.category) } + Field("Description") { .text($0.description) } + Field("User") { .id(url: $0.user) } + Field("Contact") { .id(url: $0.contact) } + Field("Debit Value") { .currency($0.debitValue, code: nil) } + }, + FieldTable("Bank Accounts", items: \.bankAccounts) { + openingBalanceColumns + }, + FieldTable("Stock Items", items: \.stockItems) { + openingBalanceColumns + }, + ] + + static var sections: [FieldSection] { + FieldSection("Details") { + Field("Description") { .text($0.description) } + Field("Tag") { .text($0.tag) } + } + FieldSection("Dates") { + Field("Dated On") { .date($0.datedOn) } + Field("Updated") { .timestamp($0.updatedAt) } + } + FieldSection("IDs") { + Field("Journal Set") { .id(url: $0.url) } + } + } + + @FieldBuilder + static var openingBalanceColumns: [Field] { + Field("ID") { .id(url: $0.url) } + Field("Description") { .text($0.description) } + Field("Debit Value") { .currency($0.debitValue, code: nil) } + } + + @Argument(help: "Journal set ID or URL") + var id: ResourceID + + @Flag(name: .long, help: "Output JSON") + var json = false + + func fetch(client: Client) async throws -> Components.Schemas.JournalSetResponse { + try await client.showJournalSet(.init(path: .init(id: id.value))).ok.body.json + } + + func record(in response: Components.Schemas.JournalSetResponse) -> Components.Schemas.JournalSet { + response.journalSet + } +} diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift new file mode 100644 index 0000000..8c41e0d --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift @@ -0,0 +1,72 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +struct JournalSetUpdateCommand: MutatingCommand { + + // MARK: Internal + + static let configuration = CommandConfiguration( + commandName: "update", + abstract: "Update a journal set", + discussion: """ + Fields left out are kept. Passing --entry replaces every entry with the ones given, in one request, \ + so they must balance on their own. + """ + ) + + @Argument(help: "Journal set ID or URL") + var id: ResourceID + + @Option(name: .long, help: "Date of the entries (YYYY-MM-DD)") + var datedOn: String? + + @Option(name: .long, help: "Description") + var description: String? + + @Option(name: .long, help: "Tag identifying sets your tool creates - an empty string removes it") + var tag: String? + + @Option(name: .long, help: ArgumentHelp(JournalEntryArgument.help), transform: JournalEntryArgument.init) + var entry = [JournalEntryArgument]() + + @Flag(help: "Print the request instead of sending it") + var dryRun = false + + @Flag(name: .long, help: "Output JSON") + var json = false + + func perform(client: Client) async throws -> Components.Schemas.JournalSetResponse { + let journalSetPayload = try await Components.Schemas.JournalSetUpdatePayload( + datedOn: datedOn, + description: description, + tag: tag, + journalEntries: journalEntries(client: client) + ) + + let input = Operations.UpdateJournalSet.Input( + path: .init(id: id.value), + body: .json(.init(journalSet: journalSetPayload)) + ) + + return try await client.updateJournalSet(input) + .ok.body.json + } + + func success(for _: Components.Schemas.JournalSetResponse) -> String { + "Updated journal set \(id)" + } + + // MARK: Private + + private func journalEntries(client: Client) async throws -> [Components.Schemas.JournalEntryPayload]? { + guard !entry.isEmpty else { + return nil + } + + let current = try await client.showJournalSet(.init(path: .init(id: id.value))) + .ok.body.json.journalSet.journalEntries ?? [] + + return current.map { .init(url: $0.url, _destroy: true) } + entry.map(\.payload) + } +} diff --git a/Sources/FreeAgentCLI/Commands/JournalSetCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSetCommand.swift new file mode 100644 index 0000000..f697784 --- /dev/null +++ b/Sources/FreeAgentCLI/Commands/JournalSetCommand.swift @@ -0,0 +1,16 @@ +import ArgumentParser + +struct JournalSetCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "journal-set", + abstract: "Manage journal sets", + subcommands: [ + JournalSetListCommand.self, + JournalSetCreateCommand.self, + JournalSetShowCommand.self, + JournalSetUpdateCommand.self, + JournalSetDeleteCommand.self, + JournalSetOpeningBalancesCommand.self, + ] + ) +} diff --git a/Sources/FreeAgentCLI/FreeAgentCLI.swift b/Sources/FreeAgentCLI/FreeAgentCLI.swift index fd8275d..51d054b 100644 --- a/Sources/FreeAgentCLI/FreeAgentCLI.swift +++ b/Sources/FreeAgentCLI/FreeAgentCLI.swift @@ -25,6 +25,7 @@ struct FreeAgentCLI: AsyncParsableCommand { BankTransactionCommand.self, ExplanationCommand.self, CategoryCommand.self, + JournalSetCommand.self, ExpenseCommand.self, AttachmentCommand.self, VatReturnCommand.self, diff --git a/Sources/FreeAgentCLI/Models/JournalEntryArgument.swift b/Sources/FreeAgentCLI/Models/JournalEntryArgument.swift new file mode 100644 index 0000000..a023d1e --- /dev/null +++ b/Sources/FreeAgentCLI/Models/JournalEntryArgument.swift @@ -0,0 +1,149 @@ +import Foundation +import FreeAgentAPI + +// MARK: - JournalEntryArgument + +struct JournalEntryArgument: Sendable { + + // MARK: Lifecycle + + init(_ argument: String) throws(JournalEntryArgumentError) { + var values = [Key: String]() + + for field in try Self.fields(in: argument) { + guard let separator = field.firstIndex(of: "=") else { + throw .missingValue(field) + } + + let name = field[.. [String] { + var fields = [String]() + var field = "" + var isQuoted = false + + for character in argument { + switch character { + case "\"": + isQuoted.toggle() + + case "," where !isQuoted: + fields.append(field) + field = "" + + default: + field.append(character) + } + } + + guard !isQuoted else { + throw .unclosedQuote + } + + return fields + [field] + } + + private static func debitValue(_ value: String) throws(JournalEntryArgumentError) -> Double { + guard let number = Double(value), number.isFinite else { + throw .notANumber(.debitValue, value) + } + + return number + } + + private static func stockAlteringQuantity(_ value: String) throws(JournalEntryArgumentError) -> Int { + guard let number = Int(value) else { + throw .notANumber(.stockAlteringQuantity, value) + } + + return number + } +} + +// MARK: - JournalEntryArgumentError + +enum JournalEntryArgumentError: LocalizedError, Equatable { + case missingValue(String) + case unknownKey(String) + case repeatedKey(JournalEntryArgument.Key) + case emptyValue(JournalEntryArgument.Key) + case notANumber(JournalEntryArgument.Key, String) + case missingCategory + case unclosedQuote + + // MARK: Internal + + var errorDescription: String? { + switch self { + case .missingValue(let field): + "'\(field)' is not a key=value field" + case .unknownKey(let key): + "'\(key)' is not a key - use \(JournalEntryArgument.Key.allCases.map(\.rawValue).joined(separator: ", "))" + case .repeatedKey(let key): + "\(key.rawValue) is given more than once" + case .emptyValue(let key): + "\(key.rawValue) needs a value" + case .notANumber(let key, let value): + "\(key.rawValue) must be a number, not '\(value)'" + case .missingCategory: + "every entry needs a category" + case .unclosedQuote: + "a double quote is not closed" + } + } +} diff --git a/Tests/FreeAgentAPIIntegrationTests/JournalSetIntegrationTests.swift b/Tests/FreeAgentAPIIntegrationTests/JournalSetIntegrationTests.swift new file mode 100644 index 0000000..8e69ef1 --- /dev/null +++ b/Tests/FreeAgentAPIIntegrationTests/JournalSetIntegrationTests.swift @@ -0,0 +1,106 @@ +import Foundation +import Testing + +@testable import FreeAgentAPI + +@Suite(.enabled(if: IntegrationTest.isModelEnabled("journal_sets"))) +struct JournalSetIntegrationTests { + + // MARK: Lifecycle + + init() throws { + client = try #require(SandboxClient.makeClient()) + } + + // MARK: Internal + + @Test("GET /v2/journal_sets returns a typed, counted list") + func listJournalSets() async throws { + let ok = try await client.listJournalSets(.init(query: .init( + fromDate: "2025-01-01", + toDate: "2030-12-31", + updatedSince: "2025-01-01", + tag: "INTEGRATIONTEST" + ))).ok + let journalSets = try ok.body.json.journalSets + + #expect(ok.headers.xTotalCount != nil) + #expect(journalSets.allSatisfy { $0.url.contains("/v2/journal_sets/") }) + } + + @Test("GET /v2/journal_sets/opening_balances returns the typed opening balances set") + func showOpeningBalances() async throws { + let openingBalances = try await client.showOpeningBalances().ok.body.json.journalSet + + #expect(openingBalances.url.contains("/v2/journal_sets/")) + #expect(openingBalances.bankAccounts != nil) + #expect(openingBalances.stockItems != nil) + } + + @Test("A journal set can be created, updated, listed, shown and deleted") + func journalSetLifecycle() async throws { + let user = try await client.showCurrentUser(.init()).ok.body.json.user + let description = "ZZ Integration Test \(Int(Date().timeIntervalSince1970))" + + let created = try await client.createJournalSet( + .init(body: .json(.init(journalSet: .init( + datedOn: "2026-09-30", + description: description, + tag: "INTEGRATIONTEST", + journalEntries: [ + .init(category: "901", debitValue: 25.5, description: "Capital", user: Self.id(of: user.url)), + .init(category: "280", debitValue: 4.5), + .init(category: "999", debitValue: -30, description: "Suspense"), + ] + )))) + ).created.body.json.journalSet + + #expect(created.url.contains("/v2/journal_sets/")) + #expect(created.datedOn == "2026-09-30") + #expect(created.description == description) + #expect(created.tag == "INTEGRATIONTEST") + + let entries = try #require(created.journalEntries) + #expect(Set(entries.compactMap(\.debitValue)) == ["25.5", "4.5", "-30.0"]) + #expect(entries.allSatisfy { $0.url.contains("/journal_entries/") }) + #expect(entries.contains { $0.user == user.url }) + + let id = Self.id(of: created.url) + let sundries = try #require(entries.first { $0.debitValue == "4.5" }) + let suspense = try #require(entries.first { $0.debitValue == "-30.0" }) + + let updated = try await client.updateJournalSet( + .init(path: .init(id: id), body: .json(.init(journalSet: .init( + datedOn: "2026-09-29", + description: description + " Updated", + tag: "INTEGRATIONTEST2", + journalEntries: [ + .init(url: sundries.url, _destroy: true), + .init(url: suspense.url, debitValue: -25.5), + ] + )))) + ).ok.body.json.journalSet + + #expect(updated.url == created.url) + #expect(updated.datedOn == "2026-09-29") + #expect(updated.description == description + " Updated") + #expect(updated.tag == "INTEGRATIONTEST2") + #expect(Set(updated.journalEntries?.map(\.url) ?? []) == Set(entries.map(\.url)).subtracting([sundries.url])) + + _ = try await client.listJournalSets(.init(query: .init(tag: "INTEGRATIONTEST2"))).ok.body.json.journalSets + + let shown = try await client.showJournalSet(.init(path: .init(id: id))).ok.body.json.journalSet + #expect(shown.url == created.url) + + _ = try await client.deleteJournalSet(.init(path: .init(id: id))).ok + } + + // MARK: Private + + private let client: Client + + private static func id(of url: String) -> String { + String(url.split(separator: "/").last ?? "") + } + +} diff --git a/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetCreateCommandTests.swift b/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetCreateCommandTests.swift new file mode 100644 index 0000000..8512073 --- /dev/null +++ b/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetCreateCommandTests.swift @@ -0,0 +1,29 @@ +import ArgumentParser +import Testing + +@testable import FreeAgentCLI + +struct JournalSetCreateCommandTests { + @Test("needs at least one entry") + func needsEntry() { + #expect(throws: (any Error).self) { + try JournalSetCreateCommand.parse(["--dated-on", "2026-09-30", "--description", "Correction"]) + } + } + + @Test("takes each entry in order") + func takesEntries() throws { + let command = try JournalSetCreateCommand.parse([ + "--dated-on", + "2026-09-30", + "--description", + "Correction", + "--entry", + "category=280,debit-value=10", + "--entry", + "category=999,debit-value=-10", + ]) + + #expect(command.entry.map(\.payload.category) == ["280", "999"]) + } +} diff --git a/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetUpdateCommandTests.swift b/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetUpdateCommandTests.swift new file mode 100644 index 0000000..588b5c1 --- /dev/null +++ b/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetUpdateCommandTests.swift @@ -0,0 +1,70 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI +import HTTPTypes +import Mockable +import OpenAPIRuntime +import Testing + +@testable import FreeAgentCLI + +struct JournalSetUpdateCommandTests { + + // MARK: Internal + + @Test("updates without reading the set when no entry is given") + func withoutEntries() async throws { + let transport = transport() + let command = try JournalSetUpdateCommand.parse(["89633", "--description", "Corrected"]) + + let response = try await command.perform(client: client(transport)) + + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("showJournalSet")).called(0) + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("updateJournalSet")).called(1) + #expect(command.success(for: response) == "Updated journal set 89633") + } + + @Test("reads the set's entries before replacing them") + func withEntries() async throws { + let transport = transport() + let command = try JournalSetUpdateCommand.parse([ + "89633", + "--entry", + "category=250,debit-value=7", + "--entry", + "category=999,debit-value=-7", + ]) + + _ = try await command.perform(client: client(transport)) + + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("showJournalSet")).called(1) + verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("updateJournalSet")).called(1) + } + + // MARK: Private + + private static let journalSet = #""" + {"journal_set":{"url":"https://api.freeagent.com/v2/journal_sets/89633","journal_entries":[ + {"url":"https://api.freeagent.com/v2/journal_sets/89633/journal_entries/366845","category":"https://api.freeagent.com/v2/categories/280","debit_value":"10.0"}, + {"url":"https://api.freeagent.com/v2/journal_sets/89633/journal_entries/366846","category":"https://api.freeagent.com/v2/categories/999","debit_value":"-10.0"}]}} + """# + + private func transport() -> MockClientTransportInterface { + let transport = MockClientTransportInterface() + for operationID in ["showJournalSet", "updateJournalSet"] { + given(transport) + .send(.any, body: .any, baseURL: .any, operationID: .value(operationID)) + .willReturn(( + HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]), + HTTPBody(Data(Self.journalSet.utf8)) + )) + } + + return transport + } + + private func client(_ transport: MockClientTransportInterface) -> Client { + Client(serverURL: Environment.sandbox.baseURL, transport: transport) + } + +} diff --git a/Tests/FreeAgentCLITests/Models/JournalEntryArgumentTests.swift b/Tests/FreeAgentCLITests/Models/JournalEntryArgumentTests.swift new file mode 100644 index 0000000..a9a2f76 --- /dev/null +++ b/Tests/FreeAgentCLITests/Models/JournalEntryArgumentTests.swift @@ -0,0 +1,88 @@ +import FreeAgentAPI +import Testing + +@testable import FreeAgentCLI + +struct JournalEntryArgumentTests { + @Test("reads every key into the entry") + func readsEveryKey() throws { + let payload = try JournalEntryArgument( + "category=601,debit-value=-123.45,description=Laptop,user=1,capital-asset-type=2,stock-item=3,stock-altering-quantity=-4,contact=5" + ).payload + + #expect(payload.category == "601") + #expect(payload.debitValue == -123.45) + #expect(payload.description == "Laptop") + #expect(payload.user == "1") + #expect(payload.capitalAssetType == "2") + #expect(payload.stockItem == "3") + #expect(payload.stockAlteringQuantity == -4) + #expect(payload.contact == "5") + #expect(payload.url == nil) + #expect(payload._destroy == nil) + } + + @Test("reduces URLs to IDs") + func reducesURLs() throws { + let payload = try JournalEntryArgument( + "category=https://api.freeagent.com/v2/categories/907,user=https://api.freeagent.com/v2/users/32382" + ).payload + + #expect(payload.category == "907") + #expect(payload.user == "32382") + } + + @Test("leaves out keys that are not given") + func leavesOutMissingKeys() throws { + let payload = try JournalEntryArgument("category=999").payload + + #expect(payload.category == "999") + #expect(payload.debitValue == nil) + #expect(payload.description == nil) + #expect(payload.user == nil) + } + + @Test("keeps commas and equals signs inside double quotes") + func quotedValue() throws { + let payload = try JournalEntryArgument(#"category=999,description="Rent, March = 2",debit-value=5"#).payload + + #expect(payload.description == "Rent, March = 2") + #expect(payload.debitValue == 5) + } + + @Test("ignores spaces around keys") + func spacedKeys() throws { + let payload = try JournalEntryArgument("category=999, debit-value=5").payload + + #expect(payload.debitValue == 5) + } + + @Test( + "refuses a malformed entry", + arguments: [ + ("debit-value=5", JournalEntryArgumentError.missingCategory), + ("category=999,debit=5", .unknownKey("debit")), + ("category=999,category=280", .repeatedKey(.category)), + ("category=999,description=", .emptyValue(.description)), + ("category=999,debit-value=ten", .notANumber(.debitValue, "ten")), + ("category=999,debit-value=inf", .notANumber(.debitValue, "inf")), + ("category=999,stock-altering-quantity=1.5", .notANumber(.stockAlteringQuantity, "1.5")), + ("category=999,5", .missingValue("5")), + ("category=999,", .missingValue("")), + (#"category=999,description="Rent"#, .unclosedQuote), + ] + ) + func refusesMalformedEntry(argument: String, error: JournalEntryArgumentError) { + #expect(throws: error) { + try JournalEntryArgument(argument) + } + } + + @Test("names the keys when one is unknown") + func namesKeys() { + #expect( + JournalEntryArgumentError.unknownKey("debit").errorDescription + == "'debit' is not a key - use category, debit-value, description, user, capital-asset-type, stock-item, stock-altering-quantity, contact" + ) + } +} diff --git a/openapi/components/schemas/JournalEntry.yaml b/openapi/components/schemas/JournalEntry.yaml new file mode 100644 index 0000000..cd2aa88 --- /dev/null +++ b/openapi/components/schemas/JournalEntry.yaml @@ -0,0 +1,41 @@ +type: object +required: + - url +properties: + url: + type: string + example: https://api.freeagent.com/v2/journal_sets/1/journal_entries/1 + category: + type: string + description: A capital asset entry comes back as a sub-account, such as 601-1 + example: https://api.freeagent.com/v2/categories/901 + debit_value: + type: string + description: Negative for a credit + example: '-123.45' + description: + type: string + example: Director's capital introduced + user: + type: string + example: https://api.freeagent.com/v2/users/1 + capital_asset_type: + type: string + example: https://api.freeagent.com/v2/capital_asset_types/1 + stock_item: + type: string + example: https://api.freeagent.com/v2/stock_items/1 + stock_altering_quantity: + type: integer + example: 10 + property: + type: string + example: https://api.freeagent.com/v2/properties/1 + contact: + type: string + description: Opening balances set only + example: https://api.freeagent.com/v2/contacts/1 + bank_account: + type: string + description: Historical entries only - journals to and from bank accounts are no longer possible + example: https://api.freeagent.com/v2/bank_accounts/1 diff --git a/openapi/components/schemas/JournalEntryPayload.yaml b/openapi/components/schemas/JournalEntryPayload.yaml new file mode 100644 index 0000000..131d87d --- /dev/null +++ b/openapi/components/schemas/JournalEntryPayload.yaml @@ -0,0 +1,45 @@ +type: object +properties: + url: + type: string + description: On update, the URL or ID of an existing entry to update or remove - left out, the entry is added + example: https://api.freeagent.com/v2/journal_sets/1/journal_entries/1 + _destroy: + type: boolean + description: Set to true, with url, to remove the entry + example: true + category: + type: string + description: The category URL or nominal code - an unknown code is a 400 + example: https://api.freeagent.com/v2/categories/901 + debit_value: + type: number + description: Negative for a credit - rounded to two places, and 0 when left out + example: -123.45 + description: + type: string + example: Director's capital introduced + user: + type: string + description: Needed for user categories such as 901 and 907, or the request is a 404 - ignored on other categories + example: https://api.freeagent.com/v2/users/1 + capital_asset_type: + type: string + description: Needed for capital asset categories 601 to 607, or the request is a 404 + example: https://api.freeagent.com/v2/capital_asset_types/1 + stock_item: + type: string + description: For stock categories + example: https://api.freeagent.com/v2/stock_items/1 + stock_altering_quantity: + type: integer + description: For stock categories, the change in quantity + example: 10 + property: + type: string + description: For unincorporated landlords, on profit and loss categories + example: https://api.freeagent.com/v2/properties/1 + contact: + type: string + description: Opening balances only, for Trade Debtors (681) and Trade Creditors (796) - a 422 on any other set + example: https://api.freeagent.com/v2/contacts/1 diff --git a/openapi/components/schemas/JournalSet.yaml b/openapi/components/schemas/JournalSet.yaml new file mode 100644 index 0000000..356fa46 --- /dev/null +++ b/openapi/components/schemas/JournalSet.yaml @@ -0,0 +1,36 @@ +type: object +required: + - url +properties: + url: + type: string + example: https://api.freeagent.com/v2/journal_sets/1 + dated_on: + type: string + description: Absent on the opening balances set + example: '2026-09-30' + description: + type: string + example: Director's capital introduced + tag: + type: string + description: Omitted until set - tagged sets are read-only in the FreeAgent app + example: MYAPPTAG + journal_entries: + type: array + items: + $ref: '#/components/schemas/JournalEntry' + bank_accounts: + type: array + description: Opening balances set only + items: + $ref: '#/components/schemas/OpeningBalance' + stock_items: + type: array + description: Opening balances set only + items: + $ref: '#/components/schemas/OpeningBalance' + updated_at: + type: string + format: date-time + example: '2026-10-03T08:33:30.000Z' diff --git a/openapi/components/schemas/JournalSetCreatePayload.yaml b/openapi/components/schemas/JournalSetCreatePayload.yaml new file mode 100644 index 0000000..10b30c3 --- /dev/null +++ b/openapi/components/schemas/JournalSetCreatePayload.yaml @@ -0,0 +1,22 @@ +type: object +required: +- dated_on +- description +- journal_entries +properties: + dated_on: + type: string + description: Any date works - an invalid one is a 422 saying it can't be blank + example: '2026-09-30' + description: + type: string + example: Director's capital introduced + tag: + type: string + description: An empty string sets no tag + example: MYAPPTAG + journal_entries: + type: array + description: At least one entry, balancing to zero once each value is rounded to two places + items: + $ref: '#/components/schemas/JournalEntryPayload' diff --git a/openapi/components/schemas/JournalSetListResponse.yaml b/openapi/components/schemas/JournalSetListResponse.yaml new file mode 100644 index 0000000..75cd73e --- /dev/null +++ b/openapi/components/schemas/JournalSetListResponse.yaml @@ -0,0 +1,8 @@ +type: object +required: +- journal_sets +properties: + journal_sets: + type: array + items: + $ref: 'JournalSet.yaml' diff --git a/openapi/components/schemas/JournalSetResponse.yaml b/openapi/components/schemas/JournalSetResponse.yaml new file mode 100644 index 0000000..0ffdb24 --- /dev/null +++ b/openapi/components/schemas/JournalSetResponse.yaml @@ -0,0 +1,6 @@ +type: object +required: +- journal_set +properties: + journal_set: + $ref: 'JournalSet.yaml' diff --git a/openapi/components/schemas/JournalSetUpdatePayload.yaml b/openapi/components/schemas/JournalSetUpdatePayload.yaml new file mode 100644 index 0000000..4abfd3c --- /dev/null +++ b/openapi/components/schemas/JournalSetUpdatePayload.yaml @@ -0,0 +1,19 @@ +type: object +description: Fields left out are kept - the opening balances set's date and description are locked, a 400 +properties: + dated_on: + type: string + description: A blank or invalid date is a 422 + example: '2026-09-30' + description: + type: string + example: Director's capital introduced + tag: + type: string + description: An empty string removes the tag + example: MYAPPTAG + journal_entries: + type: array + description: An entry with a url is updated, or removed with _destroy, and one without is added - entries left out are kept + items: + $ref: '#/components/schemas/JournalEntryPayload' diff --git a/openapi/components/schemas/OpeningBalance.yaml b/openapi/components/schemas/OpeningBalance.yaml new file mode 100644 index 0000000..0a19560 --- /dev/null +++ b/openapi/components/schemas/OpeningBalance.yaml @@ -0,0 +1,13 @@ +type: object +required: + - url +properties: + url: + type: string + example: https://api.freeagent.com/v2/bank_accounts/1 + description: + type: string + example: Business Current Account + debit_value: + type: string + example: '-1000.0' diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 5914190..bbca93d 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -163,6 +163,12 @@ paths: $ref: 'paths/self_assessment_returns.yaml#/mark_payment_as_paid' /v2/users/{user_id}/self_assessment_returns/{period_ends_on}/payments/{payment_date}/mark_as_unpaid: $ref: 'paths/self_assessment_returns.yaml#/mark_payment_as_unpaid' + /v2/journal_sets: + $ref: 'paths/journal_sets.yaml#/list' + /v2/journal_sets/opening_balances: + $ref: 'paths/journal_sets.yaml#/opening_balances' + /v2/journal_sets/{id}: + $ref: 'paths/journal_sets.yaml#/show' components: schemas: InvoiceView: @@ -361,6 +367,22 @@ components: $ref: 'components/schemas/UserRole.yaml' UserPermissionLevel: $ref: 'components/schemas/UserPermissionLevel.yaml' + JournalSet: + $ref: 'components/schemas/JournalSet.yaml' + JournalEntry: + $ref: 'components/schemas/JournalEntry.yaml' + OpeningBalance: + $ref: 'components/schemas/OpeningBalance.yaml' + JournalSetResponse: + $ref: 'components/schemas/JournalSetResponse.yaml' + JournalSetListResponse: + $ref: 'components/schemas/JournalSetListResponse.yaml' + JournalSetCreatePayload: + $ref: 'components/schemas/JournalSetCreatePayload.yaml' + JournalSetUpdatePayload: + $ref: 'components/schemas/JournalSetUpdatePayload.yaml' + JournalEntryPayload: + $ref: 'components/schemas/JournalEntryPayload.yaml' Errors: $ref: 'components/schemas/Errors.yaml' ErrorDetail: @@ -514,3 +536,4 @@ tags: - name: Corporation Tax Returns - name: Self Assessment Returns - name: Users +- name: Journal Sets diff --git a/openapi/paths/journal_sets.yaml b/openapi/paths/journal_sets.yaml new file mode 100644 index 0000000..8a0fe8e --- /dev/null +++ b/openapi/paths/journal_sets.yaml @@ -0,0 +1,172 @@ +list: + get: + tags: + - Journal Sets + summary: List journal sets + description: List journal sets by date, the opening balances set first - sort is ignored + operationId: listJournalSets + parameters: + - name: from_date + in: query + description: Show journal sets dated on or after this date - FreeAgent answers an invalid one with a 400, and leaves out the undated opening balances set + schema: + type: string + example: '2026-01-01' + - name: to_date + in: query + description: Show journal sets dated on or before this date - FreeAgent answers an invalid one with a 400, and leaves out the undated opening balances set + schema: + type: string + example: '2026-03-31' + - name: updated_since + in: query + description: Show journal sets updated since this date or timestamp - FreeAgent answers an invalid one with a 400 + schema: + type: string + example: 2026-01-01T00:00:00Z + - name: tag + in: query + description: Show journal sets with this tag, ignoring case + schema: + type: string + example: MYAPPTAG + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/PerPage' + responses: + '200': + description: Success + headers: + Link: + $ref: '#/components/headers/Link' + X-Total-Count: + $ref: '#/components/headers/XTotalCount' + content: + application/json: + schema: + $ref: '#/components/schemas/JournalSetListResponse' + default: + $ref: '#/components/responses/ErrorResponse' + post: + tags: + - Journal Sets + summary: Create journal set + description: Create journal set - its entries must balance, or the create is a 422 + operationId: createJournalSet + requestBody: + content: + application/json: + schema: + type: object + required: + - journal_set + properties: + journal_set: + $ref: '#/components/schemas/JournalSetCreatePayload' + examples: + Create Journal Set: + value: + journal_set: + dated_on: '2026-09-30' + description: Director's capital introduced + journal_entries: + - category: https://api.freeagent.com/v2/categories/901 + user: https://api.freeagent.com/v2/users/1 + debit_value: -123.45 + - category: https://api.freeagent.com/v2/categories/999 + debit_value: 123.45 + responses: + '201': + description: Created + content: + application/json: + schema: + $ref: '#/components/schemas/JournalSetResponse' + default: + $ref: '#/components/responses/ErrorResponse' +opening_balances: + get: + tags: + - Journal Sets + summary: Show opening balances + description: Show the opening balances journal set - the same record as showing it by ID + operationId: showOpeningBalances + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/JournalSetResponse' + default: + $ref: '#/components/responses/ErrorResponse' +show: + get: + tags: + - Journal Sets + summary: Show journal set + description: Show journal set + operationId: showJournalSet + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/JournalSetResponse' + default: + $ref: '#/components/responses/ErrorResponse' + put: + tags: + - Journal Sets + summary: Update journal set + description: Update journal set - the entries must still balance afterwards, or the update is a 422 that changes nothing + operationId: updateJournalSet + requestBody: + content: + application/json: + schema: + type: object + required: + - journal_set + properties: + journal_set: + $ref: '#/components/schemas/JournalSetUpdatePayload' + examples: + Update Journal Set: + value: + journal_set: + description: Journal Set for 2014-01-18 + journal_entries: + - url: https://api.freeagent.com/v2/journal_sets/37/journal_entries/17 + _destroy: true + - url: https://api.freeagent.com/v2/journal_sets/37/journal_entries/18 + debit_value: -20 + - category: https://api.freeagent.com/v2/categories/001 + debit_value: 40 + responses: + '200': + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/JournalSetResponse' + default: + $ref: '#/components/responses/ErrorResponse' + delete: + tags: + - Journal Sets + summary: Delete journal set + description: Delete journal set + operationId: deleteJournalSet + responses: + '200': + description: Success + default: + $ref: '#/components/responses/ErrorResponse' + parameters: + - name: id + in: path + required: true + schema: + type: string + example: '' From ac03b82916f9625814adda763e76a1661dc9c01b Mon Sep 17 00:00:00 2001 From: Dino Constantinou Date: Sat, 3 Oct 2026 09:53:41 +0100 Subject: [PATCH 2/3] Send Journal Entry IDs When Replacing Entries Co-Authored-By: Claude Opus 5.5 --- .../Commands/JournalSet/JournalSetUpdateCommand.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift index 8c41e0d..bd9f9bb 100644 --- a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift @@ -67,6 +67,6 @@ struct JournalSetUpdateCommand: MutatingCommand { let current = try await client.showJournalSet(.init(path: .init(id: id.value))) .ok.body.json.journalSet.journalEntries ?? [] - return current.map { .init(url: $0.url, _destroy: true) } + entry.map(\.payload) + return current.map { .init(url: ResourceID($0.url).value, _destroy: true) } + entry.map(\.payload) } } From 9a702bb29cbcbcc5735bc0f08d72d0bf7800af39 Mon Sep 17 00:00:00 2001 From: Dino Constantinou Date: Sat, 3 Oct 2026 14:50:21 +0100 Subject: [PATCH 3/3] Take Journal Entries As JSON Co-Authored-By: Claude Opus 5.5 --- .../JournalSet/JournalSetCreateCommand.swift | 17 +- .../JournalSet/JournalSetUpdateCommand.swift | 6 +- ...alEntryPayload+ExpressibleByArgument.swift | 13 ++ .../Models/JournalEntryArgument.swift | 149 ------------------ .../JournalSetCreateCommandTests.swift | 6 +- .../JournalSetUpdateCommandTests.swift | 4 +- ...ryPayload+ExpressibleByArgumentTests.swift | 37 +++++ .../Models/JournalEntryArgumentTests.swift | 88 ----------- .../schemas/JournalEntryPayload.yaml | 1 + 9 files changed, 70 insertions(+), 251 deletions(-) create mode 100644 Sources/FreeAgentCLI/ExpressibleByArgument/JournalEntryPayload+ExpressibleByArgument.swift delete mode 100644 Sources/FreeAgentCLI/Models/JournalEntryArgument.swift create mode 100644 Tests/FreeAgentCLITests/ExpressibleByArgument/JournalEntryPayload+ExpressibleByArgumentTests.swift delete mode 100644 Tests/FreeAgentCLITests/Models/JournalEntryArgumentTests.swift diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetCreateCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetCreateCommand.swift index b063bb5..bf1d43d 100644 --- a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetCreateCommand.swift +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetCreateCommand.swift @@ -8,11 +8,16 @@ struct JournalSetCreateCommand: MutatingCommand { abstract: "Create a journal set", discussion: """ The entries must balance: their debit values, negative for credits, add up to zero. User categories \ - such as 901 and 907 need a user, and capital asset categories 601 to 607 a capital asset type. + such as 901 and 907 need a user, and capital asset categories 601 to 607 a capital asset type. \ + Categories, users and other records take a URL or an ID. Example: - --entry category=907,user=1,debit-value=250 - --entry category=999,debit-value=-250,description="Suspense, to clear" + --entry '{"category": "907", "user": "1", "debit_value": 250}' + --entry '{ + "category": "999", + "debit_value": -250, + "description": "Suspense, to clear" + }' """ ) @@ -25,8 +30,8 @@ struct JournalSetCreateCommand: MutatingCommand { @Option(name: .long, help: "Tag identifying sets your tool creates - tagged sets are read-only in FreeAgent") var tag: String? - @Option(name: .long, help: ArgumentHelp(JournalEntryArgument.help), transform: JournalEntryArgument.init) - var entry: [JournalEntryArgument] + @Option(name: .long, help: "An entry as a JSON object of the API's entry fields, repeated for each entry") + var entry: [Components.Schemas.JournalEntryPayload] @Flag(help: "Print the request instead of sending it") var dryRun = false @@ -39,7 +44,7 @@ struct JournalSetCreateCommand: MutatingCommand { datedOn: datedOn, description: description, tag: tag, - journalEntries: entry.map(\.payload) + journalEntries: entry ) let input = Operations.CreateJournalSet.Input( diff --git a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift index bd9f9bb..64d9e8c 100644 --- a/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift +++ b/Sources/FreeAgentCLI/Commands/JournalSet/JournalSetUpdateCommand.swift @@ -27,8 +27,8 @@ struct JournalSetUpdateCommand: MutatingCommand { @Option(name: .long, help: "Tag identifying sets your tool creates - an empty string removes it") var tag: String? - @Option(name: .long, help: ArgumentHelp(JournalEntryArgument.help), transform: JournalEntryArgument.init) - var entry = [JournalEntryArgument]() + @Option(name: .long, help: "An entry as a JSON object of the API's entry fields, repeated for each entry") + var entry = [Components.Schemas.JournalEntryPayload]() @Flag(help: "Print the request instead of sending it") var dryRun = false @@ -67,6 +67,6 @@ struct JournalSetUpdateCommand: MutatingCommand { let current = try await client.showJournalSet(.init(path: .init(id: id.value))) .ok.body.json.journalSet.journalEntries ?? [] - return current.map { .init(url: ResourceID($0.url).value, _destroy: true) } + entry.map(\.payload) + return current.map { .init(url: ResourceID($0.url).value, _destroy: true) } + entry } } diff --git a/Sources/FreeAgentCLI/ExpressibleByArgument/JournalEntryPayload+ExpressibleByArgument.swift b/Sources/FreeAgentCLI/ExpressibleByArgument/JournalEntryPayload+ExpressibleByArgument.swift new file mode 100644 index 0000000..d8169ef --- /dev/null +++ b/Sources/FreeAgentCLI/ExpressibleByArgument/JournalEntryPayload+ExpressibleByArgument.swift @@ -0,0 +1,13 @@ +import ArgumentParser +import Foundation +import FreeAgentAPI + +extension Components.Schemas.JournalEntryPayload: ExpressibleByArgument { + public init?(argument: String) { + guard let entry = try? JSONDecoder().decode(Self.self, from: Data(argument.utf8)) else { + return nil + } + + self = entry + } +} diff --git a/Sources/FreeAgentCLI/Models/JournalEntryArgument.swift b/Sources/FreeAgentCLI/Models/JournalEntryArgument.swift deleted file mode 100644 index a023d1e..0000000 --- a/Sources/FreeAgentCLI/Models/JournalEntryArgument.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation -import FreeAgentAPI - -// MARK: - JournalEntryArgument - -struct JournalEntryArgument: Sendable { - - // MARK: Lifecycle - - init(_ argument: String) throws(JournalEntryArgumentError) { - var values = [Key: String]() - - for field in try Self.fields(in: argument) { - guard let separator = field.firstIndex(of: "=") else { - throw .missingValue(field) - } - - let name = field[.. [String] { - var fields = [String]() - var field = "" - var isQuoted = false - - for character in argument { - switch character { - case "\"": - isQuoted.toggle() - - case "," where !isQuoted: - fields.append(field) - field = "" - - default: - field.append(character) - } - } - - guard !isQuoted else { - throw .unclosedQuote - } - - return fields + [field] - } - - private static func debitValue(_ value: String) throws(JournalEntryArgumentError) -> Double { - guard let number = Double(value), number.isFinite else { - throw .notANumber(.debitValue, value) - } - - return number - } - - private static func stockAlteringQuantity(_ value: String) throws(JournalEntryArgumentError) -> Int { - guard let number = Int(value) else { - throw .notANumber(.stockAlteringQuantity, value) - } - - return number - } -} - -// MARK: - JournalEntryArgumentError - -enum JournalEntryArgumentError: LocalizedError, Equatable { - case missingValue(String) - case unknownKey(String) - case repeatedKey(JournalEntryArgument.Key) - case emptyValue(JournalEntryArgument.Key) - case notANumber(JournalEntryArgument.Key, String) - case missingCategory - case unclosedQuote - - // MARK: Internal - - var errorDescription: String? { - switch self { - case .missingValue(let field): - "'\(field)' is not a key=value field" - case .unknownKey(let key): - "'\(key)' is not a key - use \(JournalEntryArgument.Key.allCases.map(\.rawValue).joined(separator: ", "))" - case .repeatedKey(let key): - "\(key.rawValue) is given more than once" - case .emptyValue(let key): - "\(key.rawValue) needs a value" - case .notANumber(let key, let value): - "\(key.rawValue) must be a number, not '\(value)'" - case .missingCategory: - "every entry needs a category" - case .unclosedQuote: - "a double quote is not closed" - } - } -} diff --git a/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetCreateCommandTests.swift b/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetCreateCommandTests.swift index 8512073..d6a998a 100644 --- a/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetCreateCommandTests.swift +++ b/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetCreateCommandTests.swift @@ -19,11 +19,11 @@ struct JournalSetCreateCommandTests { "--description", "Correction", "--entry", - "category=280,debit-value=10", + #"{"category": "280", "debit_value": 10}"#, "--entry", - "category=999,debit-value=-10", + #"{"category": "999", "debit_value": -10}"#, ]) - #expect(command.entry.map(\.payload.category) == ["280", "999"]) + #expect(command.entry.map(\.category) == ["280", "999"]) } } diff --git a/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetUpdateCommandTests.swift b/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetUpdateCommandTests.swift index 588b5c1..9b39f9a 100644 --- a/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetUpdateCommandTests.swift +++ b/Tests/FreeAgentCLITests/Commands/JournalSet/JournalSetUpdateCommandTests.swift @@ -30,9 +30,9 @@ struct JournalSetUpdateCommandTests { let command = try JournalSetUpdateCommand.parse([ "89633", "--entry", - "category=250,debit-value=7", + #"{"category": "250", "debit_value": 7}"#, "--entry", - "category=999,debit-value=-7", + #"{"category": "999", "debit_value": -7}"#, ]) _ = try await command.perform(client: client(transport)) diff --git a/Tests/FreeAgentCLITests/ExpressibleByArgument/JournalEntryPayload+ExpressibleByArgumentTests.swift b/Tests/FreeAgentCLITests/ExpressibleByArgument/JournalEntryPayload+ExpressibleByArgumentTests.swift new file mode 100644 index 0000000..f73ee15 --- /dev/null +++ b/Tests/FreeAgentCLITests/ExpressibleByArgument/JournalEntryPayload+ExpressibleByArgumentTests.swift @@ -0,0 +1,37 @@ +import ArgumentParser +import FreeAgentAPI +import Testing + +@testable import FreeAgentCLI + +struct JournalEntryPayloadExpressibleByArgumentTests { + @Test("reads an entry from JSON over several lines") + func readsJSON() throws { + let entry = try #require(Components.Schemas.JournalEntryPayload(argument: """ + { + "category": "907", + "user": "32382", + "debit_value": -123.45, + "description": "Loan, repaid" + } + """)) + + #expect(entry.category == "907") + #expect(entry.user == "32382") + #expect(entry.debitValue == -123.45) + #expect(entry.description == "Loan, repaid") + } + + @Test( + "refuses anything but an entry object", + arguments: [ + "category=907", + #"["907"]"#, + #"{"category": "907", "debit": 10}"#, + #"{"category": "907", "debit_value": "ten"}"#, + ] + ) + func refusesOtherInput(argument: String) { + #expect(Components.Schemas.JournalEntryPayload(argument: argument) == nil) + } +} diff --git a/Tests/FreeAgentCLITests/Models/JournalEntryArgumentTests.swift b/Tests/FreeAgentCLITests/Models/JournalEntryArgumentTests.swift deleted file mode 100644 index a9a2f76..0000000 --- a/Tests/FreeAgentCLITests/Models/JournalEntryArgumentTests.swift +++ /dev/null @@ -1,88 +0,0 @@ -import FreeAgentAPI -import Testing - -@testable import FreeAgentCLI - -struct JournalEntryArgumentTests { - @Test("reads every key into the entry") - func readsEveryKey() throws { - let payload = try JournalEntryArgument( - "category=601,debit-value=-123.45,description=Laptop,user=1,capital-asset-type=2,stock-item=3,stock-altering-quantity=-4,contact=5" - ).payload - - #expect(payload.category == "601") - #expect(payload.debitValue == -123.45) - #expect(payload.description == "Laptop") - #expect(payload.user == "1") - #expect(payload.capitalAssetType == "2") - #expect(payload.stockItem == "3") - #expect(payload.stockAlteringQuantity == -4) - #expect(payload.contact == "5") - #expect(payload.url == nil) - #expect(payload._destroy == nil) - } - - @Test("reduces URLs to IDs") - func reducesURLs() throws { - let payload = try JournalEntryArgument( - "category=https://api.freeagent.com/v2/categories/907,user=https://api.freeagent.com/v2/users/32382" - ).payload - - #expect(payload.category == "907") - #expect(payload.user == "32382") - } - - @Test("leaves out keys that are not given") - func leavesOutMissingKeys() throws { - let payload = try JournalEntryArgument("category=999").payload - - #expect(payload.category == "999") - #expect(payload.debitValue == nil) - #expect(payload.description == nil) - #expect(payload.user == nil) - } - - @Test("keeps commas and equals signs inside double quotes") - func quotedValue() throws { - let payload = try JournalEntryArgument(#"category=999,description="Rent, March = 2",debit-value=5"#).payload - - #expect(payload.description == "Rent, March = 2") - #expect(payload.debitValue == 5) - } - - @Test("ignores spaces around keys") - func spacedKeys() throws { - let payload = try JournalEntryArgument("category=999, debit-value=5").payload - - #expect(payload.debitValue == 5) - } - - @Test( - "refuses a malformed entry", - arguments: [ - ("debit-value=5", JournalEntryArgumentError.missingCategory), - ("category=999,debit=5", .unknownKey("debit")), - ("category=999,category=280", .repeatedKey(.category)), - ("category=999,description=", .emptyValue(.description)), - ("category=999,debit-value=ten", .notANumber(.debitValue, "ten")), - ("category=999,debit-value=inf", .notANumber(.debitValue, "inf")), - ("category=999,stock-altering-quantity=1.5", .notANumber(.stockAlteringQuantity, "1.5")), - ("category=999,5", .missingValue("5")), - ("category=999,", .missingValue("")), - (#"category=999,description="Rent"#, .unclosedQuote), - ] - ) - func refusesMalformedEntry(argument: String, error: JournalEntryArgumentError) { - #expect(throws: error) { - try JournalEntryArgument(argument) - } - } - - @Test("names the keys when one is unknown") - func namesKeys() { - #expect( - JournalEntryArgumentError.unknownKey("debit").errorDescription - == "'debit' is not a key - use category, debit-value, description, user, capital-asset-type, stock-item, stock-altering-quantity, contact" - ) - } -} diff --git a/openapi/components/schemas/JournalEntryPayload.yaml b/openapi/components/schemas/JournalEntryPayload.yaml index 131d87d..eb1174a 100644 --- a/openapi/components/schemas/JournalEntryPayload.yaml +++ b/openapi/components/schemas/JournalEntryPayload.yaml @@ -1,4 +1,5 @@ type: object +additionalProperties: false properties: url: type: string