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
19 changes: 16 additions & 3 deletions Sources/FreeAgentCLI/Commands/Expense/ExpenseUpdateCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ struct ExpenseUpdateCommand: MutatingCommand {
static let configuration = CommandConfiguration(
commandName: "update",
abstract: "Update an expense",
discussion: "Fields left out are kept, and an empty --receipt-reference removes it. A mileage claim's engine can't be changed."
discussion: """
Fields left out are kept, and an empty --receipt-reference removes it. A mileage claim's engine can't be \
changed. --attachment replaces any attachment the expense has, and FreeAgent deletes the old file.
"""
)

@Argument(help: "Expense ID or URL")
Expand All @@ -26,18 +29,22 @@ struct ExpenseUpdateCommand: MutatingCommand {

@OptionGroup var expense: ExpenseOptions

@Flag(name: .long, help: "Remove the expense's attachment, which FreeAgent deletes")
var removeAttachment = false

@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.ExpenseResponse {
let expensePayload = expense.updatePayload(
let expensePayload = try expense.updatePayload(
user: user?.value,
category: category?.value,
datedOn: datedOn,
description: description
description: description,
removeAttachment: removeAttachment
)

let input = Operations.UpdateExpense.Input(
Expand All @@ -49,6 +56,12 @@ struct ExpenseUpdateCommand: MutatingCommand {
.ok.body.json
}

func validate() throws {
if removeAttachment, expense.attachment.path != nil {
throw ValidationError("Pass --attachment or --remove-attachment, not both")
}
}

func success(for _: Components.Schemas.ExpenseResponse) -> String {
"Updated expense \(id)"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,8 @@
import ArgumentParser
import Foundation
import FreeAgentAPI
import Noora

// MARK: - ExplanationAttachmentAddCommand

struct ExplanationAttachmentAddCommand: MutatingCommand {

// MARK: Internal

static let configuration = CommandConfiguration(
commandName: "add",
abstract: "Add attachments to a bank transaction explanation",
Expand All @@ -32,17 +26,12 @@ struct ExplanationAttachmentAddCommand: MutatingCommand {

func perform(client: Client) async throws -> Components.Schemas.AttachmentListResponse {
let attachments = try file.map { path in
let url = URL(fileURLWithPath: path)
let ext = url.pathExtension.lowercased()
let attachment = try AttachmentFile(path: path)

guard let contentType = Self.contentTypes[ext] else {
throw ExplanationAttachmentAddCommandError.unsupportedFileType(ext)
}

return try Components.Schemas.AttachmentCreatePayload(
data: Data(contentsOf: url).base64EncodedString(),
fileName: url.lastPathComponent,
contentType: contentType,
return Components.Schemas.AttachmentCreatePayload(
data: attachment.data,
fileName: attachment.fileName,
contentType: attachment.contentType,
description: description
)
}
Expand All @@ -61,39 +50,4 @@ struct ExplanationAttachmentAddCommand: MutatingCommand {
? "Added the attachment to explanation \(id)"
: "Added \(file.count) attachments to explanation \(id)"
}

// MARK: Private

private static let contentTypes = [
"pdf": "application/pdf",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
]

}

// MARK: - ExplanationAttachmentAddCommandError

enum ExplanationAttachmentAddCommandError: CommandError {
case unsupportedFileType(String)

var errorDescription: String? {
switch self {
case .unsupportedFileType(let ext):
"Unsupported attachment type '\(ext)'"
}
}

var exitCode: ExitCode {
.validationFailure
}

var takeaways: [TerminalText] {
switch self {
case .unsupportedFileType:
["Attach a pdf, png, jpg, jpeg or gif file"]
}
}
}
76 changes: 76 additions & 0 deletions Sources/FreeAgentCLI/Models/AttachmentFile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import ArgumentParser
import Foundation
import Noora

// MARK: - AttachmentFile

struct AttachmentFile {

// MARK: Lifecycle

init(path: String) throws {
let url = URL(fileURLWithPath: path)
let ext = url.pathExtension.lowercased()

guard let contentType = Self.contentTypes[ext] else {
throw AttachmentFileError.unsupportedFileType(ext)
}

do {
data = try Data(contentsOf: url).base64EncodedString()
} catch {
throw AttachmentFileError.unreadable(path: path)
}

fileName = url.lastPathComponent
self.contentType = contentType
}

// MARK: Internal

let data: String
let fileName: String
let contentType: String

// MARK: Private

private static let contentTypes = [
"pdf": "application/pdf",
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"gif": "image/gif",
]

}

// MARK: - AttachmentFileError

enum AttachmentFileError: CommandError {
case unsupportedFileType(String)
case unreadable(path: String)

// MARK: Internal

var errorDescription: String? {
switch self {
case .unsupportedFileType(let ext):
"Unsupported attachment type '\(ext)'"
case .unreadable(let path):
"Couldn't read \(path)"
}
}

var exitCode: ExitCode {
.validationFailure
}

var takeaways: [TerminalText] {
switch self {
case .unsupportedFileType:
["Attach a pdf, png, jpg, jpeg or gif file"]
case .unreadable:
["Check the file exists and is readable"]
}
}
}
38 changes: 38 additions & 0 deletions Sources/FreeAgentCLI/Options/ExpenseAttachmentOptions.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import ArgumentParser
import FreeAgentAPI

struct ExpenseAttachmentOptions: ParsableArguments {
@Option(name: .customLong("attachment"), help: "Path to a receipt to attach (pdf, png, jpg, jpeg or gif), up to 5MB")
var path: String?

@Option(name: .customLong("attachment-description"), help: "Description of the attached receipt - needs --attachment")
var description: String?

var createPayload: Components.Schemas.ExpenseAttachmentPayload? {
get throws {
guard let path else {
return nil
}

let file = try AttachmentFile(path: path)
return .init(data: file.data, fileName: file.fileName, contentType: file.contentType, description: description)
}
}

var updatePayload: Components.Schemas.ExpenseAttachmentUpdatePayload? {
get throws {
guard let path else {
return nil
}

let file = try AttachmentFile(path: path)
return .init(data: file.data, fileName: file.fileName, contentType: file.contentType, description: description)
}
}

func validate() throws {
if description != nil, path == nil {
throw ValidationError("--attachment-description needs --attachment")
}
}
}
19 changes: 12 additions & 7 deletions Sources/FreeAgentCLI/Options/ExpenseOptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,16 @@ struct ExpenseOptions: ParsableArguments {
@Option(name: .long, help: "Whether a mileage claim has a VAT receipt for its fuel")
var haveVatReceipt: Bool?

@OptionGroup var attachment: ExpenseAttachmentOptions

func createPayload(
user: String,
category: String,
datedOn: String,
description: String,
engine: ExpenseEngineOptions
) -> Components.Schemas.ExpenseCreatePayload {
.init(
) throws -> Components.Schemas.ExpenseCreatePayload {
try .init(
user: user,
category: category,
datedOn: datedOn,
Expand All @@ -73,17 +75,19 @@ struct ExpenseOptions: ParsableArguments {
vehicleType: vehicleType,
engineType: engine.type,
engineSize: engine.size,
haveVatReceipt: haveVatReceipt
haveVatReceipt: haveVatReceipt,
attachment: attachment.createPayload
)
}

func updatePayload(
user: String?,
category: String?,
datedOn: String?,
description: String?
) -> Components.Schemas.ExpenseUpdatePayload {
.init(
description: String?,
removeAttachment: Bool
) throws -> Components.Schemas.ExpenseUpdatePayload {
try .init(
user: user,
category: category,
datedOn: datedOn,
Expand All @@ -101,7 +105,8 @@ struct ExpenseOptions: ParsableArguments {
recurringEndDate: recurring.endDate,
mileage: mileage,
vehicleType: vehicleType,
haveVatReceipt: haveVatReceipt
haveVatReceipt: haveVatReceipt,
attachment: removeAttachment ? .init(_destroy: 1) : attachment.updatePayload
)
}
}
50 changes: 50 additions & 0 deletions Tests/FreeAgentAPIIntegrationTests/ExpenseIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,53 @@ struct ExpenseIntegrationTests {
_ = try await client.deleteExpense(.init(path: .init(id: id))).ok
}

@Test("An expense attachment is sent on create, removed with _destroy and added on update")
func attachment() async throws {
let user = try await client.showCurrentUser(.init()).ok.body.json.user

let created = try await client.createExpense(
.init(body: .json(.init(expense: .init(
user: Self.id(of: user.url),
category: "285",
datedOn: Self.today,
description: "ZZ Integration Test Attachment",
grossValue: "-12.0",
attachment: .init(
data: Self.onePixelPNG,
fileName: "receipt.png",
contentType: "image/png",
description: "Integration test receipt"
)
))))
).created.body.json.expense

let attached = try #require(created.attachment)
#expect(attached.url.contains("/v2/attachments/"))
#expect(attached.fileName == "receipt.png")
#expect(attached.contentType == "image/png")
#expect(attached.description == "Integration test receipt")

let id = Self.id(of: created.url)

let removed = try await client.updateExpense(
.init(path: .init(id: id), body: .json(.init(expense: .init(attachment: .init(_destroy: 1)))))
).ok.body.json.expense

#expect(removed.attachment == nil)

let added = try await client.updateExpense(
.init(path: .init(id: id), body: .json(.init(expense: .init(attachment: .init(
data: Self.onePixelPNG,
fileName: "replacement.png",
contentType: "image/png"
)))))
).ok.body.json.expense

#expect(added.attachment?.fileName == "replacement.png")

_ = try await client.deleteExpense(.init(path: .init(id: id))).ok
}

@Test("GET /v2/expenses/mileage_settings returns typed engine options and rates")
func mileageSettings() async throws {
let settings = try await client.showMileageSettings(.init()).ok.body.json.mileageSettings
Expand Down Expand Up @@ -200,6 +247,9 @@ struct ExpenseIntegrationTests {

// MARK: Private

private static let onePixelPNG =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="

private static var today: String {
date(monthsFromNow: 0)
}
Expand Down

This file was deleted.

Loading
Loading