tax
Version:
TaxMaxi CLI - Crypto tax infrastructure for fintechs, degens, and AI agents.
661 lines (660 loc) • 23.9 kB
JavaScript
import { Command, Options } from "@effect/cli"
import {
FileSystem,
HttpClient,
HttpClientRequest,
HttpClientResponse,
Path,
} from "@effect/platform"
import { NodeContext, NodeHttpClient, NodeRuntime } from "@effect/platform-node"
import { spawn } from "node:child_process"
import { homedir } from "node:os"
import { Console, Duration, Effect, Layer, Schema } from "effect"
import * as Config from "effect/Config"
import * as Option from "effect/Option"
import packageJson from "../package.json" with { type: "json" }
const DEFAULT_API_URL = "https://api.taxmaxi.com"
const API_URL_ENV_VAR = "TAXMAXI_API_URL"
const WORKFLOW_PROVIDER = "coinbase"
const TAX_JURISDICTION = "germany"
const CONNECT_TIMEOUT = Duration.minutes(5)
const CONNECT_POLL_INTERVAL = Duration.seconds(2)
const JOB_TIMEOUT = Duration.minutes(10)
const JOB_POLL_INTERVAL = Duration.seconds(2)
const SESSION_FILE_RELATIVE_PATH = ".config/tax/session.json"
const jsonOption = Options.boolean("json").pipe(
Options.withDescription("Output machine-readable JSON")
)
const noBrowserOption = Options.boolean("no-browser").pipe(
Options.withDescription("Do not attempt to open the connect URL in a browser")
)
const forceOption = Options.boolean("force").pipe(
Options.withDescription("Force re-authentication even when local session is valid")
)
const yearOption = Options.integer("year").pipe(Options.withDescription("Tax year (YYYY)"))
const yearWithDefaultOption = Options.integer("year").pipe(
Options.withDefault(new Date().getUTCFullYear()),
Options.withDescription("Tax year (YYYY)")
)
class CliCommandError extends Schema.TaggedError()("CliCommandError", {
message: Schema.String,
}) {}
const CliSession = Schema.Struct({
apiUrl: Schema.String,
sessionToken: Schema.String,
userId: Schema.String,
connectedAt: Schema.String,
})
const CliSessionJson = Schema.parseJson(CliSession)
const JsonOutput = Schema.parseJson(Schema.Unknown)
const ApiErrorResponse = Schema.parseJson(
Schema.Struct({
message: Schema.String,
})
)
const AuthorizeRedirectResponse = Schema.Struct({
redirectUrl: Schema.String,
state: Schema.String,
})
const OAuthSessionResponse = Schema.Struct({
id: Schema.String,
provider: Schema.Literal("coinbase", "google"),
status: Schema.Literal("pending", "completed", "failed", "expired"),
authorizationUrl: Schema.OptionFromNullOr(Schema.String),
sessionToken: Schema.OptionFromNullOr(Schema.String),
userId: Schema.OptionFromNullOr(Schema.String),
message: Schema.OptionFromNullOr(Schema.String),
expiresAt: Schema.String,
})
const CoinbaseSyncStartResponse = Schema.Struct({
sourceId: Schema.String,
jobId: Schema.String,
status: Schema.Literal("queued", "running", "completed", "failed"),
message: Schema.OptionFromNullOr(Schema.String),
})
const CoinbaseSyncJobResponse = Schema.Struct({
sourceId: Schema.String,
jobId: Schema.String,
status: Schema.Literal("queued", "running", "completed", "failed"),
importedRecords: Schema.OptionFromNullOr(Schema.Number),
normalizedRecords: Schema.OptionFromNullOr(Schema.Number),
failedRecords: Schema.OptionFromNullOr(Schema.Number),
message: Schema.OptionFromNullOr(Schema.String),
})
const SourceListItem = Schema.Struct({
id: Schema.String,
name: Schema.String,
providerKey: Schema.OptionFromNullOr(Schema.String),
})
const SourceListResponse = Schema.Struct({
sources: Schema.Array(SourceListItem),
})
const GermanTaxComputeResponse = Schema.Struct({
year: Schema.Number,
currency: Schema.String,
taxableGains: Schema.Number,
taxableLosses: Schema.Number,
taxFreeGains: Schema.Number,
incomeTotal: Schema.Number,
})
const getSessionFilePath = Effect.gen(function* () {
const path = yield* Path.Path
return path.join(homedir(), SESSION_FILE_RELATIVE_PATH)
})
const saveSession = (session) =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const sessionFilePath = yield* getSessionFilePath
const sessionDir = path.dirname(sessionFilePath)
const encoded = yield* Schema.encode(CliSessionJson)(session).pipe(
Effect.mapError(
() =>
new CliCommandError({
message: "Failed to encode CLI session",
})
)
)
yield* fs.makeDirectory(sessionDir, { recursive: true }).pipe(
Effect.mapError(
() =>
new CliCommandError({
message: "Failed to create CLI config directory",
})
)
)
yield* fs.writeFileString(sessionFilePath, encoded).pipe(
Effect.mapError(
() =>
new CliCommandError({
message: "Failed to persist CLI session",
})
)
)
})
const readSession = () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const sessionFilePath = yield* getSessionFilePath
const raw = yield* fs.readFileString(sessionFilePath).pipe(
Effect.mapError(
() =>
new CliCommandError({
message: "No local CLI session found. Run `tax coinbase connect` first.",
})
)
)
return yield* Schema.decodeUnknown(CliSessionJson)(raw).pipe(
Effect.mapError(
() =>
new CliCommandError({
message: "CLI session file is invalid. Run `tax coinbase connect` again.",
})
)
)
})
const readSessionOption = () => readSession().pipe(Effect.option)
const nowIsoString = Effect.map(
Effect.clockWith((clock) => clock.currentTimeMillis),
(currentTimeMillis) => new Date(Number(currentTimeMillis)).toISOString()
)
const nowMillis = Effect.map(
Effect.clockWith((clock) => clock.currentTimeMillis),
(currentTimeMillis) => Number(currentTimeMillis)
)
const getErrorMessage = (error, fallback) => {
if (typeof error === "object" && error !== null && "message" in error) {
const message = error.message
if (typeof message === "string") {
return message
}
}
return fallback
}
const resolveApiUrl = Config.string(API_URL_ENV_VAR).pipe(
Config.map((configuredUrl) => {
const trimmed = configuredUrl.trim()
return trimmed.length > 0 ? trimmed : DEFAULT_API_URL
}),
Config.orElse(() => Config.succeed(DEFAULT_API_URL))
)
const printJson = (value) =>
Schema.encode(JsonOutput)(value).pipe(
Effect.mapError(
() =>
new CliCommandError({
message: "Failed to encode JSON output",
})
),
Effect.flatMap(Console.log)
)
const decodeApiErrorMessage = (body) =>
Schema.decodeUnknown(ApiErrorResponse)(body).pipe(
Effect.map((response) => response.message),
Effect.orElseSucceed(() => {
const trimmed = body.trim()
return trimmed.length > 0 ? trimmed : "No response body"
})
)
const mapTransportErrorToCliCommandError = (fallback) => (error) =>
error instanceof CliCommandError
? error
: new CliCommandError({
message: getErrorMessage(error, fallback),
})
const openBrowser = (url) => {
const command =
process.platform === "darwin"
? { cmd: "open", args: [url] }
: process.platform === "win32"
? { cmd: "cmd", args: ["/c", "start", "", url] }
: { cmd: "xdg-open", args: [url] }
try {
const child = spawn(command.cmd, command.args, {
detached: true,
stdio: "ignore",
})
child.unref()
return true
} catch {
return false
}
}
const makeApiClient = (apiUrl) =>
Effect.gen(function* () {
const defaultClient = yield* HttpClient.HttpClient
const client = defaultClient.pipe(HttpClient.mapRequest(HttpClientRequest.prependUrl(apiUrl)))
const decodeAuthorizeResponse = HttpClientResponse.schemaBodyJson(AuthorizeRedirectResponse)
const decodeOAuthSession = HttpClientResponse.schemaBodyJson(OAuthSessionResponse)
const decodeSyncStart = HttpClientResponse.schemaBodyJson(CoinbaseSyncStartResponse)
const decodeSyncJob = HttpClientResponse.schemaBodyJson(CoinbaseSyncJobResponse)
const decodeSourceList = HttpClientResponse.schemaBodyJson(SourceListResponse)
const decodeGermanTax = HttpClientResponse.schemaBodyJson(GermanTaxComputeResponse)
const executeAndDecode = (request, decode) =>
Effect.gen(function* () {
const response = yield* client.execute(request)
if (response.status < 200 || response.status >= 300) {
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
const message = yield* decodeApiErrorMessage(body)
return yield* new CliCommandError({
message: `API request failed (${response.status}): ${message}`,
})
}
return yield* decode(response).pipe(
Effect.mapError(
() =>
new CliCommandError({
message: "Failed to decode API response",
})
)
)
}).pipe(Effect.scoped)
return {
startOAuth: () =>
executeAndDecode(
HttpClientRequest.get("/auth/authorize/coinbase"),
decodeAuthorizeResponse
),
getOAuthSession: (id) =>
executeAndDecode(HttpClientRequest.get(`/auth/oauth/${id}`), decodeOAuthSession),
validateSession: (sessionToken) =>
Effect.gen(function* () {
const request = HttpClientRequest.get("/auth/me").pipe(
HttpClientRequest.setHeader("Authorization", `Bearer ${sessionToken}`)
)
const response = yield* client.execute(request).pipe(Effect.scoped)
if (response.status >= 200 && response.status < 300) {
return true
}
if (response.status === 401 || response.status === 403) {
return false
}
const body = yield* response.text.pipe(Effect.orElseSucceed(() => ""))
return yield* new CliCommandError({
message: `Failed to validate existing session (${response.status}): ${body}`,
})
}),
listSources: (sessionToken) =>
executeAndDecode(
HttpClientRequest.get("/v1/sources").pipe(
HttpClientRequest.setHeader("Authorization", `Bearer ${sessionToken}`)
),
decodeSourceList
),
startCoinbaseSync: ({ sessionToken, sourceId }) =>
executeAndDecode(
HttpClientRequest.post(`/v1/sources/${sourceId}/sync`).pipe(
HttpClientRequest.setHeader("Authorization", `Bearer ${sessionToken}`)
),
decodeSyncStart
),
replayCoinbaseSync: ({ sessionToken, sourceId }) =>
executeAndDecode(
HttpClientRequest.post(`/v1/sources/${sourceId}/replay`).pipe(
HttpClientRequest.setHeader("Authorization", `Bearer ${sessionToken}`)
),
decodeSyncStart
),
getSyncJob: ({ sourceId, jobId, sessionToken }) =>
executeAndDecode(
HttpClientRequest.get(`/v1/sources/${sourceId}/jobs/${jobId}`).pipe(
HttpClientRequest.setHeader("Authorization", `Bearer ${sessionToken}`)
),
decodeSyncJob
),
computeGermanTax: ({ sessionToken, sourceId, year }) =>
executeAndDecode(
HttpClientRequest.post(`/v1/sources/${sourceId}/tax`).pipe(
HttpClientRequest.setHeader("Authorization", `Bearer ${sessionToken}`),
HttpClientRequest.bodyUnsafeJson({
year,
jurisdiction: TAX_JURISDICTION,
})
),
decodeGermanTax
),
}
})
const resolveCoinbaseSourceId = ({ apiUrl, sessionToken }) =>
Effect.gen(function* () {
const api = yield* makeApiClient(apiUrl)
const sourceList = yield* api.listSources(sessionToken)
const source = sourceList.sources.find((candidate) => {
const providerKey = Option.getOrNull(candidate.providerKey)
return providerKey !== null && providerKey.toLowerCase() === WORKFLOW_PROVIDER
})
if (source === undefined) {
return yield* new CliCommandError({
message: "No Coinbase source found. Run `tax coinbase connect --force` and try again.",
})
}
return source.id
})
const waitForSyncCompletion = ({ apiUrl, sessionToken, sourceId, jobId }) =>
Effect.gen(function* () {
const api = yield* makeApiClient(apiUrl)
const startedAt = yield* nowMillis
const poll = () =>
Effect.gen(function* () {
const job = yield* api
.getSyncJob({ sourceId, jobId, sessionToken })
.pipe(Effect.mapError(mapTransportErrorToCliCommandError("Failed to poll sync job.")))
if (job.status === "completed") {
return {
sourceId: job.sourceId,
jobId: job.jobId,
importedRecords: Option.getOrElse(job.importedRecords, () => 0),
normalizedRecords: Option.getOrElse(job.normalizedRecords, () => 0),
failedRecords: Option.getOrElse(job.failedRecords, () => 0),
}
}
if (job.status === "failed") {
const message = Option.getOrElse(job.message, () => "Coinbase sync failed.")
return yield* new CliCommandError({ message })
}
const currentTime = yield* nowMillis
if (currentTime - startedAt > Duration.toMillis(JOB_TIMEOUT)) {
return yield* new CliCommandError({
message: "Timed out waiting for Coinbase sync job to finish.",
})
}
yield* Effect.sleep(JOB_POLL_INTERVAL)
return yield* poll()
})
return yield* poll()
})
const printWorkflowSummary = ({ sync, tax }) =>
Effect.gen(function* () {
yield* Console.log(`Coinbase tax summary for ${tax.year} (${tax.currency})`)
yield* Console.log(`Imported records: ${sync.importedRecords}`)
yield* Console.log(`Failed records: ${sync.failedRecords}`)
yield* Console.log(`Taxable gains: ${tax.taxableGains}`)
yield* Console.log(`Taxable losses: ${tax.taxableLosses}`)
yield* Console.log(`Tax-free gains: ${tax.taxFreeGains}`)
yield* Console.log(`Income total: ${tax.incomeTotal}`)
})
const syncProgram = ({ json, emitConsoleOutput = true }) =>
Effect.gen(function* () {
const session = yield* readSession()
const sourceId = yield* resolveCoinbaseSourceId({
apiUrl: session.apiUrl,
sessionToken: session.sessionToken,
})
const api = yield* makeApiClient(session.apiUrl)
const started = yield* api.startCoinbaseSync({
sessionToken: session.sessionToken,
sourceId,
})
const summary = yield* waitForSyncCompletion({
apiUrl: session.apiUrl,
sessionToken: session.sessionToken,
sourceId: started.sourceId,
jobId: started.jobId,
})
if (json) {
yield* printJson({
stage: "sync_completed",
...summary,
})
return summary
}
if (emitConsoleOutput) {
yield* Console.log("Coinbase sync completed.")
yield* Console.log(`Imported: ${summary.importedRecords}`)
yield* Console.log(`Normalized: ${summary.normalizedRecords}`)
yield* Console.log(`Failed: ${summary.failedRecords}`)
}
return summary
})
const replayProgram = ({ json, emitConsoleOutput = true }) =>
Effect.gen(function* () {
const session = yield* readSession()
const sourceId = yield* resolveCoinbaseSourceId({
apiUrl: session.apiUrl,
sessionToken: session.sessionToken,
})
const api = yield* makeApiClient(session.apiUrl)
const started = yield* api.replayCoinbaseSync({
sessionToken: session.sessionToken,
sourceId,
})
const summary = yield* waitForSyncCompletion({
apiUrl: session.apiUrl,
sessionToken: session.sessionToken,
sourceId: started.sourceId,
jobId: started.jobId,
})
if (json) {
yield* printJson({
stage: "replay_completed",
...summary,
})
return summary
}
if (emitConsoleOutput) {
yield* Console.log("Coinbase replay completed.")
yield* Console.log(`Imported: ${summary.importedRecords}`)
yield* Console.log(`Normalized: ${summary.normalizedRecords}`)
yield* Console.log(`Failed: ${summary.failedRecords}`)
}
return summary
})
const calculateProgram = ({ year, json, emitConsoleOutput = true }) =>
Effect.gen(function* () {
const session = yield* readSession()
const sourceId = yield* resolveCoinbaseSourceId({
apiUrl: session.apiUrl,
sessionToken: session.sessionToken,
})
const api = yield* makeApiClient(session.apiUrl)
const taxSummary = yield* api.computeGermanTax({
sessionToken: session.sessionToken,
sourceId,
year,
})
if (json) {
yield* printJson({
stage: "calculate_completed",
...taxSummary,
})
return taxSummary
}
if (emitConsoleOutput) {
yield* Console.log(`German tax summary for ${taxSummary.year} (${taxSummary.currency})`)
yield* Console.log(`Taxable gains: ${taxSummary.taxableGains}`)
yield* Console.log(`Taxable losses: ${taxSummary.taxableLosses}`)
yield* Console.log(`Tax-free gains: ${taxSummary.taxFreeGains}`)
yield* Console.log(`Income total: ${taxSummary.incomeTotal}`)
}
return taxSummary
})
const waitForOAuthCompletion = ({ apiUrl, sessionId }) =>
Effect.gen(function* () {
const api = yield* makeApiClient(apiUrl)
const startedAt = yield* nowMillis
const poll = () =>
Effect.gen(function* () {
const status = yield* api
.getOAuthSession(sessionId)
.pipe(
Effect.mapError(
mapTransportErrorToCliCommandError("Failed to poll OAuth session status.")
)
)
if (
status.status === "completed" &&
Option.isSome(status.sessionToken) &&
Option.isSome(status.userId)
) {
return {
...status,
status: "completed",
sessionToken: status.sessionToken.value,
userId: status.userId.value,
}
}
if (status.status === "failed") {
const message = Option.getOrElse(status.message, () => "OAuth connect failed.")
return yield* new CliCommandError({ message })
}
if (status.status === "expired") {
return yield* new CliCommandError({
message: "OAuth session expired. Please run `tax coinbase connect` again.",
})
}
const currentTime = yield* nowMillis
if (currentTime - startedAt > Duration.toMillis(CONNECT_TIMEOUT)) {
return yield* new CliCommandError({
message: "Timed out waiting for browser authorization.",
})
}
yield* Effect.sleep(CONNECT_POLL_INTERVAL)
return yield* poll()
})
return yield* poll()
})
const connectProgram = ({ json, noBrowser, force }) =>
Effect.gen(function* () {
const apiUrl = yield* resolveApiUrl
const api = yield* makeApiClient(apiUrl)
if (!force) {
const maybeSession = yield* readSessionOption()
if (Option.isSome(maybeSession) && maybeSession.value.apiUrl === apiUrl) {
const isValidSession = yield* api.validateSession(maybeSession.value.sessionToken)
if (isValidSession) {
if (json) {
yield* printJson({
stage: "connect_skipped",
reason: "existing_session_valid",
userId: maybeSession.value.userId,
})
} else {
yield* Console.log("Existing CLI session is valid. Skipping OAuth connect.")
yield* Console.log("Use `--force` to re-authenticate.")
}
return
}
}
}
const started = yield* api.startOAuth()
const authorizationUrl = started.redirectUrl
if (json) {
yield* printJson({
stage: "connect_started",
sessionId: started.state,
connectUrl: authorizationUrl,
expiresAt: null,
})
} else {
yield* Console.log("Starting Coinbase connect flow...")
yield* Console.log(`Open this URL to continue: ${authorizationUrl}`)
}
const didOpenBrowser = noBrowser ? false : openBrowser(authorizationUrl)
if (!json && !noBrowser && !didOpenBrowser) {
yield* Console.log("Could not open browser automatically. Please open the URL manually.")
}
const completed = yield* waitForOAuthCompletion({
apiUrl,
sessionId: started.state,
})
yield* saveSession({
apiUrl,
sessionToken: completed.sessionToken,
userId: completed.userId,
connectedAt: yield* nowIsoString,
})
if (json) {
yield* printJson({
stage: "connect_completed",
userId: completed.userId,
expiresAt: completed.expiresAt,
message: Option.getOrNull(completed.message),
})
} else {
yield* Console.log("Coinbase connected and CLI session saved.")
}
})
const connectCommand = Command.make(
"connect",
{
json: jsonOption,
noBrowser: noBrowserOption,
force: forceOption,
},
({ json, noBrowser, force }) => connectProgram({ json, noBrowser, force })
).pipe(Command.withDescription("Connect Coinbase account via OAuth"))
const syncCommand = Command.make("sync", { json: jsonOption }, ({ json }) =>
syncProgram({ json })
).pipe(Command.withDescription("Sync Coinbase records"))
const replayCommand = Command.make("replay", { json: jsonOption }, ({ json }) =>
replayProgram({ json })
).pipe(Command.withDescription("Reset and replay Coinbase records from cached raw data"))
const calculateCommand = Command.make(
"calculate",
{ year: yearOption, json: jsonOption },
({ year, json }) => calculateProgram({ year, json })
).pipe(Command.withDescription("Calculate tax summary for a year"))
const coinbaseCommand = Command.make(
"coinbase",
{
year: yearWithDefaultOption,
json: jsonOption,
noBrowser: noBrowserOption,
force: forceOption,
},
({ year, json, noBrowser, force }) =>
Effect.gen(function* () {
yield* connectProgram({ json, noBrowser, force })
const syncSummary = yield* syncProgram({
json,
emitConsoleOutput: json,
})
const taxSummary = yield* calculateProgram({
year,
json,
emitConsoleOutput: json,
})
if (json) {
yield* printJson({
stage: "workflow_completed",
year: taxSummary.year,
currency: taxSummary.currency,
importedRecords: syncSummary.importedRecords,
failedRecords: syncSummary.failedRecords,
taxableGains: taxSummary.taxableGains,
taxableLosses: taxSummary.taxableLosses,
taxFreeGains: taxSummary.taxFreeGains,
incomeTotal: taxSummary.incomeTotal,
})
return
}
yield* printWorkflowSummary({
sync: syncSummary,
tax: taxSummary,
})
yield* Console.log("Done. View full details in the TaxMaxi web dashboard.")
})
).pipe(
Command.withDescription("Coinbase workflow commands"),
Command.withSubcommands([connectCommand, syncCommand, replayCommand, calculateCommand])
)
const command = Command.make("tax", {}).pipe(Command.withSubcommands([coinbaseCommand]))
const cli = Command.run(command, { name: "TaxMaxi CLI", version: packageJson.version })
const runtimeLayer = Layer.mergeAll(NodeContext.layer, NodeHttpClient.layer)
cli(process.argv).pipe(
Effect.catchAll((error) => {
const markFailedExit = Effect.sync(() => {
process.exitCode = 1
})
if (error instanceof CliCommandError) {
return Console.error(`Error: ${error.message}`).pipe(Effect.zipRight(markFailedExit))
}
return Console.error(`Unexpected error: ${getErrorMessage(error, "unknown")}`).pipe(
Effect.zipRight(markFailedExit)
)
}),
Effect.provide(runtimeLayer),
NodeRuntime.runMain
)
//# sourceMappingURL=index.js.map