Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/FreeAgentCLI/Commands/Bill/BillShowCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import FreeAgentAPI

struct BillShowCommand: ShowCommand {
typealias PricedItem = (item: Components.Schemas.BillItem, currency: String?)
typealias PricedItem = (item: Components.Schemas.BillItem, currency: Components.Schemas.Currency?)

static let configuration = CommandConfiguration(
commandName: "show",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct ContactListCommand: AsyncPaginatedListCommand {
Field("Organisation") { .text($0.organisationName) }
Field("Name") { .text([$0.firstName, $0.lastName].compactMap(\.self).joined(separator: " ")) }
Field("Email") { .text($0.email) }
Field("Status") { .status($0.status.rawValue) }
Field("Status") { .status($0.status) }
}

@Option(name: .long, help: "Filter by view - active by default, which leaves out hidden contacts")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct ContactShowCommand: ShowCommand {
FieldSection("Details") {
Field("Organisation") { .text($0.organisationName) }
Field("Name") { .text([$0.firstName, $0.lastName].compactMap(\.self).joined(separator: " ")) }
Field("Status") { .status($0.status.rawValue) }
Field("Status") { .status($0.status) }
Field("Email") { .text($0.email) }
Field("Billing Email") { .text($0.billingEmail) }
Field("Phone") { .text($0.phoneNumber) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import FreeAgentAPI

struct CreditNoteShowCommand: ShowCommand {
typealias PricedItem = (item: Components.Schemas.InvoiceItem, currency: String?)
typealias PricedItem = (item: Components.Schemas.InvoiceItem, currency: Components.Schemas.Currency?)

static let configuration = CommandConfiguration(
commandName: "show",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import FreeAgentAPI

struct EstimateShowCommand: ShowCommand {
typealias PricedItem = (item: Components.Schemas.EstimateItem, currency: String?)
typealias PricedItem = (item: Components.Schemas.EstimateItem, currency: Components.Schemas.Currency?)

static let configuration = CommandConfiguration(
commandName: "show",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import Foundation
import FreeAgentAPI

struct InvoiceShowCommand: ShowCommand {
typealias PricedItem = (item: Components.Schemas.InvoiceItem, currency: String?)
typealias PricedItem = (item: Components.Schemas.InvoiceItem, currency: Components.Schemas.Currency?)

static let configuration = CommandConfiguration(
commandName: "show",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ struct PriceListItemListCommand: AsyncPaginatedListCommand {
Field("ID") { .id(url: $0.url) }
Field("Code") { .text($0.code) }
Field("Description") { .text($0.description) }
Field("Item Type") { .text($0.itemType?.rawValue) }
Field("Item Type") { .text($0.itemType) }
Field("Quantity") { .text($0.quantity) }
Field("Price") { .currency($0.price, code: nil) }
Field("VAT Status") { .text($0.vatStatus?.rawValue) }
Field("VAT Status") { .text($0.vatStatus) }
}

@Option(name: .long, parsing: .unconditional, help: "Sort order")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,12 @@ struct PriceListItemShowCommand: ShowCommand {
FieldSection("Details") {
Field("Code") { .text($0.code) }
Field("Description") { .text($0.description) }
Field("Item Type") { .text($0.itemType?.rawValue) }
Field("Item Type") { .text($0.itemType) }
Field("Quantity") { .text($0.quantity) }
Field("Price") { .currency($0.price, code: nil) }
}
FieldSection("Tax") {
Field("VAT Status") { .text($0.vatStatus?.rawValue) }
Field("VAT Status") { .text($0.vatStatus) }
Field("Sales Tax Rate") { .percent($0.salesTaxRate) }
Field("Second Sales Tax Rate") { .percent($0.secondSalesTaxRate) }
}
Expand Down
34 changes: 1 addition & 33 deletions Sources/FreeAgentCLI/Commands/Project/ProjectCreateCommand.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import ArgumentParser
import Foundation
import FreeAgentAPI
import Noora

// MARK: - ProjectCreateCommand

struct ProjectCreateCommand: MutatingCommand {

Expand Down Expand Up @@ -65,35 +62,6 @@ struct ProjectCreateCommand: MutatingCommand {
return currency
}

let code = try await client.companyDetails().ok.body.json.company.currency

guard let currency = Components.Schemas.Currency(rawValue: code) else {
throw ProjectCreateCommandError.unsupportedCompanyCurrency(code)
}

return currency
}
}

// MARK: - ProjectCreateCommandError

enum ProjectCreateCommandError: CommandError {
case unsupportedCompanyCurrency(String)

// MARK: Internal

var errorDescription: String? {
switch self {
case .unsupportedCompanyCurrency(let code):
"The company's currency, \(code), is not one a project can use"
}
}

var exitCode: ExitCode {
.validationFailure
}

var takeaways: [TerminalText] {
["Pass \(.command("--currency"))"]
return try await client.companyDetails().ok.body.json.company.currency
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct ProjectListCommand: AsyncPaginatedListCommand {
Field("ID") { .id(url: $0.url) }
Field("Name") { .text($0.name) }
Field("Contact") { .text($0.contactName) }
Field("Status") { .status($0.status?.rawValue) }
Field("Status") { .status($0.status) }
Field("Starts On") { .date($0.startsOn) }
Field("Ends On") { .date($0.endsOn) }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,21 @@ struct ProjectShowCommand: ShowCommand {
FieldSection("Details") {
Field("Name") { .text($0.name) }
Field("Contact") { .text($0.contactName) }
Field("Status") { .status($0.status?.rawValue) }
Field("Status") { .status($0.status) }
Field("PO Reference") { .text($0.contractPoReference) }
Field("IR35") { .flag($0.isIr35) }
Field("Deletable") { .flag($0.isDeletable) }
}
FieldSection("Billing") {
Field("Currency") { .text($0.currency) }
Field("Normal Billing Rate") { .currency($0.normalBillingRate, code: $0.currency) }
Field("Billing Period") { .text($0.billingPeriod?.rawValue) }
Field("Billing Period") { .text($0.billingPeriod) }
Field("Hours Per Day") { .hours($0.hoursPerDay) }
Field("Own Invoice Sequence") { .flag($0.usesProjectInvoiceSequence) }
}
FieldSection("Budget") {
Field("Budget") { budget(of: $0) }
Field("Budget Units") { .text($0.budgetUnits?.rawValue) }
Field("Budget Units") { .text($0.budgetUnits) }
Field("Unbilled Time In Profitability") { .flag($0.includeUnbilledTimeInProfitability) }
}
FieldSection("Dates") {
Expand Down
4 changes: 2 additions & 2 deletions Sources/FreeAgentCLI/Commands/Task/TaskListCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,10 @@ struct TaskListCommand: AsyncPaginatedListCommand {
Field("ID") { .id(url: $0.url) }
Field("Name") { .text($0.name) }
Field("Project") { .id(url: $0.project) }
Field("Status") { .status($0.status?.rawValue) }
Field("Status") { .status($0.status) }
Field("Billable") { .flag($0.isBillable) }
Field("Billing Rate") { .currency($0.billingRate, code: $0.currency) }
Field("Billing Period") { .text($0.billingPeriod?.rawValue) }
Field("Billing Period") { .text($0.billingPeriod) }
}

@Option(name: .long, help: "Filter by status - every status is listed by default")
Expand Down
4 changes: 2 additions & 2 deletions Sources/FreeAgentCLI/Commands/Task/TaskShowCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ struct TaskShowCommand: ShowCommand {
static var sections: [FieldSection<Components.Schemas.Task>] {
FieldSection("Details") {
Field("Name") { .text($0.name) }
Field("Status") { .status($0.status?.rawValue) }
Field("Status") { .status($0.status) }
Field("Deletable") { .flag($0.isDeletable) }
}
FieldSection("Billing") {
Field("Billable") { .flag($0.isBillable) }
Field("Currency") { .text($0.currency) }
Field("Billing Rate") { .currency($0.billingRate, code: $0.currency) }
Field("Billing Period") { .text($0.billingPeriod?.rawValue) }
Field("Billing Period") { .text($0.billingPeriod) }
}
FieldSection("Dates") {
Field("Created") { .timestamp($0.createdAt) }
Expand Down
2 changes: 1 addition & 1 deletion Sources/FreeAgentCLI/Commands/User/UserListCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct UserListCommand: AsyncPaginatedListCommand {
Field("Last Name") { .text($0.lastName) }
Field("Email") { .text($0.email) }
Field("Role") { .text($0.role) }
Field("Permission") { .text($0.permissionLevel?.description) }
Field("Permission") { .text($0.permissionLevel) }
Field("Hidden") { .flag($0.hidden) }
}

Expand Down
2 changes: 1 addition & 1 deletion Sources/FreeAgentCLI/Commands/User/UserShowCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ struct UserShowCommand: ShowCommand {
Field("Last Name") { .text($0.lastName) }
Field("Email") { .text($0.email) }
Field("Role") { .text($0.role) }
Field("Permission") { .text($0.permissionLevel?.description) }
Field("Permission") { .text($0.permissionLevel) }
Field("Hidden") { .flag($0.hidden) }
}
FieldSection("Tax") {
Expand Down
16 changes: 16 additions & 0 deletions Sources/FreeAgentCLI/Fields/FieldValue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,22 @@ enum FieldValue: Sendable {

// MARK: Internal

static func text(_ value: (some RawRepresentable<String>)?) -> FieldValue {
.text(value?.rawValue)
}

static func text(_ value: (some RawRepresentable & CustomStringConvertible)?) -> FieldValue {
.text(value?.description)
}

static func status(_ value: (some RawRepresentable<String>)?) -> FieldValue {
.status(value?.rawValue)
}

static func currency(_ amount: String?, code: (some RawRepresentable<String>)?) -> FieldValue {
.currency(amount, code: code?.rawValue)
}

func formatted(locale: Locale = .current, timeZone: TimeZone = .current) -> String? {
let text =
switch self {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct BankAccountIntegrationTests {
let account = try #require(accounts.first)
#expect(account.url.contains("/v2/bank_accounts/"))
#expect(account._type == "StandardBankAccount")
#expect(account.currency == "GBP")
#expect(account.currency == .gbp)
#expect(try #require(account.createdAt) <= Date())
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ struct CompanyIntegrationTests {
name: "Acme Technologies Ltd",
subdomain: "acmetechnologiesltd",
_type: "UkLimitedCompany",
currency: "GBP",
currency: .gbp,
mileageUnits: "miles",
companyStartDate: "2025-01-01",
freeagentStartDate: "2025-01-01",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,10 @@ struct ExpenseIntegrationTests {

@Test("An expense rebilled through invoice create decodes its lock fields")
func rebilledExpense() async throws {
let project = try #require(try await client.listProjects(.init()).ok.body.json.projects.last)
let project = try #require(
try await client.listProjects(.init()).ok.body.json.projects
.last { !($0.name?.hasPrefix("ZZ Integration Test") ?? false) }
)
let contact = try #require(project.contact)
let user = try await client.showCurrentUser(.init()).ok.body.json.user

Expand Down
4 changes: 2 additions & 2 deletions Tests/FreeAgentAPIIntegrationTests/UserIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ struct UserIntegrationTests {
#expect(created.email == email)
#expect(created.firstName == "Integration")
#expect(created.lastName == "Test")
#expect(created.role == "Employee")
#expect(created.role == .employee)
#expect(created.permissionLevel == .time)
#expect(created.niNumber == "AB123456C")
#expect(created.uniqueTaxReference == "1234567890")
Expand All @@ -94,7 +94,7 @@ struct UserIntegrationTests {

#expect(updated.url == created.url)
#expect(updated.lastName == "Updated")
#expect(updated.role == "Accountant")
#expect(updated.role == .accountant)
#expect(updated.permissionLevel == .taxAccountingAndUsers)
#expect(updated.hidden == true)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,31 +34,17 @@ struct ProjectCreateCommandTests {
verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("createProject")).called(1)
}

@Test("asks for --currency when the company's currency is not one a project can use")
func unsupportedCompanyCurrency() async throws {
let transport = transport(companyCurrency: "HRK")
let command = try ProjectCreateCommand.parse(["--contact", "215832", "--name", "Website"])

let error = await #expect(throws: ProjectCreateCommandError.self) {
try await command.perform(client: client(transport))
}

#expect(error?.errorDescription == "The company's currency, HRK, is not one a project can use")
#expect(error?.exitCode == ExitCode.validationFailure)
verify(transport).send(.any, body: .any, baseURL: .any, operationID: .value("createProject")).called(0)
}

// MARK: Private

private func transport(companyCurrency: String = "GBP") -> MockClientTransportInterface {
private func transport() -> MockClientTransportInterface {
let transport = MockClientTransportInterface()
given(transport)
.send(.any, body: .any, baseURL: .any, operationID: .value("companyDetails"))
.willReturn((
HTTPResponse(status: .ok, headerFields: [.contentType: "application/json"]),
HTTPBody(Data(#"""
{"company":{"url":"https://api.freeagent.com/v2/company","name":"Acme Technologies Ltd",\#
"subdomain":"acmetechnologiesltd","type":"UkLimitedCompany","currency":"\#(companyCurrency)","mileage_units":"miles",\#
"subdomain":"acmetechnologiesltd","type":"UkLimitedCompany","currency":"GBP","mileage_units":"miles",\#
"company_start_date":"2025-01-01","freeagent_start_date":"2025-01-01","first_accounting_year_end":"2026-01-31"}}
"""#.utf8))
))
Expand Down
19 changes: 19 additions & 0 deletions Tests/FreeAgentCLITests/Fields/FieldValueTests.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Foundation
import FreeAgentAPI
import Testing

@testable import FreeAgentCLI
Expand Down Expand Up @@ -44,6 +45,24 @@ struct FieldValueTests {
#expect(format(.status("Overdue")) == "Overdue")
}

@Test("shows an enum as the value FreeAgent sends")
func formatsEnum() {
#expect(format(.text(Components.Schemas.PriceListItemType._hyphen_noUnit)) == "-no unit-")
#expect(format(.status(Components.Schemas.TaskStatus.completed)) == "Completed")
#expect(format(.text(Components.Schemas.VatStatus?.none)) == nil)
}

@Test("shows a described value by its description")
func formatsDescribed() {
#expect(format(.text(PermissionLevel.full)) == PermissionLevel.full.description)
}

@Test("formats an amount in its currency enum")
func formatsCurrencyEnum() {
#expect(format(.currency("120.5", code: Components.Schemas.Currency.eur)) == "€120.50")
#expect(format(.currency("-840.0", code: Components.Schemas.Currency?.none)) == "-840.00")
}

@Test(
"formats an amount in its currency",
arguments: [
Expand Down
3 changes: 1 addition & 2 deletions openapi/components/schemas/BankAccount.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,7 @@ properties:
type: string
example: generic
currency:
type: string
example: GBP
$ref: '#/components/schemas/Currency'
status:
type: string
example: active
Expand Down
3 changes: 1 addition & 2 deletions openapi/components/schemas/Bill.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@ properties:
type: string
example: Open
currency:
type: string
example: GBP
$ref: '#/components/schemas/Currency'
exchange_rate:
type: string
example: '1.0'
Expand Down
3 changes: 1 addition & 2 deletions openapi/components/schemas/Company.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,7 @@ properties:
type: string
example: UkLimitedCompany
currency:
type: string
example: GBP
$ref: '#/components/schemas/Currency'
mileage_units:
type: string
example: miles
Expand Down
3 changes: 1 addition & 2 deletions openapi/components/schemas/CreditNote.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,7 @@ properties:
description: Omitted until set
example: Refund for cancelled work
currency:
type: string
example: GBP
$ref: '#/components/schemas/Currency'
exchange_rate:
type: string
example: '1.0'
Expand Down
1 change: 1 addition & 0 deletions openapi/components/schemas/CreditNoteCreatePayload.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ properties:
example: CN-001
currency:
$ref: '#/components/schemas/Currency'
description: The retired codes are refused, and SSP and VES are a 500 that creates nothing
comments:
type: string
example: Refund for cancelled work
Loading
Loading